diff --git a/docs/CLI.md b/docs/CLI.md
index 34c4ba40b2..3472069624 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -372,7 +372,35 @@ These are read by the application itself rather than by the CLI client.
## Deep links
-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:
+The Windows installer registers a `unigetui://` protocol handler for regular installations; a portable install does not register it, see [PORTABLE.md](PORTABLE.md).
+
+### Package links
+
+A package link opens a package's details in UniGetUI, from where the user can install it:
+
+```
+unigetui://package?manager=winget&source=winget&id=TechSmith.Snagit.2025&name=Snagit%202025
+```
+
+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.
+
+| Parameter | Required | Meaning |
+| --- | --- | --- |
+| `id` | Yes | The package identifier. It must match exactly, ignoring case. |
+| `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. |
+| `source` | No | The source name, for package managers with several sources (`winget`, `msstore`, `community`, ...). Without it, any source matches. |
+| `version` | No | The version to install instead of the latest one. |
+| `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. |
+
+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.
+
+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.
+
+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.
+
+### Notification actions
+
+These links route notification clicks back into a running instance:
| Deep link | Meaning |
| --- | --- |
@@ -381,7 +409,7 @@ The Windows installer registers a `unigetui://` protocol handler for regular ins
| `unigetui://updateAll` | Starts an update for every available package update. |
| `unigetui://releaseSelfUpdateLock` | Allows a pending UniGetUI self-update to proceed. |
-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.
+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.
## Installer parameters
diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json
index 7f56d229ec..8f57852cfd 100644
--- a/src/Languages/lang_en.json
+++ b/src/Languages/lang_en.json
@@ -2004,5 +2004,13 @@
"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.",
"UniGetUI cannot update itself from a read-only location.": "UniGetUI cannot update itself from a read-only location.",
"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.",
- "Copy link": "Copy link"
+ "Copy link": "Copy link",
+ "Looking for {0}...": "Looking for {0}...",
+ "This version of UniGetUI does not support the package manager {0}.": "This version of UniGetUI does not support the package manager {0}.",
+ "{0} is disabled. Enable it in the settings to open this package.": "{0} is disabled. Enable it in the settings to open this package.",
+ "{0} is not available on this computer.": "{0} is not available on this computer.",
+ "{0} was not found.": "{0} was not found.",
+ "{0} was not found in {1}.": "{0} was not found in {1}.",
+ "The package link could not be opened": "The package link could not be opened",
+ "A link to {0} was copied to the clipboard": "A link to {0} was copied to the clipboard"
}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/PackageLink.cs b/src/UniGetUI.Avalonia/Infrastructure/PackageLink.cs
new file mode 100644
index 0000000000..b047c510af
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/PackageLink.cs
@@ -0,0 +1,143 @@
+using System.Text;
+using UniGetUI.PackageEngine.Interfaces;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+///
+/// A unigetui://package?manager=…&source=…&id=… deep link. It names a package so
+/// that the recipient's UniGetUI can look it up in its own managers and open the package details,
+/// from where the user installs it. A link identifies a package and nothing else: it never carries
+/// installer URLs, arguments or other install options, which always come from the recipient's
+/// own settings.
+///
+internal sealed record PackageLink(string Id, string? Manager, string? Source, string? Version, string? Name)
+{
+ public const string Action = "package";
+
+ private const string SchemePrefix = "unigetui:";
+
+ // Long enough for any real package identity, short enough that a crafted link cannot hand
+ // the package lookup an arbitrarily large query.
+ internal const int MaxLinkLength = 2048;
+ internal const int MaxValueLength = 256;
+
+ ///
+ /// Whether is a unigetui: link to the package action, valid or not.
+ ///
+ public static bool IsPackageLink(string? arg)
+ => TrySplit(arg, out string action, out _)
+ && action.Equals(Action, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Parses a package link, or returns null when is not one or is malformed:
+ /// no id, a repeated parameter, a control character, or a value or link over the size limits.
+ /// Unknown parameters are ignored, so a newer link still opens in an older version.
+ ///
+ public static PackageLink? TryParse(string? arg)
+ {
+ if (arg is null || arg.Length > MaxLinkLength)
+ return null;
+
+ if (!TrySplit(arg, out string action, out string query)
+ || !action.Equals(Action, StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ Dictionary values = new(StringComparer.OrdinalIgnoreCase);
+ foreach (string pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ int separator = pair.IndexOf('=');
+ string rawKey = separator < 0 ? pair : pair[..separator];
+ string rawValue = separator < 0 ? "" : pair[(separator + 1)..];
+
+ if (!TryDecode(rawKey, out string key) || !TryDecode(rawValue, out string value))
+ return null;
+
+ // A repeated parameter makes the link ambiguous about which package it names.
+ if (!values.TryAdd(key, value))
+ return null;
+ }
+
+ if (Get("id") is not { } id)
+ return null;
+
+ return new PackageLink(id, Get("manager"), Get("source"), Get("version"), Get("name"));
+
+ string? Get(string key)
+ => values.TryGetValue(key, out string? value) && value.Trim() is { Length: > 0 } trimmed
+ ? trimmed
+ : null;
+ }
+
+ ///
+ /// Whether a link can name : packages that only exist on this
+ /// machine, such as the WinGet "Local PC" entries, cannot be found from another one.
+ ///
+ public static bool CanLinkTo(IPackage package) => !package.Source.IsVirtualManager;
+
+ public static PackageLink FromPackage(IPackage package) => new(
+ package.Id,
+ package.Manager.Id,
+ // Managers without custom sources only have their default source, so naming it adds nothing.
+ package.Manager.Capabilities.SupportsCustomSources ? package.Source.Name : null,
+ Version: null,
+ // The name is only shown while looking the package up; drop it rather than produce a
+ // link that the parser would refuse.
+ package.Name.Length <= MaxValueLength ? package.Name : null);
+
+ public string ToUri()
+ {
+ StringBuilder uri = new($"{SchemePrefix}//{Action}");
+ char separator = '?';
+ Append("manager", Manager);
+ Append("source", Source);
+ Append("id", Id);
+ Append("version", Version);
+ Append("name", Name);
+ return uri.ToString();
+
+ void Append(string key, string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return;
+
+ uri.Append(separator).Append(key).Append('=').Append(Uri.EscapeDataString(value));
+ separator = '&';
+ }
+ }
+
+ /// The package as it should be named before it is found: its name and id, or its id.
+ public string DisplayName
+ => Name is null || Name.Equals(Id, StringComparison.OrdinalIgnoreCase) ? Id : $"{Name} ({Id})";
+
+ private static bool TrySplit(string? arg, out string action, out string query)
+ {
+ action = "";
+ query = "";
+
+ if (arg is null || !arg.StartsWith(SchemePrefix, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ string rest = arg[SchemePrefix.Length..];
+
+ int fragment = rest.IndexOf('#');
+ if (fragment >= 0)
+ rest = rest[..fragment];
+
+ int queryStart = rest.IndexOf('?');
+ if (queryStart >= 0)
+ {
+ query = rest[(queryStart + 1)..];
+ rest = rest[..queryStart];
+ }
+
+ // unigetui://package, unigetui:package and the unigetui://package/ some browsers produce
+ action = rest.Trim('/');
+ return true;
+ }
+
+ private static bool TryDecode(string raw, out string decoded)
+ {
+ decoded = Uri.UnescapeDataString(raw.Replace('+', ' '));
+ return decoded.Length <= MaxValueLength && !decoded.Any(char.IsControl);
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/PackageLinkHandler.cs b/src/UniGetUI.Avalonia/Infrastructure/PackageLinkHandler.cs
new file mode 100644
index 0000000000..296738d155
--- /dev/null
+++ b/src/UniGetUI.Avalonia/Infrastructure/PackageLinkHandler.cs
@@ -0,0 +1,127 @@
+using UniGetUI.Avalonia.ViewModels;
+using UniGetUI.Avalonia.ViewModels.Pages;
+using UniGetUI.Avalonia.Views;
+using UniGetUI.Core.Logging;
+using UniGetUI.Core.Tools;
+using UniGetUI.Interface;
+using UniGetUI.Interface.Telemetry;
+using UniGetUI.PackageEngine;
+using UniGetUI.PackageEngine.Enums;
+using UniGetUI.PackageEngine.Interfaces;
+
+namespace UniGetUI.Avalonia.Infrastructure;
+
+///
+/// Opens a : looks the package up in the recipient's own managers and
+/// shows its details, from where the user decides whether to install it. Nothing is installed
+/// without that click.
+///
+internal static class PackageLinkHandler
+{
+ public static async Task OpenAsync(PackageLink link, MainWindow window)
+ {
+ Logger.ImportantInfo($"Opening the package link {link.ToUri()}");
+ MainWindowViewModel? viewModel = window.DataContext as MainWindowViewModel;
+
+ InfoBarViewModel searching = new()
+ {
+ Title = CoreTools.Translate("Looking for {0}...", link.DisplayName),
+ Severity = InfoBarSeverity.Informational,
+ IsClosable = false,
+ IsOpen = true,
+ };
+ searching.OnClosed = () => viewModel?.DismissToast(searching);
+ viewModel?.ShowToast(searching);
+
+ IPackage? package = null;
+ string? problem = DescribeUnavailableManager(link);
+ try
+ {
+ if (problem is null)
+ {
+ package = await Task.Run(() => IpcPackageApi.FindCatalogPackage(new IpcPackageActionRequest
+ {
+ PackageId = link.Id,
+ ManagerName = link.Manager,
+ PackageSource = link.Source,
+ }));
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"The package link {link.ToUri()} did not resolve to a package");
+ Logger.Warn(ex);
+ }
+ finally
+ {
+ viewModel?.DismissToast(searching);
+ }
+
+ if (package is null)
+ {
+ ShowProblem(viewModel, problem ?? DescribeNotFound(link));
+ return;
+ }
+
+ var dialog = new PackageDetailsWindow(package, OperationType.Install, TEL_InstallReferral.FROM_LINK)
+ {
+ InstallVersion = link.Version,
+ };
+ await dialog.ShowDialog(window);
+
+ if (dialog.ShouldProceedWithOperation)
+ await PackagesPageViewModel.LaunchInstall(
+ [package], version: link.Version, referral: TEL_InstallReferral.FROM_LINK);
+ }
+
+ private static string? DescribeUnavailableManager(PackageLink link)
+ {
+ // A link without a manager searches every ready manager.
+ if (link.Manager is null)
+ return null;
+
+ IPackageManager? manager = PEInterface.Managers.FirstOrDefault(candidate =>
+ candidate.Id.Equals(link.Manager, StringComparison.OrdinalIgnoreCase));
+
+ if (manager is null)
+ return CoreTools.Translate("This version of UniGetUI does not support the package manager {0}.", link.Manager);
+
+ if (!manager.IsEnabled())
+ return CoreTools.Translate("{0} is disabled. Enable it in the settings to open this package.", manager.DisplayName);
+
+ if (!manager.IsReady())
+ return CoreTools.Translate("{0} is not available on this computer.", manager.DisplayName);
+
+ return null;
+ }
+
+ private static string DescribeNotFound(PackageLink link)
+ {
+ string? managerName = link.Manager is null
+ ? null
+ : PEInterface.Managers.FirstOrDefault(candidate =>
+ candidate.Id.Equals(link.Manager, StringComparison.OrdinalIgnoreCase))?.DisplayName;
+
+ return managerName is null
+ ? CoreTools.Translate("{0} was not found.", link.DisplayName)
+ : CoreTools.Translate("{0} was not found in {1}.", link.DisplayName, managerName);
+ }
+
+ private static void ShowProblem(MainWindowViewModel? viewModel, string message)
+ {
+ Logger.Warn(message);
+ if (viewModel is null)
+ return;
+
+ InfoBarViewModel toast = new()
+ {
+ Title = CoreTools.Translate("The package link could not be opened"),
+ Message = message,
+ Severity = InfoBarSeverity.Error,
+ IsClosable = true,
+ IsOpen = true,
+ };
+ toast.OnClosed = () => viewModel.DismissToast(toast);
+ viewModel.ShowToast(toast);
+ }
+}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/StartupArgumentProcessor.cs b/src/UniGetUI.Avalonia/Infrastructure/StartupArgumentProcessor.cs
index beb12e4f4b..d4a8d4112e 100644
--- a/src/UniGetUI.Avalonia/Infrastructure/StartupArgumentProcessor.cs
+++ b/src/UniGetUI.Avalonia/Infrastructure/StartupArgumentProcessor.cs
@@ -20,6 +20,26 @@ public static void WarnIfBundlesIgnored(IReadOnlyList args, string reaso
}
}
+ ///
+ /// Returns the first unigetui://package link in , logging and
+ /// skipping malformed ones.
+ ///
+ internal static PackageLink? FindPackageLink(IReadOnlyList args)
+ {
+ foreach (string arg in args)
+ {
+ if (!PackageLink.IsPackageLink(arg))
+ continue;
+
+ if (PackageLink.TryParse(arg) is { } link)
+ return link;
+
+ Logger.Warn($"Ignoring a malformed package link: {arg}");
+ }
+
+ return null;
+ }
+
public static async Task ProcessAsync(IReadOnlyList args)
{
if (args.Count == 0)
@@ -27,12 +47,20 @@ public static async Task ProcessAsync(IReadOnlyList args)
return;
}
- List bundles = StartupBundleArguments.Resolve(args, Environment.CurrentDirectory);
- if (bundles.Count == 0)
+ // A protocol launch carries nothing but its link, so a link and a bundle never come together.
+ PackageLink? link = FindPackageLink(args);
+ List bundles = link is null
+ ? StartupBundleArguments.Resolve(args, Environment.CurrentDirectory)
+ : [];
+ if (link is null && bundles.Count == 0)
{
return;
}
+ string request = link is not null
+ ? $"the package link {link.ToUri()}"
+ : $"the package bundle {bundles[0]}";
+
if (bundles.Count > 1)
{
Logger.Warn(
@@ -41,20 +69,19 @@ public static async Task ProcessAsync(IReadOnlyList args)
if (!await AvaloniaBootstrapper.Initialized)
{
- Logger.Warn(
- $"Could not load the package bundle {bundles[0]}: UniGetUI failed to finish initializing");
+ Logger.Warn($"Could not open {request}: UniGetUI failed to finish initializing");
return;
}
if (MainWindow.Instance is not { } window)
{
- Logger.Warn($"Could not load the package bundle {bundles[0]}: the main window is not available");
+ Logger.Warn($"Could not open {request}: the main window is not available");
return;
}
if (window.DataContext is not MainWindowViewModel viewModel)
{
- Logger.Warn($"Could not load the package bundle {bundles[0]}: the main window has no view model");
+ Logger.Warn($"Could not open {request}: the main window has no view model");
return;
}
@@ -63,6 +90,12 @@ public static async Task ProcessAsync(IReadOnlyList args)
window.ShowFromTray();
}
+ if (link is not null)
+ {
+ await PackageLinkHandler.OpenAsync(link, window);
+ return;
+ }
+
Logger.ImportantInfo($"Loading the package bundle {bundles[0]} requested on the command line");
await viewModel.LoadBundleFromFileAsync(bundles[0]);
}
diff --git a/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs b/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs
index d6b07d5402..bcbb2a96a4 100644
--- a/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs
+++ b/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs
@@ -35,7 +35,8 @@ internal static class WindowsAppNotificationBridge
///
public static string? TryParseToastLaunchArgument(string arg)
{
- if (string.IsNullOrEmpty(arg))
+ // Package links name a package rather than an action; StartupArgumentProcessor opens them.
+ if (string.IsNullOrEmpty(arg) || PackageLink.IsPackageLink(arg))
return null;
const string prefix = "unigetui://";
diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs
index e17c60b1ee..e78ea8e69e 100644
--- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs
+++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/PackagesPageViewModel.cs
@@ -1247,7 +1247,8 @@ public static async Task LaunchInstall(
bool? elevated = null,
bool? interactive = null,
bool? no_integrity = null,
- string? version = null)
+ string? version = null,
+ TEL_InstallReferral referral = TEL_InstallReferral.DIRECT_SEARCH)
{
foreach (var pkg in packages)
{
@@ -1256,8 +1257,8 @@ public static async Task LaunchInstall(
if (!string.IsNullOrEmpty(version)) opts.Version = version;
if (PackageOperation.HasPendingOperation(pkg, OperationType.Install)) continue;
var op = new InstallPackageOperation(pkg, opts);
- op.OperationSucceeded += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.SUCCESS, TEL_InstallReferral.DIRECT_SEARCH);
- op.OperationFailed += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.FAILED, TEL_InstallReferral.DIRECT_SEARCH);
+ op.OperationSucceeded += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.SUCCESS, referral);
+ op.OperationFailed += (_, _) => TelemetryHandler.InstallPackage(pkg, TEL_OP_RESULT.FAILED, referral);
AvaloniaOperationRegistry.Add(op);
_ = op.MainThread();
}
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs
index 76e4fc3e92..f9255053a6 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml.cs
@@ -19,6 +19,7 @@
using Avalonia.VisualTree;
using UniGetUI.Avalonia.Extensions;
using UniGetUI.Avalonia.Infrastructure;
+using UniGetUI.Avalonia.ViewModels;
using UniGetUI.Avalonia.ViewModels.Pages;
using UniGetUI.Avalonia.Views.Controls;
using UniGetUI.Core.Logging;
@@ -341,6 +342,33 @@ protected virtual void WhenShowingContextMenu(IPackage package) { }
Height = 16,
};
+ /// "Copy link": puts a unigetui://package link to the selected package on the clipboard.
+ protected MenuItem CreateCopyLinkMenuItem()
+ {
+ var item = new MenuItem { Header = CoreTools.Translate("Copy link"), Icon = LoadMenuIcon("share") };
+ item.Click += (_, _) => _ = CopyPackageLinkAsync(SelectedItem);
+ return item;
+ }
+
+ private static async Task CopyPackageLinkAsync(IPackage? package)
+ {
+ if (package is null || !PackageLink.CanLinkTo(package)
+ || GetMainWindow() is not { } win || win.Clipboard is not { } clipboard) return;
+
+ await clipboard.SetTextAsync(PackageLink.FromPackage(package).ToUri());
+
+ if (win.DataContext is not MainWindowViewModel main) return;
+ var toast = new InfoBarViewModel
+ {
+ Title = CoreTools.Translate("A link to {0} was copied to the clipboard", package.Name),
+ Severity = InfoBarSeverity.Success,
+ IsClosable = true,
+ IsOpen = true,
+ };
+ toast.OnClosed = () => main.DismissToast(toast);
+ main.ShowToast(toast);
+ }
+
// ─── Protected access to main toolbar controls for subclasses ─────────────
/// Sets the icon and text of the primary action button.
protected void SetMainButton(string svgName, string label, Action onClick)
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/DiscoverSoftwarePage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/DiscoverSoftwarePage.cs
index 77fbb5bd8e..778da2cf46 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/DiscoverSoftwarePage.cs
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/DiscoverSoftwarePage.cs
@@ -28,6 +28,7 @@ public class DiscoverSoftwarePage : AbstractPackagesPage
private MenuItem? _menuInteractive;
private MenuItem? _menuSkipHash;
private MenuItem? _menuDownloadInstaller;
+ private MenuItem? _menuCopyLink;
public DiscoverSoftwarePage(CatalogEditorViewModel? catalogEditor = null) : base(new PackagesPageData
{
@@ -272,6 +273,8 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
var menuDetails = new MenuItem { Header = ShortcutHeader(CoreTools.Translate("Package details"), DetailsShortcut), Icon = LoadMenuIcon("info_round") };
menuDetails.Click += (_, _) => _ = ShowDetailsForPackage(SelectedItem);
+ _menuCopyLink = CreateCopyLinkMenuItem();
+
var menu = new ContextMenu();
menu.Items.Add(menuInstall);
menu.Items.Add(new Separator());
@@ -284,6 +287,7 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
menu.Items.Add(_menuDownloadInstaller);
menu.Items.Add(new Separator());
menu.Items.Add(menuDetails);
+ menu.Items.Add(_menuCopyLink);
return menu;
}
@@ -291,7 +295,7 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
protected override void WhenShowingContextMenu(IPackage package)
{
if (_menuAsAdmin is null || _menuInteractive is null
- || _menuSkipHash is null || _menuDownloadInstaller is null)
+ || _menuSkipHash is null || _menuDownloadInstaller is null || _menuCopyLink is null)
{
Logger.Warn("Context menu items are null on DiscoverSoftwarePage");
return;
@@ -301,6 +305,7 @@ protected override void WhenShowingContextMenu(IPackage package)
_menuInteractive.IsEnabled = package.Manager.Capabilities.CanRunInteractively;
_menuSkipHash.IsEnabled = package.Manager.Capabilities.CanSkipIntegrityChecks;
_menuDownloadInstaller.IsEnabled = package.Manager.Capabilities.CanDownloadInstaller;
+ _menuCopyLink.IsEnabled = PackageLink.CanLinkTo(package);
}
// ─── Abstract action overrides ────────────────────────────────────────────
diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs
index 9b65e7ecd9..133bd22504 100644
--- a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs
+++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs
@@ -31,6 +31,7 @@ public class InstalledPackagesPage : AbstractPackagesPage
private MenuItem? _menuUninstallThenReinstall;
private MenuItem? _menuIgnoreUpdates;
private MenuItem? _menuDetails;
+ private MenuItem? _menuCopyLink;
private MenuItem? _menuOpenInstallLocation;
private MenuItem? _menuDownloadInstaller;
private MenuItem? _menuManual;
@@ -239,6 +240,8 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
};
_menuDetails.Click += (_, _) => _ = ShowDetailsForPackage(SelectedItem);
+ _menuCopyLink = CreateCopyLinkMenuItem();
+
var menu = new ContextMenu();
menu.Items.Add(menuUninstall);
menu.Items.Add(new Separator());
@@ -261,6 +264,7 @@ protected override void GenerateToolBar(PackagesPageViewModel vm)
menu.Items.Add(_menuIgnoreUpdates);
menu.Items.Add(new Separator());
menu.Items.Add(_menuDetails);
+ menu.Items.Add(_menuCopyLink);
return menu;
}
@@ -271,7 +275,7 @@ protected override void WhenShowingContextMenu(IPackage package)
|| _menuAsAdmin is null || _menuInteractive is null || _menuRemoveData is null
|| _menuInstallationOptions is null || _menuReinstall is null
|| _menuUninstallThenReinstall is null || _menuIgnoreUpdates is null
- || _menuDetails is null
+ || _menuDetails is null || _menuCopyLink is null
|| _menuOpenInstallLocation is null || _menuDownloadInstaller is null
|| _menuManual is null)
{
@@ -302,6 +306,7 @@ protected override void WhenShowingContextMenu(IPackage package)
_menuReinstall.IsEnabled = !isLocal;
_menuUninstallThenReinstall.IsEnabled = !isLocal;
_menuDetails.IsEnabled = !isLocal;
+ _menuCopyLink.IsEnabled = PackageLink.CanLinkTo(package);
_menuOpenInstallLocation.IsEnabled =
package.Manager.DetailsHelper.GetInstallLocation(package) is not null;
diff --git a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs
index 2c26359ed8..61ba2fc07c 100644
--- a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs
+++ b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs
@@ -374,6 +374,17 @@ public static IPackage ResolvePackage(IpcPackageActionRequest request)
return FindAnyPackage(request);
}
+ ///
+ /// Finds the package a manager search returns for the exact id (and source, when given),
+ /// as the Discover page would show it. Unlike it never loads
+ /// the installed or upgradable lists first, so it stays quick on a cold start.
+ ///
+ public static IPackage FindCatalogPackage(IpcPackageActionRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return FindSearchResult(request);
+ }
+
public static IReadOnlyList GetPackageVersions(IpcPackageActionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
diff --git a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs
index bdfca096a4..b3bf74242c 100644
--- a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs
+++ b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs
@@ -21,6 +21,7 @@ public enum TEL_InstallReferral
DIRECT_SEARCH,
FROM_BUNDLE,
ALREADY_INSTALLED,
+ FROM_LINK,
}
public enum TEL_OP_RESULT
diff --git a/src/UniGetUI.Tests/PackageLinkTests.cs b/src/UniGetUI.Tests/PackageLinkTests.cs
new file mode 100644
index 0000000000..5e565ec48f
--- /dev/null
+++ b/src/UniGetUI.Tests/PackageLinkTests.cs
@@ -0,0 +1,181 @@
+using UniGetUI.Avalonia.Infrastructure;
+using UniGetUI.PackageEngine.Classes.Manager;
+using UniGetUI.PackageEngine.PackageClasses;
+
+namespace UniGetUI.Tests;
+
+public sealed class PackageLinkTests
+{
+ [Fact]
+ public void Parse_ReadsEveryParameter()
+ {
+ PackageLink? link = PackageLink.TryParse(
+ "unigetui://package?manager=winget&source=msstore&id=TechSmith.Snagit.2025&version=25.1.0&name=Snagit%202025");
+
+ Assert.Equal(
+ new PackageLink("TechSmith.Snagit.2025", "winget", "msstore", "25.1.0", "Snagit 2025"),
+ link);
+ }
+
+ [Fact]
+ public void Parse_OnlyNeedsAnId()
+ {
+ Assert.Equal(new PackageLink("Git.Git", null, null, null, null), PackageLink.TryParse("unigetui://package?id=Git.Git"));
+ }
+
+ [Theory]
+ [InlineData("unigetui://package/?id=Git.Git")]
+ [InlineData("unigetui:package?id=Git.Git")]
+ [InlineData("UNIGETUI://Package?ID=Git.Git")]
+ [InlineData("unigetui://package?id=Git.Git#fragment")]
+ [InlineData("unigetui://package?id=Git.Git&")]
+ [InlineData("unigetui://package?id=%20Git.Git%20")]
+ public void Parse_AcceptsTheShapesBrowsersAndShellsProduce(string uri)
+ {
+ Assert.Equal("Git.Git", PackageLink.TryParse(uri)?.Id);
+ }
+
+ [Fact]
+ public void Parse_DecodesPlusAsASpace()
+ {
+ Assert.Equal("Visual Studio Code", PackageLink.TryParse("unigetui://package?id=x&name=Visual+Studio+Code")?.Name);
+ }
+
+ [Fact]
+ public void Parse_IgnoresUnknownParameters()
+ {
+ Assert.Equal("Git.Git", PackageLink.TryParse("unigetui://package?id=Git.Git&future=1")?.Id);
+ }
+
+ [Fact]
+ public void Parse_DoesNotTakeInstallOptionsFromTheLink()
+ {
+ PackageLink? link = PackageLink.TryParse(
+ "unigetui://package?id=Git.Git&args=--silent&installer=https%3A%2F%2Fexample.test%2Fevil.exe");
+
+ Assert.Equal(new PackageLink("Git.Git", null, null, null, null), link);
+ }
+
+ [Theory]
+ [InlineData("unigetui://package")]
+ [InlineData("unigetui://package?")]
+ [InlineData("unigetui://package?id=")]
+ [InlineData("unigetui://package?id=%20")]
+ [InlineData("unigetui://package?manager=winget")]
+ [InlineData("unigetui://package?id=Git.Git&id=Other.Package")]
+ [InlineData("unigetui://package?id=Git.Git&ID=Other.Package")]
+ [InlineData("unigetui://package?id=Git.Git%0A--import-settings")]
+ [InlineData("unigetui://package?id=Git.Git&name=a%00b")]
+ [InlineData("unigetui://openUniGetUI")]
+ [InlineData("unigetui://packages?id=Git.Git")]
+ [InlineData("https://package?id=Git.Git")]
+ [InlineData("package?id=Git.Git")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void Parse_RefusesMalformedOrOtherLinks(string? uri)
+ {
+ Assert.Null(PackageLink.TryParse(uri));
+ }
+
+ [Fact]
+ public void Parse_RefusesOversizedValuesAndLinks()
+ {
+ string longValue = new('a', PackageLink.MaxValueLength + 1);
+ Assert.Null(PackageLink.TryParse($"unigetui://package?id={longValue}"));
+ Assert.NotNull(PackageLink.TryParse($"unigetui://package?id={longValue[1..]}"));
+
+ string longLink = "unigetui://package?id=Git.Git&padding=" + new string('a', PackageLink.MaxLinkLength);
+ Assert.Null(PackageLink.TryParse(longLink));
+ }
+
+ [Theory]
+ [InlineData("unigetui://package?id=Git.Git", true)]
+ [InlineData("unigetui://package?id=", true)]
+ [InlineData("unigetui://package/", true)]
+ [InlineData("unigetui://openUniGetUI", false)]
+ [InlineData("unigetui://updateAll", false)]
+ [InlineData("C:\\bundle.ubundle", false)]
+ public void IsPackageLink_RecognisesTheActionWhetherOrNotTheLinkIsValid(string arg, bool expected)
+ {
+ Assert.Equal(expected, PackageLink.IsPackageLink(arg));
+ }
+
+ [Fact]
+ public void ToUri_RoundTripsValuesThatNeedEncoding()
+ {
+ PackageLink link = new("@scope/pkg&x=1", "npm", "my source", "1.0.0-beta+2", "Name: with & symbols / ?");
+
+ string uri = link.ToUri();
+
+ Assert.StartsWith("unigetui://package?manager=npm&source=my%20source&id=%40scope%2Fpkg%26x%3D1&", uri);
+ Assert.Equal(link, PackageLink.TryParse(uri));
+ }
+
+ [Fact]
+ public void ToUri_LeavesOutMissingParameters()
+ {
+ Assert.Equal("unigetui://package?id=Git.Git", new PackageLink("Git.Git", null, null, null, null).ToUri());
+ }
+
+ [Fact]
+ public void FromPackage_NamesTheManagerAndPackage()
+ {
+ var manager = new NullPackageManager();
+ var package = new Package("Snagit 2025", "TechSmith.Snagit.2025", "25.1.0",
+ new ManagerSource(manager, "main", new Uri("https://example.test/feed")), manager);
+
+ PackageLink link = PackageLink.FromPackage(package);
+
+ // The test manager has no custom sources, so the source is left out.
+ Assert.Equal(new PackageLink("TechSmith.Snagit.2025", manager.Id, null, null, "Snagit 2025"), link);
+ Assert.True(PackageLink.CanLinkTo(package));
+ }
+
+ [Fact]
+ public void FromPackage_DropsANameTooLongToParse()
+ {
+ var manager = new NullPackageManager();
+ var package = new Package(new string('n', PackageLink.MaxValueLength + 1), "Some.Package", "1.0",
+ new ManagerSource(manager, "main", new Uri("https://example.test/feed")), manager);
+
+ PackageLink link = PackageLink.FromPackage(package);
+
+ Assert.Null(link.Name);
+ Assert.Equal(link, PackageLink.TryParse(link.ToUri()));
+ }
+
+ [Fact]
+ public void CanLinkTo_RefusesPackagesOnlyKnownToThisComputer()
+ {
+ var package = new Package("Local", "Local.App", "1.0", NullSource.Instance, NullPackageManager.Instance);
+
+ Assert.False(PackageLink.CanLinkTo(package));
+ }
+
+ [Theory]
+ [InlineData("Git", "Git", "Git")]
+ [InlineData("git", "Git", "Git")]
+ [InlineData("Git", "Git.Git", "Git (Git.Git)")]
+ [InlineData(null, "Git.Git", "Git.Git")]
+ public void DisplayName_ShowsTheIdOnlyWhenItAddsSomething(string? name, string id, string expected)
+ {
+ Assert.Equal(expected, new PackageLink(id, null, null, null, name).DisplayName);
+ }
+
+ [Fact]
+ public void ToastParser_LeavesPackageLinksToTheStartupProcessor()
+ {
+ Assert.Null(WindowsAppNotificationBridge.TryParseToastLaunchArgument("unigetui://package?id=Git.Git"));
+ Assert.Equal("openUniGetUI", WindowsAppNotificationBridge.TryParseToastLaunchArgument("unigetui://openUniGetUI"));
+ }
+
+ [Fact]
+ public void StartupProcessor_FindsTheFirstValidPackageLink()
+ {
+ PackageLink? link = StartupArgumentProcessor.FindPackageLink(
+ ["--daemon", "unigetui://package?id=", "unigetui://package?id=Git.Git", "unigetui://package?id=Other"]);
+
+ Assert.Equal("Git.Git", link?.Id);
+ Assert.Null(StartupArgumentProcessor.FindPackageLink(["unigetui://openUniGetUI", "bundle.ubundle"]));
+ }
+}