Skip to content

Commit 1d9fea3

Browse files
Open packages from unigetui://package links (#5611)
1 parent 1badf37 commit 1d9fea3

13 files changed

Lines changed: 587 additions & 15 deletions

File tree

‎docs/CLI.md‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,35 @@ These are read by the application itself rather than by the CLI client.
372372

373373
## Deep links
374374

375-
The Windows installer registers a `unigetui://` protocol handler for regular installations; a portable install does not register it, see [PORTABLE.md](PORTABLE.md). It is used to route notification clicks back into a running instance, and accepts these actions:
375+
The Windows installer registers a `unigetui://` protocol handler for regular installations; a portable install does not register it, see [PORTABLE.md](PORTABLE.md).
376+
377+
### Package links
378+
379+
A package link opens a package's details in UniGetUI, from where the user can install it:
380+
381+
```
382+
unigetui://package?manager=winget&source=winget&id=TechSmith.Snagit.2025&name=Snagit%202025
383+
```
384+
385+
Right-click a package on the Discover Packages or Installed Packages page and choose **Copy link** to put a link to it on the clipboard.
386+
387+
| Parameter | Required | Meaning |
388+
| --- | --- | --- |
389+
| `id` | Yes | The package identifier. It must match exactly, ignoring case. |
390+
| `manager` | No | The package manager's identifier, as `unigetui manager list` prints it (`winget`, `chocolatey`, `scoop`, `npm`, ...). Without it, every enabled package manager is searched. |
391+
| `source` | No | The source name, for package managers with several sources (`winget`, `msstore`, `community`, ...). Without it, any source matches. |
392+
| `version` | No | The version to install instead of the latest one. |
393+
| `name` | No | The package's name. It is only shown while UniGetUI looks for the package and if the package is not found; it is never used to find the package. |
394+
395+
Values are percent-encoded. Unknown parameters are ignored. A link is refused if it has no `id`, repeats a parameter, contains a control character, has a value longer than 256 characters, or is longer than 2048 characters.
396+
397+
A link only names a package. Opening one never installs anything by itself: it shows the package details, and the package is installed only if the user clicks Install. The installation uses the user's own install options for that package, and no installer address, argument or other install option can be passed in a link. A package that is only known to this computer, such as a WinGet "Local PC" entry, cannot be linked to.
398+
399+
The link works whether or not UniGetUI is already running. If the package manager is disabled or not available, or the package is not found, UniGetUI says so and opens nothing.
400+
401+
### Notification actions
402+
403+
These links route notification clicks back into a running instance:
376404

377405
| Deep link | Meaning |
378406
| --- | --- |
@@ -381,7 +409,7 @@ The Windows installer registers a `unigetui://` protocol handler for regular ins
381409
| `unigetui://updateAll` | Starts an update for every available package update. |
382410
| `unigetui://releaseSelfUpdateLock` | Allows a pending UniGetUI self-update to proceed. |
383411

384-
Anything else after `unigetui://` is ignored. The action is only dispatched when UniGetUI is already running: a link that cold-starts the app launches it normally and the action is dropped. To drive UniGetUI programmatically, use the verb commands above or the [IPC API](IPC.md) rather than deep links.
412+
Anything else after `unigetui://` is ignored. A notification action is only dispatched when UniGetUI is already running: a link that cold-starts the app launches it normally and the action is dropped. To drive UniGetUI programmatically, use the verb commands above or the [IPC API](IPC.md) rather than deep links.
385413

386414
## Installer parameters
387415

‎src/Languages/lang_en.json‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2004,5 +2004,13 @@
20042004
"UniGetUI could not replace {0}, so it is still running the previous version. Open the log to see what happened.": "UniGetUI could not replace {0}, so it is still running the previous version. Open the log to see what happened.",
20052005
"UniGetUI cannot update itself from a read-only location.": "UniGetUI cannot update itself from a read-only location.",
20062006
"UniGetUI is running from {0}, which cannot be modified, for example a mounted disk image. Move UniGetUI to the Applications folder, open it from there, and try again.": "UniGetUI is running from {0}, which cannot be modified, for example a mounted disk image. Move UniGetUI to the Applications folder, open it from there, and try again.",
2007-
"Copy link": "Copy link"
2007+
"Copy link": "Copy link",
2008+
"Looking for {0}...": "Looking for {0}...",
2009+
"This version of UniGetUI does not support the package manager {0}.": "This version of UniGetUI does not support the package manager {0}.",
2010+
"{0} is disabled. Enable it in the settings to open this package.": "{0} is disabled. Enable it in the settings to open this package.",
2011+
"{0} is not available on this computer.": "{0} is not available on this computer.",
2012+
"{0} was not found.": "{0} was not found.",
2013+
"{0} was not found in {1}.": "{0} was not found in {1}.",
2014+
"The package link could not be opened": "The package link could not be opened",
2015+
"A link to {0} was copied to the clipboard": "A link to {0} was copied to the clipboard"
20082016
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using System.Text;
2+
using UniGetUI.PackageEngine.Interfaces;
3+
4+
namespace UniGetUI.Avalonia.Infrastructure;
5+
6+
/// <summary>
7+
/// A <c>unigetui://package?manager=…&amp;source=…&amp;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+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using UniGetUI.Avalonia.ViewModels;
2+
using UniGetUI.Avalonia.ViewModels.Pages;
3+
using UniGetUI.Avalonia.Views;
4+
using UniGetUI.Core.Logging;
5+
using UniGetUI.Core.Tools;
6+
using UniGetUI.Interface;
7+
using UniGetUI.Interface.Telemetry;
8+
using UniGetUI.PackageEngine;
9+
using UniGetUI.PackageEngine.Enums;
10+
using UniGetUI.PackageEngine.Interfaces;
11+
12+
namespace UniGetUI.Avalonia.Infrastructure;
13+
14+
/// <summary>
15+
/// Opens a <see cref="PackageLink"/>: looks the package up in the recipient's own managers and
16+
/// shows its details, from where the user decides whether to install it. Nothing is installed
17+
/// without that click.
18+
/// </summary>
19+
internal static class PackageLinkHandler
20+
{
21+
public static async Task OpenAsync(PackageLink link, MainWindow window)
22+
{
23+
Logger.ImportantInfo($"Opening the package link {link.ToUri()}");
24+
MainWindowViewModel? viewModel = window.DataContext as MainWindowViewModel;
25+
26+
InfoBarViewModel searching = new()
27+
{
28+
Title = CoreTools.Translate("Looking for {0}...", link.DisplayName),
29+
Severity = InfoBarSeverity.Informational,
30+
IsClosable = false,
31+
IsOpen = true,
32+
};
33+
searching.OnClosed = () => viewModel?.DismissToast(searching);
34+
viewModel?.ShowToast(searching);
35+
36+
IPackage? package = null;
37+
string? problem = DescribeUnavailableManager(link);
38+
try
39+
{
40+
if (problem is null)
41+
{
42+
package = await Task.Run(() => IpcPackageApi.FindCatalogPackage(new IpcPackageActionRequest
43+
{
44+
PackageId = link.Id,
45+
ManagerName = link.Manager,
46+
PackageSource = link.Source,
47+
}));
48+
}
49+
}
50+
catch (Exception ex)
51+
{
52+
Logger.Warn($"The package link {link.ToUri()} did not resolve to a package");
53+
Logger.Warn(ex);
54+
}
55+
finally
56+
{
57+
viewModel?.DismissToast(searching);
58+
}
59+
60+
if (package is null)
61+
{
62+
ShowProblem(viewModel, problem ?? DescribeNotFound(link));
63+
return;
64+
}
65+
66+
var dialog = new PackageDetailsWindow(package, OperationType.Install, TEL_InstallReferral.FROM_LINK)
67+
{
68+
InstallVersion = link.Version,
69+
};
70+
await dialog.ShowDialog(window);
71+
72+
if (dialog.ShouldProceedWithOperation)
73+
await PackagesPageViewModel.LaunchInstall(
74+
[package], version: link.Version, referral: TEL_InstallReferral.FROM_LINK);
75+
}
76+
77+
private static string? DescribeUnavailableManager(PackageLink link)
78+
{
79+
// A link without a manager searches every ready manager.
80+
if (link.Manager is null)
81+
return null;
82+
83+
IPackageManager? manager = PEInterface.Managers.FirstOrDefault(candidate =>
84+
candidate.Id.Equals(link.Manager, StringComparison.OrdinalIgnoreCase));
85+
86+
if (manager is null)
87+
return CoreTools.Translate("This version of UniGetUI does not support the package manager {0}.", link.Manager);
88+
89+
if (!manager.IsEnabled())
90+
return CoreTools.Translate("{0} is disabled. Enable it in the settings to open this package.", manager.DisplayName);
91+
92+
if (!manager.IsReady())
93+
return CoreTools.Translate("{0} is not available on this computer.", manager.DisplayName);
94+
95+
return null;
96+
}
97+
98+
private static string DescribeNotFound(PackageLink link)
99+
{
100+
string? managerName = link.Manager is null
101+
? null
102+
: PEInterface.Managers.FirstOrDefault(candidate =>
103+
candidate.Id.Equals(link.Manager, StringComparison.OrdinalIgnoreCase))?.DisplayName;
104+
105+
return managerName is null
106+
? CoreTools.Translate("{0} was not found.", link.DisplayName)
107+
: CoreTools.Translate("{0} was not found in {1}.", link.DisplayName, managerName);
108+
}
109+
110+
private static void ShowProblem(MainWindowViewModel? viewModel, string message)
111+
{
112+
Logger.Warn(message);
113+
if (viewModel is null)
114+
return;
115+
116+
InfoBarViewModel toast = new()
117+
{
118+
Title = CoreTools.Translate("The package link could not be opened"),
119+
Message = message,
120+
Severity = InfoBarSeverity.Error,
121+
IsClosable = true,
122+
IsOpen = true,
123+
};
124+
toast.OnClosed = () => viewModel.DismissToast(toast);
125+
viewModel.ShowToast(toast);
126+
}
127+
}

0 commit comments

Comments
 (0)