diff --git a/AGENTS.md b/AGENTS.md index 14b528213f..55dde61da1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,46 @@ retried against a fabricated V2 endpoint. V2-only feeds never enter the V3 path V3 has no `IsLatestVersion`, so the client picks "latest" itself using the comparator described in the next section. +## macOS installer packages (.pkg, .dmg and .zip) + +`UniGetUI.PackageEngine.Managers.MacPkg` (id `pkg`, macOS only) installs IT-published `.pkg` and +`.dmg` files through the Software Catalog; the administrator workflow is in `docs/PKG_FEEDS.md`. + +- Its sources are not added from the UI: each catalog of the machine-wide `SoftwareCatalog.json` + that has a `pkgFeed` declares one, named by the catalog's id and shown by its name + (`PkgCatalogFeeds`, `PkgFeedSource`), with `Sources.CanBeEdited` false. The catalog page + then lists every package of that feed in the catalog (`SoftwareCatalogViewModel.ExpandPkgFeedsAsync`). +- A feed is a `file:` folder whose `.pkg` files are read directly, or an `https:` folder read through + the `index.json` that `unigetui pkg-index ` writes (`PkgIndexCommand`). +- Identity comes from the file itself: for a `.pkg`, `XarArchive` + `PkgMetadata` (the first + component of a Distribution, else `PackageInfo`, plus its outermost app); for a `.dmg`, + `DiskImage` mounts it read-only and reads the app at its root. Installed state comes from + `pkgutil` receipts (`PkgReceipts`), else from that app being in place under its bundle id (`AppBundle`). +- Icons come from the app too: a `.dmg`'s app, or the app's `Info.plist` and `.icns` found in one + forward pass through a `.pkg` payload (`PkgPayload`, gzip'd odc cpio). They are converted with + `sips` once per file version into the icon cache; the manager sets `SupportsCustomPackageIcons`, + without which the base `GetIcon` never asks it. +- Installs run in-process (`IManagedPackageOperationHelper`): stage a private copy, verify the + index hash, the package identity and its Gatekeeper verdict (`spctl --assess`, waived only by a + catalog's `allowUnsignedPackages`), then `installer -pkg`, or `ditto` of a `.dmg`'s app into + `/Applications`, as root through `osascript ... with administrator privileges`. Every tool is run + through `Tool`, which drains both pipes. `ManagerCapabilities.CanUninstall` is false, and the UI + refuses uninstalls for such managers; `InstalledPackagesKeepTheirSource` is false (as for + Chocolatey), so catalogs sharing a feed all see its installed packages. +- A `.dmg` may hold one app or one `.pkg` (`WrapsPackage`, recognized by receipt); a `.zip` holds one + app, read with `ZipApp` and extracted with `ditto`. `PkgInstaller.PrepareAsync` stages and checks + every kind and returns the one root command to run. +- `unigetui pkg-helper` (`PkgHelperCommand`) is an optional root LaunchDaemon on a Unix socket. A + client sends only catalog id, package id and version (`PkgHelperClient`); the helper resolves the + feed itself from the catalog, refuses paths standard users could change (`TrustedPath`), then + runs the same `PrepareAsync`. Installs fall back to the administrator prompt when it is absent. +- Behind the prompt, a UniGetUI that standard users cannot replace runs itself as root + (`pkg-helper install-item`, `PkgElevatedInstall`), so staging and checks happen where the user's + own processes cannot swap the package; only an unprotected build (such as a dev build) checks it + before the prompt instead. +- The Catalog Editor's **Package feed** dialog (`PkgFeedEditor`, `PkgFeedEditorViewModel`) edits + `pkgFeed`/`allowUnsignedPackages` and previews a feed through `PkgFeedInspector`. + ## Version comparison across ecosystems `CoreTools.VersionStringToStruct` treats `-`, `_`, `/` and `#` as plain numeric separators and diff --git a/docs/CLI.md b/docs/CLI.md index ce9a11f89a..a87fdd79bd 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -356,6 +356,8 @@ These parameters are accepted by the app executables in addition to the automati | `--enable-secure-setting-for-user ` / `--disable-secure-setting-for-user ` | Toggles one secure setting for a specified user. | May require elevation. | | `--delete-shortcuts [...]` | Deletes desktop or Start Menu shortcuts, then quits. | Used by UniGetUI to remove shortcuts the current user is not allowed to delete; refuses any path that is not a `.lnk` or `.url` under a desktop or Start Menu folder. | | `` | Loads a valid bundle file into the Package Bundles page. | Supported extensions include `.ubundle`, `.json`, `.yaml`, and `.xml`. | +| `pkg-helper install-daemon` / `remove-daemon` | Installs (or removes) the root service that lets standard users install catalog packages without an administrator password. | macOS, as root. `pkg-helper serve` is what the service runs. See [PKG_FEEDS.md](PKG_FEEDS.md). | +| `pkg-index ` | Writes the `index.json` that publishes a folder of macOS `.pkg`, `.dmg` and `.zip` files as a web feed, then quits. | macOS and Linux builds only; `.dmg` files are read on macOS only. Exits 3 when a file in the folder could not be read. See [PKG_FEEDS.md](PKG_FEEDS.md). | ## Other environment variables diff --git a/docs/PKG_FEEDS.md b/docs/PKG_FEEDS.md new file mode 100644 index 0000000000..5a570566ca --- /dev/null +++ b/docs/PKG_FEEDS.md @@ -0,0 +1,149 @@ +# Publishing macOS apps (.pkg, .dmg and .zip) + +The software catalog can offer apps that no package manager carries: an administrator publishes +the vendor's `.pkg`, `.dmg` or `.zip` unchanged, and users install and update it from the +**Software Catalog** page. + +There is nothing to repackage and no detection rule to write. UniGetUI reads each file's +identifier, version and app from the file itself, then checks the Mac: + +- **`.pkg`:** installed when macOS holds its receipt (`pkgutil --pkgs`), at the receipt's version. + Without a receipt, the app the package installs counts too when it is in place under its bundle + id, so an app that a management tool or the user installed another way is recognized. +- **`.dmg`:** a disk image with one app at its root (the usual "drag to Applications" image). + Installed when that app is in `/Applications` under its bundle id, at its bundle version. A disk + image holding an installer package instead is treated as that `.pkg`. +- **`.zip`:** an archive holding one app, at its root or one folder down, as some vendors ship + it (Visual Studio Code, for one). Recognized like a `.dmg`'s app. + +## Set up a feed once + +A feed is a location holding `.pkg`, `.dmg` and `.zip` files. In the Catalog Editor, select a +catalog and choose **Package feed**: enter the folder or web address, then **Check feed** lists +every package it publishes and every file it skips, with the reason. Or declare it as the +`pkgFeed` of a catalog in the machine-wide catalog file, `/Library/Application Support/Devolutions/UniGetUI/SoftwareCatalog.json`: + +```json +{ + "version": 1, + "catalogs": [ + { + "id": "mac-apps", + "name": "Mac apps", + "packages": [], + "pkgFeed": "https://software.example.com/mac/" + } + ] +} +``` + +Every package in the feed appears in that catalog. `packages` may stay empty; any entry listed +there as well is shown alongside the feed's packages. Keep `"packages": []` even when it is empty: +UniGetUI versions without feed support reject a catalog without it. + +A feed is identified by its catalog's `id`, so renaming the catalog keeps everything in place, and +two catalogs may share a name or even a feed. Outside the catalog page (Installed, Updates, bundles, +the command line) the id is the package's source, as in `Installer packages: mac-apps`, so choose +a readable one. To list a feed's package by hand in another catalog, use `"ManagerName": "pkg"` +and the feed catalog's `id` as `"Source"`. + +### Signatures + +Before installing, UniGetUI asks Gatekeeper about the package the same way macOS checks anything +downloaded: it must be signed with a Developer ID and notarized by Apple. Vendors' own packages +and disk images usually are. A package Gatekeeper rejects is not installed. + +To publish your own unsigned packages, add `"allowUnsignedPackages": true` to that catalog. Its +packages are then installed as they are, so keep its feed where only administrators can change +it: UniGetUI refuses an unsigned file, or a file in a folder, that any user on the Mac can write +to (such as `/Users/Shared`). + +A feed can be: + +| Location | Example | What UniGetUI reads | +|---|---|---| +| A web folder (https) | `https://software.example.com/mac/` | `index.json` in that folder | +| A local folder or a mounted share | `/Volumes/Software/mac` | the `.pkg` and `.dmg` files directly | + +Plain `http` is refused (except on `localhost`, for testing, and never by the package helper below), +because the index is what pins each package's hash. + +## Publish a package + +**Folder or share feed:** copy the `.pkg` or `.dmg` into the folder. Subfolders up to three levels +deep are read too. + +**Web feed:** copy the file into the folder you publish, then regenerate its index and upload the +folder: + +```bash +/Applications/UniGetUI.app/Contents/MacOS/uniget pkg-index /path/to/mac +``` + +The command reads every `.pkg` and `.dmg` in the folder and writes `index.json` next to them, with +each package's identifier, version, supported architectures, app and SHA-256. Run it on a Mac (disk +images are read with `hdiutil`), and again after every change. A file it cannot read is reported and +left out of the index. + +Each package shows the icon of the app it installs, taken from the `.dmg`'s app or from the +`.pkg`'s payload. To use another icon, put a `.png` or `.jpg` with the same name next to the file +(`Zoom.pkg` → `Zoom.png`). `pkg-index` saves the app's icon next to each file that has none, so a +web feed serves it too. + +To offer an update, publish the newer file. You can delete the older one or keep it. When the +feed holds several versions of a package, users get the newest one that runs on their Mac. A +native build is preferred over one that needs Rosetta, and an `arm64`-only build is never offered +to an Intel Mac. + +## What users see + +- **Install** and **Update** ask for an administrator password with the standard macOS prompt, + then, as root, download the file (or copy it from the share) and check its hash, identity and + signature. A `.pkg` then runs through + `installer`, and a `.dmg`'s app is copied to `/Applications`, replacing the previous version + only once the new one is in place. Once that step starts it cannot be canceled, since stopping + it halfway would leave a broken app. +- Packages cannot be uninstalled from UniGetUI. +- With the package helper installed (below), no password is asked, and standard users can + install too. +- When the feed is unreachable (off the VPN, share not mounted), UniGetUI keeps showing the + packages it last listed. Installing needs the feed again. + +## Installing without an administrator password + +By default, installing asks for an administrator password, which standard users do not have. The +package helper is a small root service, like a management agent's, that installs what the catalog +publishes without asking. Set it up once per Mac from a deployment script, after UniGetUI itself: + +```bash +sudo /Applications/UniGetUI.app/Contents/MacOS/UniGetUI pkg-helper install-daemon +``` + +It runs from `/Library/LaunchDaemons/com.devolutions.unigetui.pkghelper.plist` and logs to +`/Library/Logs/UniGetUI-pkg-helper.log`. `pkg-helper remove-daemon` removes it. + +A user can only ask it for a package by catalog id, package id and version. The helper reads the +catalog, lists the feed, stages and checks the package itself, with the same hash, identity and +Gatekeeper checks as any install, and refuses: + +- anything a catalog does not publish; +- a catalog file, folder feed or package file that a standard user could change: each must belong + to root or an administrator and be writable by no one else. So install UniGetUI with its + installer package (so that the app belongs to root), and keep feeds out of shared folders such as + `/Users/Shared`. +- a web feed that is not served over `https`, since any user could answer on a `localhost` port + the real server left free. + +When the helper is not running, or refuses a package (such as one from an unprotected feed), UniGetUI +falls back to the administrator prompt, which an administrator can still approve. + +## Limits + +- Only flat packages are read; legacy bundle packages (a `.pkg` that is a folder) are skipped. +- A `.dmg` must hold exactly one installer package or one app at its root (an uninstaller beside + it is ignored); a `.zip` exactly one app. +- A product archive is identified by its first component. Without that component's receipt or the + app it installs, the product shows as not installed, even if other components are present. +- An app is looked for only where the package installs it: an app renamed or moved elsewhere is + not recognized. +- A `.pkg` receipt survives the user deleting the app by hand, so the app still shows as installed. diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 7f56d229ec..1e4cd72df9 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -119,6 +119,26 @@ "Enable the Catalog Editor": "Enable the Catalog Editor", "Catalog Editor is disabled": "Catalog Editor is disabled", "Enable the Catalog Editor in Experimental settings and developer options, then restart UniGetUI.": "Enable the Catalog Editor in Experimental settings and developer options, then restart UniGetUI.", + "Apps (.pkg and .dmg) that your organization publishes in its software catalog.": "Apps (.pkg and .dmg) that your organization publishes in its software catalog.", + "Installer package (.pkg)": "Installer package (.pkg)", + "Disk image (.dmg)": "Disk image (.dmg)", + "Zip archive (.zip)": "Zip archive (.zip)", + "Enter an https address or the full path of a folder.": "Enter an https address or the full path of a folder.", + "Reading the feed...": "Reading the feed...", + "{0} packages found, {1} files skipped.": "{0} packages found, {1} files skipped.", + "The feed could not be read: {0}": "The feed could not be read: {0}", + "Every .pkg, .dmg and .zip file in this folder or web address appears in the catalog. Leave it empty for a catalog without a feed.": "Every .pkg, .dmg and .zip file in this folder or web address appears in the catalog. Leave it empty for a catalog without a feed.", + "Package feed location": "Package feed location", + "Choose folder...": "Choose folder...", + "Allow packages that are not signed and notarized (for your own packages; keep the feed where only administrators can change it)": "Allow packages that are not signed and notarized (for your own packages; keep the feed where only administrators can change it)", + "Check feed": "Check feed", + "Package feed": "Package feed", + "Choose the folder of the package feed": "Choose the folder of the package feed", + "Package feed of {0}": "Package feed of {0}", + "Published by your organization in {0}.": "Published by your organization in {0}.", + "UniGetUI wants to install {0}.": "UniGetUI wants to install {0}.", + "Some packages cannot be uninstalled": "Some packages cannot be uninstalled", + "{0} cannot be uninstalled from UniGetUI. Ask your administrator to remove it.": "{0} cannot be uninstalled from UniGetUI. Ask your administrator to remove it.", "Software Catalog": "Software Catalog", "Software selected for you, ready to install.": "Software selected for you, ready to install.", "Refresh software catalog": "Refresh software catalog", diff --git a/src/UniGetUI.Avalonia.slnx b/src/UniGetUI.Avalonia.slnx index e653df643e..726cc986bc 100644 --- a/src/UniGetUI.Avalonia.slnx +++ b/src/UniGetUI.Avalonia.slnx @@ -181,6 +181,12 @@ + + + + + + diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index d263d62879..74db3fc3e9 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -1130,11 +1130,11 @@ private static async Task ApplyMacUpdateAsAdminAsync(string target, string archi string command = string.Join( ' ', "/bin/sh -c", - MacAdminFileInstaller.ShellQuote(MacUpdateTargetResolver.ElevatedSwapScript), + CoreTools.QuoteForPosixShell(MacUpdateTargetResolver.ElevatedSwapScript), "sh", - MacAdminFileInstaller.ShellQuote(target), - MacAdminFileInstaller.ShellQuote(archive), - MacAdminFileInstaller.ShellQuote(expectedHash.ToLowerInvariant()), + CoreTools.QuoteForPosixShell(target), + CoreTools.QuoteForPosixShell(archive), + CoreTools.QuoteForPosixShell(expectedHash.ToLowerInvariant()), pid); bool swapped; diff --git a/src/UniGetUI.Avalonia/Infrastructure/MacAdminFileInstaller.cs b/src/UniGetUI.Avalonia/Infrastructure/MacAdminFileInstaller.cs index 7c1d4aa266..edb7d9897c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/MacAdminFileInstaller.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/MacAdminFileInstaller.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Runtime.Versioning; +using UniGetUI.Core.Tools; namespace UniGetUI.Avalonia.Infrastructure; @@ -25,7 +26,7 @@ public static async Task WriteAsync(string destination, string contents, s string folder = Path.GetDirectoryName(destination)!; return await RunAsync( - $"/bin/mkdir -p {ShellQuote(folder)} && /usr/bin/install -m 0644 {ShellQuote(staged)} {ShellQuote(destination)}", + $"/bin/mkdir -p {CoreTools.QuoteForPosixShell(folder)} && /usr/bin/install -m 0644 {CoreTools.QuoteForPosixShell(staged)} {CoreTools.QuoteForPosixShell(destination)}", prompt); } finally @@ -48,7 +49,7 @@ public static async Task RunAsync(string command, string prompt) UseShellExecute = false, }; startInfo.ArgumentList.Add("-e"); - startInfo.ArgumentList.Add($"do shell script \"{AppleScriptEscape(command)}\" with prompt \"{AppleScriptEscape(prompt)}\" with administrator privileges"); + startInfo.ArgumentList.Add($"do shell script \"{CoreTools.EscapeAppleScriptString(command)}\" with prompt \"{CoreTools.EscapeAppleScriptString(prompt)}\" with administrator privileges"); using var process = Process.Start(startInfo) ?? throw new IOException("Could not start the administrator prompt."); @@ -59,7 +60,4 @@ public static async Task RunAsync(string command, string prompt) throw new IOException(error.Trim()); } - internal static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''") + "'"; - - internal static string AppleScriptEscape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); } diff --git a/src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs b/src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs index 2bee5f3227..fd8949c7eb 100644 --- a/src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs +++ b/src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs @@ -16,11 +16,10 @@ public CatalogPackageIdentitySet(IEnumerable packages, bool installedI foreach (var package in packages) { var manager = package.Manager; - // Only installed Chocolatey inventory loses feed provenance. Pending operations remain source-specific. - string? source = installedInventory - && string.Equals(manager.Id, "chocolatey", StringComparison.OrdinalIgnoreCase) - ? null - : package.Source.Name; + // Some installed inventories lose feed provenance. Pending operations remain source-specific. + string? source = installedInventory && !manager.Capabilities.InstalledPackagesKeepTheirSource + ? null + : package.Source.Name; _identities.Add(new Identity(manager.Id, package.Id, source)); _identities.Add(new Identity(manager.Name, package.Id, source)); _identities.Add(new Identity(manager.DisplayName, package.Id, source)); diff --git a/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs index 1baf858762..ff79b6bb7d 100644 --- a/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs +++ b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs @@ -25,6 +25,16 @@ public sealed class CatalogDefinition public required string Name { get; init; } [JsonPropertyName("packages")] public required CatalogEntry[] Packages { get; init; } + + // A folder or https location of macOS .pkg and .dmg files: every package in it joins this catalog. + [JsonPropertyName("pkgFeed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PkgFeed { get; init; } + + // Lets the feed's packages install even when Gatekeeper rejects them (unsigned or not notarized). + [JsonPropertyName("allowUnsignedPackages")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowUnsignedPackages { get; init; } } public sealed class CatalogEntry @@ -71,8 +81,7 @@ public bool Matches(IPackage package) => public bool MatchesInstalled(IPackage package) => string.Equals(Id, package.Id, StringComparison.OrdinalIgnoreCase) && MatchesManager(package.Manager) - // Chocolatey's installed inventory assigns DefaultSource and does not retain feed provenance. - && (string.Equals(package.Manager.Id, "chocolatey", StringComparison.OrdinalIgnoreCase) + && (!package.Manager.Capabilities.InstalledPackagesKeepTheirSource || string.Equals(Source, package.Source.Name, StringComparison.OrdinalIgnoreCase)); } @@ -138,7 +147,9 @@ internal static bool HasUsableCatalog(string filePath) { try { - return File.Exists(filePath) && Parse(File.ReadAllText(filePath)).Any(c => c.Packages.Length > 0); + // A pkgFeed fills its catalog only on macOS, where the Installer packages manager exists. + return File.Exists(filePath) && Parse(File.ReadAllText(filePath)).Any(c => c.Packages.Length > 0 + || (OperatingSystem.IsMacOS() && !string.IsNullOrWhiteSpace(c.PkgFeed))); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { @@ -169,6 +180,8 @@ internal static CatalogDefinition[] Parse(string json) throw new JsonException("Every catalog requires an id, name and packages array."); if (!catalogIds.Add(catalog.Id)) throw new JsonException($"Duplicate catalog identifier: {catalog.Id}."); + if (catalog.PkgFeed is not null && string.IsNullOrWhiteSpace(catalog.PkgFeed)) + throw new JsonException($"The pkgFeed of catalog {catalog.Id} is empty."); var identities = new HashSet<(string, string, string)>(); foreach (var entry in catalog.Packages) diff --git a/src/UniGetUI.Avalonia/Program.cs b/src/UniGetUI.Avalonia/Program.cs index 1c34cd903d..907146225d 100644 --- a/src/UniGetUI.Avalonia/Program.cs +++ b/src/UniGetUI.Avalonia/Program.cs @@ -2,6 +2,9 @@ using Avalonia; using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Core.Data; +#if !WINDOWS +using UniGetUI.PackageEngine.Managers.MacPkgManager; +#endif using UniGetUI.Tui; namespace UniGetUI.Avalonia; @@ -14,6 +17,15 @@ sealed class Program [STAThread] public static void Main(string[] args) { +#if !WINDOWS + // The root helper runs first, before anything that could log to a folder other users share. + if (PkgHelperCommand.IsCommand(args)) + { + Environment.ExitCode = PkgHelperCommand.Run(args[1..], Console.Out, Console.Error); + return; + } +#endif + // Both front-ends must wait while the installer replaces their shared files. try { @@ -38,6 +50,14 @@ public static void Main(string[] args) return; } +#if !WINDOWS + if (PkgIndexCommand.IsCommand(args)) + { + Environment.ExitCode = PkgIndexCommand.Run(args[1..], Console.Out, Console.Error); + return; + } +#endif + #if WINDOWS // Stamp the AUMID onto this process before anything else so the shell attributes // Action-Center toasts to UniGetUI. Must match the AUMID stamped on the Start Menu diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs index 3db59f35c9..2facd6f783 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs @@ -21,14 +21,30 @@ public partial class CatalogEditorCatalog : ViewModelBase public ObservableCollection Packages { get; } + // A folder or https location of macOS packages that fills the catalog, edited from the Package feed dialog. + [ObservableProperty] + private string? _pkgFeed; + + [ObservableProperty] + private bool? _allowUnsignedPackages; + public CatalogEditorCatalog(CatalogDefinition definition) { _id = definition.Id; _name = definition.Name; Packages = new(definition.Packages); + _pkgFeed = definition.PkgFeed; + _allowUnsignedPackages = definition.AllowUnsignedPackages; } - public CatalogDefinition ToDefinition() => new() { Id = Id, Name = Name, Packages = Packages.ToArray() }; + public CatalogDefinition ToDefinition() => new() + { + Id = Id, + Name = Name, + Packages = Packages.ToArray(), + PkgFeed = PkgFeed, + AllowUnsignedPackages = AllowUnsignedPackages + }; } public sealed record CatalogAddResult(CatalogEditorCatalog Catalog, int Added, int AlreadyPresent); @@ -365,6 +381,8 @@ private static CatalogDefinition[] CopyCatalogs(IEnumerable c { Id = c.Id, Name = c.Name, + PkgFeed = c.PkgFeed, + AllowUnsignedPackages = c.AllowUnsignedPackages, Packages = c.Packages.Select(p => new CatalogEntry { Id = p.Id, diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/PkgFeedEditorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/PkgFeedEditorViewModel.cs new file mode 100644 index 0000000000..9640addbb0 --- /dev/null +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/PkgFeedEditorViewModel.cs @@ -0,0 +1,116 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +#if !WINDOWS +using UniGetUI.PackageEngine.Managers.MacPkgManager; +#endif + +namespace UniGetUI.Avalonia.ViewModels; + +/// One line of the feed preview: what it is, then its details. +public sealed record FeedPreviewRow(string Title, string Detail); + +/// +/// Edits a catalog's package feed (where IT drops macOS .pkg, .dmg and .zip files) and previews what +/// it publishes, so a typo or an unreadable file shows up before the catalog is deployed. +/// +public partial class PkgFeedEditorViewModel : ViewModelBase +{ + private readonly Func> _pickFolder; + + public PkgFeedEditorViewModel(string? location, bool allowUnsignedPackages, Func> pickFolder) + { + _location = location ?? ""; + _allowUnsignedPackages = allowUnsignedPackages; + _pickFolder = pickFolder; + } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsValid))] + [NotifyPropertyChangedFor(nameof(ValidationMessage))] + [NotifyCanExecuteChangedFor(nameof(CheckCommand))] + private string _location; + + [ObservableProperty] + private bool _allowUnsignedPackages; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(CheckCommand))] + private bool _isChecking; + + [ObservableProperty] + private string _summary = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasWarning))] + private string _warning = ""; + + public ObservableCollection Packages { get; } = []; + public ObservableCollection Problems { get; } = []; + + public bool HasWarning => Warning.Length > 0; + + /// An empty location is valid: it removes the catalog's feed. + public bool IsValid => Location.Trim().Length == 0 || IsValidLocation(Location.Trim()); + + public string ValidationMessage => IsValid + ? "" + : CoreTools.Translate("Enter an https address or the full path of a folder."); + + /// Previews read feeds with the macOS reader, which only macOS and Linux builds include. + public static bool CanPreview => !OperatingSystem.IsWindows(); + + private static bool IsValidLocation(string text) + { +#if WINDOWS + return Uri.TryCreate(text, UriKind.Absolute, out Uri? uri) && (uri.IsFile || uri.Scheme == Uri.UriSchemeHttps); +#else + return PkgFeedInspector.IsValidLocation(text); +#endif + } + + [RelayCommand] + private async Task BrowseAsync() + { + if (await _pickFolder() is { Length: > 0 } folder) + Location = folder; + } + + private bool CanCheck() => CanPreview && !IsChecking && Location.Trim().Length > 0 && IsValid; + + [RelayCommand(CanExecute = nameof(CanCheck))] + private async Task CheckAsync() + { + IsChecking = true; + Packages.Clear(); + Problems.Clear(); + Warning = ""; + Summary = CoreTools.Translate("Reading the feed..."); + try + { +#if !WINDOWS + string location = Location.Trim(); + var inspection = await Task.Run(() => PkgFeedInspector.Inspect(location)); + foreach (var package in inspection.Packages.OrderBy(p => p.Name, StringComparer.CurrentCultureIgnoreCase)) + Packages.Add(new FeedPreviewRow($"{package.Name} {package.Version}", + $"{package.Id} · {package.Kind} · {package.File}" + + (package.Architectures.Count > 0 ? " · " + string.Join(", ", package.Architectures) : ""))); + foreach (var problem in inspection.Problems) + Problems.Add(new FeedPreviewRow(problem.File, problem.Reason)); + Warning = inspection.Warning ?? ""; + Summary = CoreTools.Translate("{0} packages found, {1} files skipped.", Packages.Count, Problems.Count); +#endif + } + catch (Exception ex) + { + Logger.Warn($"Could not read the package feed {Location}: {ex.Message}"); + Summary = CoreTools.Translate("The feed could not be read: {0}", ex.Message); + } + finally + { + IsChecking = false; + } + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs index 598ec5c619..7cdd77ac90 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs @@ -209,6 +209,10 @@ public void LoadCatalogs(IReadOnlyList catalogs) CatalogNodes.Clear(); _allPackages.Clear(); _updatingCatalogSelection = true; + // A pkg feed's source is named by its catalog's id; people know it by the catalog's name. + var feedNames = catalogs.Where(c => c.PkgFeed is not null) + .DistinctBy(c => c.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase); try { foreach (var catalog in catalogs) @@ -222,7 +226,12 @@ public void LoadCatalogs(IReadOnlyList catalogs) CatalogNodes.Add(node); _catalogSelection.TryAdd(catalog.Id, node); foreach (var entry in catalog.Packages) - _allPackages.Add(new CatalogTileViewModel(entry, catalog) { Owner = this }); + _allPackages.Add(new CatalogTileViewModel(entry, catalog) + { + Owner = this, + SourceLabel = FindManager(entry)?.Id == PkgManagerId + && feedNames.TryGetValue(entry.Source, out string? feedName) ? feedName : entry.Source, + }); } } finally { _updatingCatalogSelection = false; } @@ -346,7 +355,7 @@ public async Task RefreshAsync() UpdateStates(); try { - var catalogs = await SoftwareCatalog.LoadAsync(); + var catalogs = await ExpandPkgFeedsAsync(await SoftwareCatalog.LoadAsync()); _notConfigured = false; BackgroundDetail = ""; @@ -400,6 +409,43 @@ public async Task RefreshAsync() private static IPackageManager? FindManager(CatalogEntry entry) => PEInterface.Managers.FirstOrDefault(entry.MatchesManager); + // The macOS Installer packages manager, which serves each catalog's pkgFeed as a source named by the catalog's id. + private const string PkgManagerId = "pkg"; + + /// + /// Adds every package of a catalog's pkgFeed to that catalog, so that publishing a .pkg in the + /// feed is all it takes to offer it. Entries written in the catalog itself come first and win. + /// + internal static async Task ExpandPkgFeedsAsync(CatalogDefinition[] catalogs) + { + if (catalogs.All(c => c.PkgFeed is null) + || PEInterface.Managers.FirstOrDefault(m => m.Id == PkgManagerId) is not { } manager + || !manager.IsReady()) + return catalogs; + // A refresh shows what IT just published, not the feed listing kept for icon lookups. + var published = await Task.Run(() => + { + manager.RefreshPackageIndexes(); + return manager.FindPackages(""); + }); + return catalogs.Select(c => c.PkgFeed is null ? c : new CatalogDefinition + { + Id = c.Id, + Name = c.Name, + PkgFeed = c.PkgFeed, + AllowUnsignedPackages = c.AllowUnsignedPackages, + Packages = + [ + .. c.Packages, + .. published + .Where(p => string.Equals(p.Source.Name, c.Id, StringComparison.OrdinalIgnoreCase) + && !c.Packages.Any(e => e.Matches(p))) + .Select(CatalogEntry.FromPackage) + .OrderBy(e => e.Name, StringComparer.CurrentCultureIgnoreCase), + ], + }).ToArray(); + } + private static (string Manager, string Source) SourceKey(CatalogEntry entry) => ((FindManager(entry)?.Id ?? entry.ManagerName).ToUpperInvariant(), entry.Source.ToUpperInvariant()); @@ -412,20 +458,20 @@ private void BuildSourceNodes() _updatingSourceSelection = true; try { - var entries = _allPackages.Select(t => t.Entry).ToArray(); - foreach (var group in entries.GroupBy(p => SourceKey(p).Manager)) + foreach (var group in _allPackages.ToArray().GroupBy(t => SourceKey(t.Entry).Manager)) { var root = new SourceTreeNode { - PackageName = FindManager(group.First())?.DisplayName ?? group.First().ManagerName, + PackageName = FindManager(group.First().Entry)?.DisplayName ?? group.First().Entry.ManagerName, IsExpanded = true, }; - foreach (var entry in group.DistinctBy(SourceKey)) + foreach (var tile in group.DistinctBy(t => SourceKey(t.Entry))) { + var entry = tile.Entry; var key = SourceKey(entry); var child = new SourceTreeNode { - PackageName = entry.Source, + PackageName = tile.SourceLabel, Source = entry.Source, IsSelected = previous.GetValueOrDefault(key, true), }; @@ -619,7 +665,7 @@ public void ToggleSort(CatalogSortField field) private static string SortKey(CatalogSortField field, CatalogTileViewModel t) => field switch { CatalogSortField.Id => t.Entry.Id, - CatalogSortField.Source => t.ManagerDisplayName + "/" + t.Entry.Source, + CatalogSortField.Source => t.ManagerDisplayName + "/" + t.SourceLabel, _ => t.Entry.Name, }; @@ -805,7 +851,15 @@ public partial class CatalogTileViewModel(CatalogEntry entry, CatalogDefinition? public string ManagerDisplayName { get; } = PEInterface.Managers.FirstOrDefault(entry.MatchesManager)?.DisplayName ?? entry.ManagerName; - public string SourceDisplay => $"{ManagerDisplayName}: {Entry.Source}"; + // The source as people know it: a pkg feed shows its catalog's name rather than the id naming it. + public string SourceLabel { get; init; } = entry.Source; + + public string SourceDisplay => $"{ManagerDisplayName}: {SourceLabel}"; + + private readonly bool _canUninstall = + PEInterface.Managers.FirstOrDefault(entry.MatchesManager)?.Capabilities.CanUninstall ?? true; + + public bool ShowUninstall => IsInstalled && _canUninstall; // An administrator can pin the version the catalog installs; empty means the latest. public bool HasPinnedVersion => Entry.Version.Length > 0; @@ -862,6 +916,7 @@ internal async Task LoadIconAsync(IPackage package) [NotifyPropertyChangedFor(nameof(CanInstall))] [NotifyPropertyChangedFor(nameof(ShowUpdate))] [NotifyPropertyChangedFor(nameof(ShowInstalledLabel))] + [NotifyPropertyChangedFor(nameof(ShowUninstall))] private bool _isInstalled; [ObservableProperty] diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/PkgFeedEditor.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/PkgFeedEditor.axaml new file mode 100644 index 0000000000..89b07ff250 --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/PkgFeedEditor.axaml @@ -0,0 +1,69 @@ + + + + + + + +