|
| 1 | +using System.Text; |
| 2 | +using UniGetUI.PackageEngine.Interfaces; |
| 3 | + |
| 4 | +namespace UniGetUI.Avalonia.Infrastructure; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// A <c>unigetui://package?manager=…&source=…&id=…</c> deep link. It names a package so |
| 8 | +/// that the recipient's UniGetUI can look it up in its own managers and open the package details, |
| 9 | +/// from where the user installs it. A link identifies a package and nothing else: it never carries |
| 10 | +/// installer URLs, arguments or other install options, which always come from the recipient's |
| 11 | +/// own settings. |
| 12 | +/// </summary> |
| 13 | +internal sealed record PackageLink(string Id, string? Manager, string? Source, string? Version, string? Name) |
| 14 | +{ |
| 15 | + public const string Action = "package"; |
| 16 | + |
| 17 | + private const string SchemePrefix = "unigetui:"; |
| 18 | + |
| 19 | + // Long enough for any real package identity, short enough that a crafted link cannot hand |
| 20 | + // the package lookup an arbitrarily large query. |
| 21 | + internal const int MaxLinkLength = 2048; |
| 22 | + internal const int MaxValueLength = 256; |
| 23 | + |
| 24 | + /// <summary> |
| 25 | + /// Whether <paramref name="arg"/> is a <c>unigetui:</c> link to the package action, valid or not. |
| 26 | + /// </summary> |
| 27 | + public static bool IsPackageLink(string? arg) |
| 28 | + => TrySplit(arg, out string action, out _) |
| 29 | + && action.Equals(Action, StringComparison.OrdinalIgnoreCase); |
| 30 | + |
| 31 | + /// <summary> |
| 32 | + /// Parses a package link, or returns null when <paramref name="arg"/> is not one or is malformed: |
| 33 | + /// no <c>id</c>, a repeated parameter, a control character, or a value or link over the size limits. |
| 34 | + /// Unknown parameters are ignored, so a newer link still opens in an older version. |
| 35 | + /// </summary> |
| 36 | + public static PackageLink? TryParse(string? arg) |
| 37 | + { |
| 38 | + if (arg is null || arg.Length > MaxLinkLength) |
| 39 | + return null; |
| 40 | + |
| 41 | + if (!TrySplit(arg, out string action, out string query) |
| 42 | + || !action.Equals(Action, StringComparison.OrdinalIgnoreCase)) |
| 43 | + return null; |
| 44 | + |
| 45 | + Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase); |
| 46 | + foreach (string pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) |
| 47 | + { |
| 48 | + int separator = pair.IndexOf('='); |
| 49 | + string rawKey = separator < 0 ? pair : pair[..separator]; |
| 50 | + string rawValue = separator < 0 ? "" : pair[(separator + 1)..]; |
| 51 | + |
| 52 | + if (!TryDecode(rawKey, out string key) || !TryDecode(rawValue, out string value)) |
| 53 | + return null; |
| 54 | + |
| 55 | + // A repeated parameter makes the link ambiguous about which package it names. |
| 56 | + if (!values.TryAdd(key, value)) |
| 57 | + return null; |
| 58 | + } |
| 59 | + |
| 60 | + if (Get("id") is not { } id) |
| 61 | + return null; |
| 62 | + |
| 63 | + return new PackageLink(id, Get("manager"), Get("source"), Get("version"), Get("name")); |
| 64 | + |
| 65 | + string? Get(string key) |
| 66 | + => values.TryGetValue(key, out string? value) && value.Trim() is { Length: > 0 } trimmed |
| 67 | + ? trimmed |
| 68 | + : null; |
| 69 | + } |
| 70 | + |
| 71 | + /// <summary> |
| 72 | + /// Whether a link can name <paramref name="package"/>: packages that only exist on this |
| 73 | + /// machine, such as the WinGet "Local PC" entries, cannot be found from another one. |
| 74 | + /// </summary> |
| 75 | + public static bool CanLinkTo(IPackage package) => !package.Source.IsVirtualManager; |
| 76 | + |
| 77 | + public static PackageLink FromPackage(IPackage package) => new( |
| 78 | + package.Id, |
| 79 | + package.Manager.Id, |
| 80 | + // Managers without custom sources only have their default source, so naming it adds nothing. |
| 81 | + package.Manager.Capabilities.SupportsCustomSources ? package.Source.Name : null, |
| 82 | + Version: null, |
| 83 | + // The name is only shown while looking the package up; drop it rather than produce a |
| 84 | + // link that the parser would refuse. |
| 85 | + package.Name.Length <= MaxValueLength ? package.Name : null); |
| 86 | + |
| 87 | + public string ToUri() |
| 88 | + { |
| 89 | + StringBuilder uri = new($"{SchemePrefix}//{Action}"); |
| 90 | + char separator = '?'; |
| 91 | + Append("manager", Manager); |
| 92 | + Append("source", Source); |
| 93 | + Append("id", Id); |
| 94 | + Append("version", Version); |
| 95 | + Append("name", Name); |
| 96 | + return uri.ToString(); |
| 97 | + |
| 98 | + void Append(string key, string? value) |
| 99 | + { |
| 100 | + if (string.IsNullOrEmpty(value)) |
| 101 | + return; |
| 102 | + |
| 103 | + uri.Append(separator).Append(key).Append('=').Append(Uri.EscapeDataString(value)); |
| 104 | + separator = '&'; |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + /// <summary>The package as it should be named before it is found: its name and id, or its id.</summary> |
| 109 | + public string DisplayName |
| 110 | + => Name is null || Name.Equals(Id, StringComparison.OrdinalIgnoreCase) ? Id : $"{Name} ({Id})"; |
| 111 | + |
| 112 | + private static bool TrySplit(string? arg, out string action, out string query) |
| 113 | + { |
| 114 | + action = ""; |
| 115 | + query = ""; |
| 116 | + |
| 117 | + if (arg is null || !arg.StartsWith(SchemePrefix, StringComparison.OrdinalIgnoreCase)) |
| 118 | + return false; |
| 119 | + |
| 120 | + string rest = arg[SchemePrefix.Length..]; |
| 121 | + |
| 122 | + int fragment = rest.IndexOf('#'); |
| 123 | + if (fragment >= 0) |
| 124 | + rest = rest[..fragment]; |
| 125 | + |
| 126 | + int queryStart = rest.IndexOf('?'); |
| 127 | + if (queryStart >= 0) |
| 128 | + { |
| 129 | + query = rest[(queryStart + 1)..]; |
| 130 | + rest = rest[..queryStart]; |
| 131 | + } |
| 132 | + |
| 133 | + // unigetui://package, unigetui:package and the unigetui://package/ some browsers produce |
| 134 | + action = rest.Trim('/'); |
| 135 | + return true; |
| 136 | + } |
| 137 | + |
| 138 | + private static bool TryDecode(string raw, out string decoded) |
| 139 | + { |
| 140 | + decoded = Uri.UnescapeDataString(raw.Replace('+', ' ')); |
| 141 | + return decoded.Length <= MaxValueLength && !decoded.Any(char.IsControl); |
| 142 | + } |
| 143 | +} |
0 commit comments