Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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

Expand Down
10 changes: 9 additions & 1 deletion src/Languages/lang_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
143 changes: 143 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/PackageLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System.Text;
using UniGetUI.PackageEngine.Interfaces;

namespace UniGetUI.Avalonia.Infrastructure;

/// <summary>
/// A <c>unigetui://package?manager=…&amp;source=…&amp;id=…</c> 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.
/// </summary>
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;

/// <summary>
/// Whether <paramref name="arg"/> is a <c>unigetui:</c> link to the package action, valid or not.
/// </summary>
public static bool IsPackageLink(string? arg)
=> TrySplit(arg, out string action, out _)
&& action.Equals(Action, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Parses a package link, or returns null when <paramref name="arg"/> is not one or is malformed:
/// no <c>id</c>, 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.
/// </summary>
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<string, string> 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;
}

/// <summary>
/// Whether a link can name <paramref name="package"/>: packages that only exist on this
/// machine, such as the WinGet "Local PC" entries, cannot be found from another one.
/// </summary>
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 = '&';
}
}

/// <summary>The package as it should be named before it is found: its name and id, or its id.</summary>
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);
}
}
127 changes: 127 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/PackageLinkHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Opens a <see cref="PackageLink"/>: 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.
/// </summary>
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);
}
}
Loading
Loading