From 07cbfb02bbb228386f0572dba3aaa1f1ace45076 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Mon, 26 Oct 2026 14:10:02 -0400 Subject: [PATCH 01/16] Offer IT-published .pkg and .dmg apps in the macOS Software Catalog --- AGENTS.md | 23 + docs/CLI.md | 1 + docs/PKG_FEEDS.md | 93 ++ src/Languages/lang_en.json | 7 + src/UniGetUI.Avalonia.slnx | 6 + .../Models/SoftwareCatalog.cs | 10 +- src/UniGetUI.Avalonia/Program.cs | 11 + .../DialogPages/CatalogEditorViewModel.cs | 7 +- .../SoftwarePages/SoftwareCatalogViewModel.cs | 39 +- .../SettingsPages/PackageManagerPage.axaml.cs | 3 +- .../SoftwarePages/InstalledPackagesPage.cs | 18 +- .../SoftwarePages/SoftwareCatalogPage.axaml | 2 +- .../ManagerCapabilities.cs | 2 + .../Internal/NuGetManifestLoader.cs | 11 +- .../Helpers/MacPkgDetailsHelper.cs | 50 + .../Helpers/MacPkgOperationHelper.cs | 51 + .../Helpers/MacPkgSourceHelper.cs | 34 + .../Internal/AppBundle.cs | 183 ++++ .../Internal/DiskImage.cs | 152 +++ .../Internal/PkgCatalogFeeds.cs | 76 ++ .../Internal/PkgFeed.cs | 390 ++++++++ .../Internal/PkgFeedIndex.cs | 80 ++ .../Internal/PkgInstaller.cs | 191 ++++ .../Internal/PkgMetadata.cs | 176 ++++ .../Internal/PkgPayload.cs | 150 +++ .../Internal/PkgReceipts.cs | 64 ++ .../Internal/XarArchive.cs | 176 ++++ .../InternalsVisibleTo.cs | 3 + .../MacPkg.cs | 173 ++++ .../PkgIndexCommand.cs | 90 ++ ...GetUI.PackageEngine.Managers.MacPkg.csproj | 23 + .../PEInterface.cs | 4 + .../UniGetUI.PackageEngine.PEInterface.csproj | 3 + .../MacPkgTests.cs | 938 ++++++++++++++++++ .../NuGetManifestLoaderTests.cs | 23 + .../UniGetUI.PackageEngine.Tests.csproj | 1 + src/UniGetUI.Tests/CatalogEditorTests.cs | 19 + src/UniGetUI.Tests/SoftwareCatalogTests.cs | 25 + src/UniGetUI.Tui/Views/Pages/ManagersPage.cs | 3 +- src/UniGetUI.Windows.slnx | 4 + 40 files changed, 3304 insertions(+), 11 deletions(-) create mode 100644 docs/PKG_FEEDS.md create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeedIndex.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgMetadata.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgPayload.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/XarArchive.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/UniGetUI.PackageEngine.Managers.MacPkg.csproj create mode 100644 src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs diff --git a/AGENTS.md b/AGENTS.md index 14b528213f..ce4df1aab1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,29 @@ 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 and .dmg) + +`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 after the catalog (`PkgCatalogFeeds`). 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 and the package identity, then `installer -pkg`, or `ditto` of a `.dmg`'s app into + `/Applications`, as root through `osascript ... with administrator privileges`. `ManagerCapabilities.CanUninstall` is false, and the UI refuses + uninstalls for such managers. + ## 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..eaa88426c6 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -356,6 +356,7 @@ 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-index ` | Writes the `index.json` that publishes a folder of macOS `.pkg` and `.dmg` 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..0bedf6eca8 --- /dev/null +++ b/docs/PKG_FEEDS.md @@ -0,0 +1,93 @@ +# Publishing macOS apps (.pkg and .dmg) + +The software catalog can offer apps that no package manager carries, the way Jamf Self Service +does: an administrator publishes the vendor's `.pkg` or `.dmg` 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 Jamf 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. + +## Set up a feed once + +A feed is a location holding `.pkg` and `.dmg` files. 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 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`), 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** download the file (or copy it from the share) and check its hash and + identity. Behind the standard macOS administrator prompt, a `.pkg` then runs through + `installer`, and a `.dmg`'s app is copied to `/Applications`, replacing the previous version. +- Packages cannot be uninstalled from UniGetUI, as with Jamf Self Service. +- When the feed is unreachable (off the VPN, share not mounted), UniGetUI keeps showing the + packages it last listed. Installing needs the feed again. + +## Limits + +- Only flat packages are read; legacy bundle packages (a `.pkg` that is a folder) are skipped. +- A `.dmg` must hold exactly one app at its root (an uninstaller app beside it is ignored). An + image that holds a `.pkg` instead is not supported. +- 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..b807c3d7cf 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -119,6 +119,13 @@ "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)", + "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/Models/SoftwareCatalog.cs b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs index 1baf858762..25639d2083 100644 --- a/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs +++ b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs @@ -25,6 +25,11 @@ 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; } } public sealed class CatalogEntry @@ -138,7 +143,8 @@ internal static bool HasUsableCatalog(string filePath) { try { - return File.Exists(filePath) && Parse(File.ReadAllText(filePath)).Any(c => c.Packages.Length > 0); + return File.Exists(filePath) + && Parse(File.ReadAllText(filePath)).Any(c => c.Packages.Length > 0 || !string.IsNullOrWhiteSpace(c.PkgFeed)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { @@ -169,6 +175,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..0378ca0684 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; @@ -38,6 +41,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..02d868739c 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs @@ -21,14 +21,18 @@ public partial class CatalogEditorCatalog : ViewModelBase public ObservableCollection Packages { get; } + // Not edited here, but kept so that saving does not drop it. + public string? PkgFeed { get; } + public CatalogEditorCatalog(CatalogDefinition definition) { _id = definition.Id; _name = definition.Name; Packages = new(definition.Packages); + PkgFeed = definition.PkgFeed; } - public CatalogDefinition ToDefinition() => new() { Id = Id, Name = Name, Packages = Packages.ToArray() }; + public CatalogDefinition ToDefinition() => new() { Id = Id, Name = Name, Packages = Packages.ToArray(), PkgFeed = PkgFeed }; } public sealed record CatalogAddResult(CatalogEditorCatalog Catalog, int Added, int AlreadyPresent); @@ -365,6 +369,7 @@ private static CatalogDefinition[] CopyCatalogs(IEnumerable c { Id = c.Id, Name = c.Name, + PkgFeed = c.PkgFeed, Packages = c.Packages.Select(p => new CatalogEntry { Id = p.Id, diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs index 598ec5c619..6b063be911 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs @@ -346,7 +346,7 @@ public async Task RefreshAsync() UpdateStates(); try { - var catalogs = await SoftwareCatalog.LoadAsync(); + var catalogs = await ExpandPkgFeedsAsync(await SoftwareCatalog.LoadAsync()); _notConfigured = false; BackgroundDetail = ""; @@ -400,6 +400,37 @@ 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 of the catalog's name. + 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; + var published = await Task.Run(() => manager.FindPackages("")); + return catalogs.Select(c => c.PkgFeed is null ? c : new CatalogDefinition + { + Id = c.Id, + Name = c.Name, + PkgFeed = c.PkgFeed, + Packages = + [ + .. c.Packages, + .. published + .Where(p => string.Equals(p.Source.Name, c.Name.Trim(), 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()); @@ -807,6 +838,11 @@ public partial class CatalogTileViewModel(CatalogEntry entry, CatalogDefinition? public string SourceDisplay => $"{ManagerDisplayName}: {Entry.Source}"; + 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; public string PinnedVersionText => HasPinnedVersion ? CoreTools.Translate("Version {0}", Entry.Version) : ""; @@ -862,6 +898,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/Pages/SettingsPages/PackageManagerPage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs index c6aa9a7d30..7ff73bdc6d 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs @@ -425,7 +425,8 @@ private void BuildExtraControls(CheckboxCard_Dict disableNotifsCard) { ExtraControls.Children.Clear(); var manager = ViewModel.Manager; - bool managerHasSources = manager.Capabilities.SupportsCustomSources && manager.Name != "vcpkg"; + // vcpkg has no source commands; .pkg feeds are declared in the software catalog. + bool managerHasSources = manager.Capabilities.SupportsCustomSources && manager.Name != "vcpkg" && manager.Id != "pkg"; if (managerHasSources) { diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs index 9b65e7ecd9..e1de6682c8 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/InstalledPackagesPage.cs @@ -395,20 +395,34 @@ private static async Task ConfirmAndLaunchUninstall( bool? interactive = null, bool? remove_data = null) { - var list = packages.ToList(); + var list = ExcludeNonUninstallable(packages); if (list.Count == 0 || MainWindow.Instance is not { } window) return; if (!await UninstallConfirmationDialog.ConfirmAsync(window, list)) return; await LaunchUninstall(list, elevated, interactive, remove_data); } + // Installer packages (.pkg) have no uninstaller: say so instead of queuing an operation that fails. + private static List ExcludeNonUninstallable(IEnumerable packages) + { + var list = packages.ToList(); + var refused = list.Where(p => !p.Manager.Capabilities.CanUninstall).ToList(); + if (refused.Count > 0) + MainWindow.Instance?.ShowBanner( + CoreTools.Translate("Some packages cannot be uninstalled"), + CoreTools.Translate("{0} cannot be uninstalled from UniGetUI. Ask your administrator to remove it.", + string.Join(", ", refused.Select(p => p.Name))), + MainWindow.RuntimeNotificationLevel.Error); + return list.Except(refused).ToList(); + } + internal static async Task LaunchUninstall( IEnumerable packages, bool? elevated = null, bool? interactive = null, bool? remove_data = null) { - var list = packages.ToList(); + var list = ExcludeNonUninstallable(packages); if (list.Count == 0) return; foreach (var pkg in list) diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml index bcad0dbce3..932697a753 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareCatalogPage.axaml @@ -158,7 +158,7 @@ - + diff --git a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs index b9428ab04c..0324fe4d9d 100644 --- a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs +++ b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs @@ -30,6 +30,8 @@ public struct ManagerCapabilities public bool CanSkipIntegrityChecks = false; public bool CanRunInteractively = false; public bool CanRemoveDataOnUninstall = false; + // False for managers that install but cannot remove, such as macOS .pkg files. + public bool CanUninstall = true; public bool CanDownloadInstaller = false; public bool CanUninstallPreviousVersionsAfterUpdate = false; public bool CanListDependencies = false; diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs index b3d3532bd8..8a188fe6ec 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs @@ -32,9 +32,14 @@ public static long GetCacheKey(IPackage package, string? version = null) /// A Uri object public static Uri GetManifestUrl(IPackage package, string? version = null) { - return new Uri( - $"{package.Source.Url}/Packages(Id='{package.Id}',Version='{version ?? package.VersionString}')" - ); + version ??= package.VersionString; + // A package without a version, such as an unpinned Software Catalog entry, gets the + // newest listed one; "Version=''" matches nothing. + if (version.Length == 0) + return new Uri( + $"{package.Source.Url}/Packages()?$filter=Id eq '{Uri.EscapeDataString(package.Id)}' and IsLatestVersion" + ); + return new Uri($"{package.Source.Url}/Packages(Id='{package.Id}',Version='{version}')"); } /// diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs new file mode 100644 index 0000000000..3486cfb319 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs @@ -0,0 +1,50 @@ +using UniGetUI.Core.IconEngine; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager; + +internal sealed class MacPkgDetailsHelper : BasePkgDetailsHelper +{ + private readonly MacPkg _manager; + + public MacPkgDetailsHelper(MacPkg manager) + : base(manager) { _manager = manager; } + + // A .pkg carries no description or publisher; the details show what the feed knows. + protected override void GetDetails_UnSafe(IPackageDetails details) + { + var item = _manager.FindFeedItem(details.Package); + if (item is null) + return; + details.Version = item.Version; + details.InstallerUrl = item.Location; + details.InstallerHash = item.Sha256; + details.InstallerType = item.IsDiskImage + ? CoreTools.Translate("Disk image (.dmg)") + : CoreTools.Translate("Installer package (.pkg)"); + details.InstallerSize = item.Size; + details.ManifestUrl = item.Location.IsFile ? null : item.Location; + details.Description = CoreTools.Translate("Published by your organization in {0}.", details.Package.Source.Name); + } + + protected override CacheableIcon? GetIcon_UnSafe(IPackage package) + { + var icon = _manager.FindFeedItem(package)?.Icon; + if (icon is null) + return null; + return icon.IsFile ? new CacheableIcon(icon.LocalPath) : new CacheableIcon(icon, package.VersionString); + } + + protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) => []; + + protected override string? GetInstallLocation_UnSafe(IPackage package) => null; + + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) => + _manager.ListFeedItems(package) + .Select(i => i.Version) + .Distinct(StringComparer.Ordinal) + .OrderDescending(Comparer.Create((a, b) => _manager.CompareVersions(a, b) ?? string.CompareOrdinal(a, b))) + .ToArray(); +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs new file mode 100644 index 0000000000..ab47d2dff8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs @@ -0,0 +1,51 @@ +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Interfaces.ManagerProviders; +using UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager; + +/// +/// Installs and updates run in-process (). macOS has no uninstaller for +/// .pkg, and neither does Jamf Self Service, so uninstalling is refused. +/// +internal sealed class MacPkgOperationHelper : BasePkgOperationHelper, IManagedPackageOperationHelper +{ + private readonly MacPkg _manager; + + public MacPkgOperationHelper(MacPkg manager) + : base(manager) { _manager = manager; } + + // Only shown as the operation's command line; the install itself never runs it. + protected override IReadOnlyList _getOperationParameters( + IPackage package, InstallOptions options, OperationType operation) => + ["-pkg", package.Id, "-target", "/"]; + + protected override OperationVeredict _getOperationResult( + IPackage package, OperationType operation, IReadOnlyList processOutput, int returnCode) => + returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + + public async Task TryPerformAsync(IPackage package, InstallOptions options, + OperationType operation, Action log, Action progress, + CancellationToken cancellationToken) + { + if (operation == OperationType.Uninstall) + { + log("Installer packages cannot be uninstalled by UniGetUI. Ask your administrator to remove this app."); + return OperationVeredict.Failure; + } + + string? version = operation == OperationType.Update ? package.NewVersionString + : options.Version.Length > 0 ? options.Version : null; + var item = await Task.Run(() => _manager.FindFeedItem(package, version), cancellationToken) + ?? throw new InvalidOperationException( + $"The package feed {package.Source.Name} does not publish {package.Id}{(version is null ? "" : " " + version)} for this Mac."); + + var verdict = await PkgInstaller.InstallAsync(item, package.Name, log, progress, cancellationToken); + if (verdict == OperationVeredict.Success) + _manager.InvalidateInstalledPackages(); + return verdict; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs new file mode 100644 index 0000000000..a8dd6ba2c0 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs @@ -0,0 +1,34 @@ +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Classes.Manager.Providers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager; + +/// +/// Feeds come from the machine-wide software catalog, which an administrator deploys; they cannot +/// be added or removed from UniGetUI. +/// +internal sealed class MacPkgSourceHelper : BaseSourceHelper +{ + public MacPkgSourceHelper(MacPkg manager) + : base(manager) { } + + protected override IReadOnlyList GetSources_UnSafe() => + PkgCatalogFeeds.Load() + .Select(feed => (IManagerSource)new ManagerSource(Manager, feed.Name, feed.Location)) + .ToArray(); + + public override string[] GetAddSourceParameters(IManagerSource source) => + throw new NotSupportedException("Package feeds are declared in the software catalog."); + + public override string[] GetRemoveSourceParameters(IManagerSource source) => + throw new NotSupportedException("Package feeds are declared in the software catalog."); + + protected override OperationVeredict _getAddSourceOperationVeredict(IManagerSource source, int ReturnCode, string[] Output) => + OperationVeredict.Failure; + + protected override OperationVeredict _getRemoveSourceOperationVeredict(IManagerSource source, int ReturnCode, string[] Output) => + OperationVeredict.Failure; +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs new file mode 100644 index 0000000000..5e55e8faf8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs @@ -0,0 +1,183 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Xml.Linq; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// The application a feed package puts on disk: where, with which bundle id and version. +internal sealed record AppBundleInfo(string BundleId, string Path, string? Version); + +/// +/// Reads .app bundles: their Info.plist and the architectures of their executable. An installed +/// app is recognized by its bundle id at the path the package installs it to, however it got there. +/// +internal static class AppBundle +{ + private const int MaxPlistBytes = 4 * 1024 * 1024; + + /// Prefixed to absolute app paths, so tests can stand in for /Applications. + internal static string? TestRootOverride { get; set; } + + internal static string OnDisk(string absolutePath) => + TestRootOverride is null ? absolutePath : System.IO.Path.Join(TestRootOverride, absolutePath); + + /// The app installed where says, or null when absent or another app. + public static AppBundleInfo? FindInstalled(AppBundleInfo expected) + { + string path = OnDisk(expected.Path); + if (!Directory.Exists(path)) + return null; + try + { + var plist = ReadInfoPlist(path); + return plist.GetValueOrDefault("CFBundleIdentifier") == expected.BundleId + ? new AppBundleInfo(expected.BundleId, expected.Path, VersionOf(plist)) + : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException + or System.Xml.XmlException) + { + return null; + } + } + + public static string? VersionOf(IReadOnlyDictionary plist) => + plist.GetValueOrDefault("CFBundleShortVersionString") is { Length: > 0 } version + ? version + : plist.GetValueOrDefault("CFBundleVersion"); + + /// The top-level string values of an app's Info.plist, in XML or binary form. + public static IReadOnlyDictionary ReadInfoPlist(string appPath) + { + string file = System.IO.Path.Combine(appPath, "Contents", "Info.plist"); + var info = new FileInfo(file); + if (!info.Exists || info.Length > MaxPlistBytes) + throw new InvalidDataException($"{appPath} has no readable Info.plist."); + return ParsePlist(File.ReadAllBytes(file)); + } + + /// The top-level string values of a property list in XML or binary form. + public static IReadOnlyDictionary ParsePlist(byte[] bytes) => + ParseXmlPlist(bytes.AsSpan().StartsWith("bplist"u8) ? ConvertBinaryPlist(bytes) : bytes); + + /// The .icns file an app names as its icon, with the extension the bundle may omit. + public static string? IconFileName(IReadOnlyDictionary plist) => + plist.GetValueOrDefault("CFBundleIconFile") is { Length: > 0 } icon && !icon.Contains('/') + ? icon.EndsWith(".icns", StringComparison.OrdinalIgnoreCase) ? icon : icon + ".icns" + : null; + + /// Converts an .icns to a 256-pixel PNG with sips, which ships with macOS. + public static bool ConvertIcon(string icns, string destinationPng) + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(destinationPng)!); + var (code, output) = DiskImage.RunTool("/usr/bin/sips", + ["-s", "format", "png", "-Z", "256", icns, "--out", destinationPng], null); + if (code == 0) + return true; + Logger.Warn($"Could not convert the icon {icns}: {output.Trim()}"); + return false; + } + + internal static IReadOnlyDictionary ParseXmlPlist(byte[] xml) + { + XElement dict = XarArchive.ParseXml(xml).Root?.Element("dict") + ?? throw new InvalidDataException("The property list has no top-level dictionary."); + var values = new Dictionary(StringComparer.Ordinal); + string? key = null; + foreach (XElement element in dict.Elements()) + { + if (element.Name == "key") + key = element.Value; + else if (key is not null) + { + if (element.Name == "string") + values.TryAdd(key, element.Value.Trim()); + key = null; + } + } + return values; + } + + // plutil ships with macOS and is the supported reader of binary property lists. + private static byte[] ConvertBinaryPlist(byte[] binary) + { + if (!OperatingSystem.IsMacOS()) + throw new InvalidDataException("Binary property lists can only be read on macOS."); + var startInfo = new ProcessStartInfo("/usr/bin/plutil") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + startInfo.RedirectStandardInput = true; + foreach (string argument in new[] { "-convert", "xml1", "-o", "-", "-" }) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) ?? throw new InvalidDataException("Could not start plutil."); + var writing = Task.Run(() => + { + process.StandardInput.BaseStream.Write(binary); + process.StandardInput.Close(); + }); + using var output = new MemoryStream(); + process.StandardOutput.BaseStream.CopyTo(output); + process.WaitForExit(); + writing.Wait(); + if (process.ExitCode != 0 || output.Length > MaxPlistBytes) + throw new InvalidDataException("plutil could not read a binary property list."); + return output.ToArray(); + } + + private const uint FatMagic = 0xCAFEBABE; + private const uint FatMagic64 = 0xCAFEBABF; + private const uint MachMagic64 = 0xFEEDFACF; + private const int CpuTypeX64 = 0x01000007; + private const int CpuTypeArm64 = 0x0100000C; + + /// The architectures of an app's main executable, from its Mach-O header; empty when unknown. + public static IReadOnlyList ReadArchitectures(string appPath, IReadOnlyDictionary plist) + { + if (plist.GetValueOrDefault("CFBundleExecutable") is not { Length: > 0 } executable || executable.Contains('/')) + return []; + string file = System.IO.Path.Combine(appPath, "Contents", "MacOS", executable); + try + { + using var stream = File.OpenRead(file); + byte[] header = new byte[8 + 20 * 32]; + int read = stream.ReadAtLeast(header, 8, throwOnEndOfStream: false); + return ParseArchitectures(header.AsSpan(0, read)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return []; + } + } + + internal static IReadOnlyList ParseArchitectures(ReadOnlySpan header) + { + if (header.Length < 8) + return []; + uint magic = BinaryPrimitives.ReadUInt32BigEndian(header); + if (magic is FatMagic or FatMagic64) + { + int count = (int)BinaryPrimitives.ReadUInt32BigEndian(header[4..]); + int stride = magic == FatMagic ? 20 : 32; + var architectures = new List(); + for (int i = 0; i < count && 8 + (i + 1) * stride <= header.Length; i++) + if (Name(BinaryPrimitives.ReadInt32BigEndian(header[(8 + i * stride)..])) is { } name) + architectures.Add(name); + return architectures; + } + if (BinaryPrimitives.ReadUInt32LittleEndian(header) == MachMagic64 + && Name(BinaryPrimitives.ReadInt32LittleEndian(header[4..])) is { } single) + return [single]; + return []; + } + + private static string? Name(int cpuType) => cpuType switch + { + CpuTypeX64 => "x86_64", + CpuTypeArm64 => "arm64", + _ => null, + }; +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs new file mode 100644 index 0000000000..b6338f1717 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs @@ -0,0 +1,152 @@ +using System.Diagnostics; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// +/// A .dmg holding an app to drag to /Applications, the way most vendors ship Mac apps. It is +/// mounted read-only and out of sight of Finder; the app at its root describes the package. +/// +internal sealed class DiskImage : IDisposable +{ + private const string HdiUtilPath = "/usr/bin/hdiutil"; + private static readonly TimeSpan MountTimeout = TimeSpan.FromMinutes(2); + + public string MountPoint { get; } + + private DiskImage(string mountPoint) => MountPoint = mountPoint; + + public static bool IsDiskImage(string path) => path.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase); + + public static DiskImage Mount(string image) + { + if (!OperatingSystem.IsMacOS()) + throw new InvalidDataException("Disk images can only be read on macOS."); + string mountPoint = Path.Combine(Path.GetTempPath(), "UniGetUI-dmg", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(mountPoint); + // "Y" accepts the license agreement some vendors put in front of their image. An image another + // process has attached is "busy" until that process detaches it, so a few retries cover it. + (int code, string output) = (0, ""); + for (int attempt = 0; attempt < 5; attempt++) + { + if (attempt > 0) + Thread.Sleep(TimeSpan.FromSeconds(1)); + (code, output) = Run(["attach", "-nobrowse", "-readonly", "-noverify", "-noautoopen", + "-mountpoint", mountPoint, image], "Y\n"); + if (code == 0 || !output.Contains("Resource busy", StringComparison.OrdinalIgnoreCase)) + break; + } + if (code != 0) + { + TryDeleteDirectory(mountPoint); + throw new InvalidDataException($"The disk image {image} could not be mounted: {output.Trim()}"); + } + return new DiskImage(mountPoint); + } + + public void Dispose() + { + var (code, output) = Run(["detach", MountPoint, "-force"], null); + if (code != 0) + Logger.Warn($"Could not detach the disk image at {MountPoint}: {output.Trim()}"); + TryDeleteDirectory(MountPoint); + } + + /// The app at the image's root. Links (such as the usual one to /Applications) are ignored. + public string FindApp() + { + var apps = new DirectoryInfo(MountPoint).EnumerateDirectories("*.app") + .Where(d => d.LinkTarget is null) + .Select(d => d.FullName) + .ToList(); + if (apps.Count > 1) + apps.RemoveAll(a => Path.GetFileName(a).Contains("uninstall", StringComparison.OrdinalIgnoreCase)); + return apps.Count switch + { + 1 => apps[0], + 0 => throw new InvalidDataException("The disk image has no app at its root."), + _ => throw new InvalidDataException("The disk image has several apps at its root: " + + string.Join(", ", apps.Select(Path.GetFileName)) + "."), + }; + } + + /// Reads the app a disk image carries; with , also saves its icon as a PNG. + public static PkgMetadata ReadMetadata(string image, string? iconFile = null) + { + using var mounted = Mount(image); + string app = mounted.FindApp(); + var metadata = FromApp(app); + if (iconFile is not null) + TryExtractIcon(app, iconFile); + return metadata; + } + + internal static PkgMetadata FromApp(string app) + { + var plist = AppBundle.ReadInfoPlist(app); + string id = plist.GetValueOrDefault("CFBundleIdentifier") ?? ""; + string? version = AppBundle.VersionOf(plist); + if (id.Length == 0 || string.IsNullOrEmpty(version)) + throw new InvalidDataException($"{Path.GetFileName(app)} has no bundle identifier or version."); + string name = Path.GetFileName(app); + string? title = plist.GetValueOrDefault("CFBundleDisplayName") is { Length: > 0 } display ? display + : Path.GetFileNameWithoutExtension(name); + return new PkgMetadata(id, version, title, AppBundle.ReadArchitectures(app, plist), + new AppBundleInfo(id, "/Applications/" + name, version)); + } + + // An app without an .icns (or naming one it lacks) falls back to any .icns it carries, or has none. + private static void TryExtractIcon(string app, string destination) + { + try + { + string resources = Path.Combine(app, "Contents", "Resources"); + string? source = AppBundle.IconFileName(AppBundle.ReadInfoPlist(app)) is { } icon + ? Path.Combine(resources, icon) + : null; + if (source is null || !File.Exists(source)) + source = Directory.Exists(resources) ? Directory.EnumerateFiles(resources, "*.icns").FirstOrDefault() : null; + if (source is not null) + AppBundle.ConvertIcon(source, destination); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + Logger.Warn($"Could not extract the icon of {app}: {ex.Message}"); + } + } + + private static (int, string) Run(string[] arguments, string? input) => RunTool(HdiUtilPath, arguments, input); + + internal static (int, string) RunTool(string tool, string[] arguments, string? input) + { + var startInfo = new ProcessStartInfo(tool) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) ?? throw new InvalidDataException($"Could not start {tool}."); + if (input is not null) + process.StandardInput.Write(input); + process.StandardInput.Close(); + var stderr = process.StandardError.ReadToEndAsync(); + var stdout = process.StandardOutput.ReadToEndAsync(); + if (!process.WaitForExit(MountTimeout)) + { + process.Kill(entireProcessTree: true); + throw new InvalidDataException($"{Path.GetFileName(tool)} did not finish within {MountTimeout.TotalMinutes} minutes."); + } + return (process.ExitCode, stdout.Result + stderr.Result); + } + + private static void TryDeleteDirectory(string path) + { + try { Directory.Delete(path); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs new file mode 100644 index 0000000000..c9bf520e50 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +internal readonly record struct PkgFeedDeclaration(string Name, Uri Location); + +/// +/// Reads the feeds that the machine-wide software catalog declares through each catalog's +/// pkgFeed, so an administrator configures a feed once, in the file they already deploy. +/// +internal static class PkgCatalogFeeds +{ + internal static string? TestCatalogPathOverride { get; set; } + + public static string CatalogPath => + TestCatalogPathOverride ?? Path.Combine(CoreData.UniGetUIGlobalDirectory, "SoftwareCatalog.json"); + + public static IReadOnlyList Load() => Load(CatalogPath); + + internal static IReadOnlyList Load(string path) + { + try + { + if (!File.Exists(path)) + return []; + return Parse(File.ReadAllBytes(path)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + Logger.Warn($"Could not read the package feeds of the software catalog at {path}: {ex.Message}"); + return []; + } + } + + // Catalogs that share a name share a source, so the first one wins. + internal static IReadOnlyList Parse(byte[] json) + { + var document = JsonSerializer.Deserialize(json, PkgCatalogJsonContext.Default.PkgCatalogDocument); + var feeds = new List(); + foreach (var catalog in document?.Catalogs ?? []) + { + if (catalog?.PkgFeed is null || string.IsNullOrWhiteSpace(catalog.Name)) + continue; + if (!PkgFeed.TryParseLocation(catalog.PkgFeed, out Uri location)) + { + Logger.Warn($"Ignoring the package feed of catalog {catalog.Name}: \"{catalog.PkgFeed}\" is not an https URL or an absolute folder path."); + continue; + } + string name = catalog.Name.Trim(); + if (!feeds.Any(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase))) + feeds.Add(new PkgFeedDeclaration(name, location)); + } + return feeds; + } +} + +internal sealed class PkgCatalogDocument +{ + [JsonPropertyName("catalogs")] + public PkgCatalogDefinition?[]? Catalogs { get; init; } +} + +internal sealed class PkgCatalogDefinition +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("pkgFeed")] + public string? PkgFeed { get; init; } +} + +[JsonSerializable(typeof(PkgCatalogDocument))] +internal partial class PkgCatalogJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs new file mode 100644 index 0000000000..b97acafa44 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs @@ -0,0 +1,390 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// One installable .pkg or .dmg of a feed, with the app it installs when known. +internal sealed record PkgFeedItem( + string Id, + string Name, + string Version, + Uri Location, + string? Sha256, + long Size, + IReadOnlyList Architectures, + Uri? Icon, + AppBundleInfo? App = null) +{ + public bool IsDiskImage => DiskImage.IsDiskImage(Location.AbsolutePath); +} + +/// +/// Lists the packages of a feed. A file: location (a local folder or a mounted share) is +/// scanned directly; an https: location is read through the index.json beside its packages. +/// +internal static class PkgFeed +{ + internal const string IndexFileName = "index.json"; + private const int MaxScanDepth = 3; + private const int MaxIndexBytes = 8 * 1024 * 1024; + private static readonly TimeSpan IndexLifetime = TimeSpan.FromMinutes(5); + private static readonly string[] IconExtensions = [".png", ".jpg", ".jpeg"]; + private static readonly string[] PackageExtensions = [".pkg", ".mpkg", ".dmg"]; + + private readonly record struct ParsedFile(long Size, DateTime WriteTimeUtc, PkgMetadata Metadata); + private readonly record struct CachedIndex(DateTime FetchedUtc, IReadOnlyList Items); + + private static readonly ConcurrentDictionary ParsedFiles = new(StringComparer.Ordinal); + private static readonly ConcurrentDictionary Indexes = new(); + + /// + /// Turns the location an administrator wrote (an https URL, a file URL or an absolute path) + /// into a feed address. Plain http is refused except on loopback, since the index pins hashes. + /// + public static bool TryParseLocation(string? text, out Uri location) + { + location = null!; + text = text?.Trim(); + if (string.IsNullOrEmpty(text)) + return false; + if (Path.IsPathRooted(text) && !text.Contains("://", StringComparison.Ordinal)) + { + location = new Uri(Path.GetFullPath(text)); + return true; + } + if (!Uri.TryCreate(text, UriKind.Absolute, out Uri? uri)) + return false; + if (uri.IsFile || uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) + { + location = uri; + return true; + } + return false; + } + + public static void ForgetCachedIndexes() => Indexes.Clear(); + + private static readonly ConcurrentDictionary> LastKnown = new(); + + /// + /// Lists a feed. When it cannot be reached (off the VPN, share not mounted) the last listing is + /// reused, so installed packages keep their state; a web feed's last index also survives restarts. + /// + public static IReadOnlyList List(Uri location, CancellationToken cancellationToken = default) + { + try + { + var items = location.IsFile ? ScanFolder(location.LocalPath, cancellationToken) : ReadIndex(location, cancellationToken); + LastKnown[location] = items; + return items; + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + if (LastKnown.TryGetValue(location, out var known) || TryReadSavedIndex(location, out known)) + { + Logger.Warn($"The package feed {location} is unreachable ({ex.Message}); using its last known contents."); + return known; + } + throw; + } + } + + private static string SavedIndexPath(Uri indexUrl) => Path.Combine(CoreData.UniGetUICacheDirectory_Data, "PkgFeeds", + Convert.ToHexStringLower(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(indexUrl.AbsoluteUri))) + ".json"); + + private static void SaveIndex(Uri indexUrl, byte[] json) + { + try + { + string path = SavedIndexPath(indexUrl); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, json); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Warn($"Could not keep a copy of the package feed index {indexUrl}: {ex.Message}"); + } + } + + private static bool TryReadSavedIndex(Uri location, out IReadOnlyList items) + { + items = []; + if (location.IsFile) + return false; + try + { + Uri indexUrl = IndexUrl(location); + string path = SavedIndexPath(indexUrl); + if (!File.Exists(path)) + return false; + items = ParseIndex(File.ReadAllBytes(path), indexUrl); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + return false; + } + } + + /// + /// The newest package of each identifier that this Mac can run, preferring a native build over + /// one that needs Rosetta when both carry the same version. + /// + public static IReadOnlyList Latest(IEnumerable items, Func compare, + Architecture machine) + { + var latest = new List(); + foreach (var group in items.GroupBy(i => i.Id, StringComparer.Ordinal)) + { + PkgFeedItem? best = null; + int bestFit = 0; + foreach (var item in group) + { + int fit = PkgMetadata.ArchitectureFit(item.Architectures, machine); + if (fit == 0) + continue; + int order = best is null ? 1 : compare(item.Version, best.Version) ?? 0; + if (order > 0 || (order == 0 && fit > bestFit)) + { + best = item; + bestFit = fit; + } + } + if (best is not null) + latest.Add(best); + } + return latest; + } + + // ── Folder feeds ─────────────────────────────────────────────────────── + + internal static IReadOnlyList ScanFolder(string directory, CancellationToken cancellationToken) + { + if (!Directory.Exists(directory)) + throw new DirectoryNotFoundException($"The package feed folder {directory} does not exist or is not mounted."); + + var items = new List(); + foreach (string file in EnumeratePackages(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (TryReadFile(file) is { } metadata) + items.Add(ToItem(metadata, file, sha256: null)); + } + return items; + } + + internal static IEnumerable EnumeratePackages(string directory) + { + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + MaxRecursionDepth = MaxScanDepth, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.Hidden | FileAttributes.System, + }; + // Bundle-style packages are directories; only flat packages and disk images (files) are read. + return Directory.EnumerateFiles(directory, "*", options) + .Where(f => PackageExtensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase)) + .Order(StringComparer.Ordinal); + } + + // Listings run in parallel at startup, and a second mount of a disk image that is already + // attached comes back empty, so each file is read by one listing at a time. + private static readonly ConcurrentDictionary FileLocks = new(StringComparer.Ordinal); + + internal static void ForgetReadFiles() => ParsedFiles.Clear(); + + internal static PkgMetadata? TryReadFile(string file) + { + try + { + lock (FileLocks.GetOrAdd(file, _ => new object())) + { + var info = new FileInfo(file); + if (ParsedFiles.TryGetValue(file, out var cached) + && cached.Size == info.Length && cached.WriteTimeUtc == info.LastWriteTimeUtc) + return cached.Metadata; + // What was read from a file, its icon included, is kept on disk per file version, so a + // file is read once; one without an icon is not searched again at every launch. + string icon = ExtractedIconPath(info); + string saved = Path.ChangeExtension(icon, ".json"); + var metadata = TryLoadMetadata(saved); + if (metadata is null) + { + metadata = PkgMetadata.Read(file, File.Exists(icon) ? null : icon); + TryWriteFile(saved, JsonSerializer.SerializeToUtf8Bytes(metadata, PkgMetadataJsonContext.Default.PkgMetadata)); + } + ParsedFiles[file] = new ParsedFile(info.Length, info.LastWriteTimeUtc, metadata); + return metadata; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException + or System.Xml.XmlException or EndOfStreamException) + { + Logger.Warn($"Skipping {file}: it could not be read as an installer package ({ex.Message})"); + return null; + } + } + + private static PkgFeedItem ToItem(PkgMetadata metadata, string file, string? sha256) => new( + metadata.Id, + metadata.Title ?? Path.GetFileNameWithoutExtension(file), + metadata.Version, + new Uri(file), + sha256, + new FileInfo(file).Length, + metadata.Architectures, + FindIcon(file) is { } icon ? new Uri(icon) : null, + metadata.App); + + /// An image next to the package with the same name, else the icon taken from the package's app. + internal static string? FindIcon(string packageFile) + { + string stem = Path.Combine(Path.GetDirectoryName(packageFile)!, Path.GetFileNameWithoutExtension(packageFile)); + if (IconExtensions.Select(e => stem + e).FirstOrDefault(File.Exists) is { } sibling) + return sibling; + string extracted = ExtractedIconPath(new FileInfo(packageFile)); + return File.Exists(extracted) ? extracted : null; + } + + /// Where extracted icons are kept. Replaceable so tests stay out of the user's cache. + internal static string? TestIconDirectoryOverride { get; set; } + + // Bump when what is read from a file changes, so files are read again instead of trusting what + // an older reader made of them (its metadata, or an icon it could not find). + private const string ReaderVersion = "3"; + + // Named from the reader version and the file's path, size and write time, so a replaced + // package gets its new icon. + private static string ExtractedIconPath(FileInfo package) => Path.Combine( + TestIconDirectoryOverride ?? Path.Combine(CoreData.UniGetUICacheDirectory_Icons, "PkgFeeds"), + Convert.ToHexStringLower(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes( + $"{ReaderVersion}|{package.FullName}|{package.Length}|{package.LastWriteTimeUtc.Ticks}")))[..32] + ".png"); + + private static PkgMetadata? TryLoadMetadata(string saved) + { + try + { + return File.Exists(saved) + ? JsonSerializer.Deserialize(File.ReadAllBytes(saved), PkgMetadataJsonContext.Default.PkgMetadata) + : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return null; + } + } + + private static void TryWriteFile(string path, byte[] contents) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, contents); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Logger.Debug($"Could not write the package feed cache file {path}: {ex.Message}"); + } + } + + // ── Web feeds ────────────────────────────────────────────────────────── + + internal static Uri IndexUrl(Uri location) => + location.AbsolutePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + ? location + : new Uri(location.AbsoluteUri.TrimEnd('/') + "/" + IndexFileName); + + private static IReadOnlyList ReadIndex(Uri location, CancellationToken cancellationToken) + { + Uri indexUrl = IndexUrl(location); + if (Indexes.TryGetValue(indexUrl, out var cached) && DateTime.UtcNow - cached.FetchedUtc < IndexLifetime) + return cached.Items; + + using var client = new HttpClient(CoreTools.GenericHttpClientParameters); + client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); + using var response = client.GetAsync(indexUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .GetAwaiter().GetResult(); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new FileNotFoundException($"The package feed has no index at {indexUrl}. Run 'unigetui pkg-index' on its folder."); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength > MaxIndexBytes) + throw new InvalidDataException($"The package feed index at {indexUrl} is larger than {MaxIndexBytes} bytes."); + + using var body = response.Content.ReadAsStream(cancellationToken); + using var limited = new MemoryStream(); + byte[] buffer = new byte[81920]; + int read; + while ((read = body.Read(buffer)) > 0) + { + if (limited.Length + read > MaxIndexBytes) + throw new InvalidDataException($"The package feed index at {indexUrl} is larger than {MaxIndexBytes} bytes."); + limited.Write(buffer, 0, read); + } + + byte[] json = limited.ToArray(); + var items = ParseIndex(json, indexUrl); + Indexes[indexUrl] = new CachedIndex(DateTime.UtcNow, items); + SaveIndex(indexUrl, json); + return items; + } + + internal static IReadOnlyList ParseIndex(byte[] json, Uri indexUrl) + { + var index = JsonSerializer.Deserialize(json, PkgFeedJsonContext.Default.PkgFeedIndex) + ?? throw new InvalidDataException("The package feed index is empty."); + if (index.Version != PkgFeedIndex.CurrentVersion) + throw new InvalidDataException($"Unsupported package feed index version: {index.Version}."); + + var items = new List(); + foreach (var entry in index.Packages ?? []) + { + if (entry is null || string.IsNullOrWhiteSpace(entry.Id) || string.IsNullOrWhiteSpace(entry.Version) + || !IsSha256(entry.Sha256) || ResolveWithin(indexUrl, entry.File) is not { } file) + { + Logger.Warn($"Skipping an invalid entry ({entry?.Id ?? "null"}) in the package feed index {indexUrl}."); + continue; + } + items.Add(new PkgFeedItem( + entry.Id.Trim(), + string.IsNullOrWhiteSpace(entry.Name) ? entry.Id.Trim() : entry.Name.Trim(), + entry.Version.Trim(), + file, + entry.Sha256.ToLowerInvariant(), + entry.Size, + entry.Architectures ?? [], + ResolveWithin(indexUrl, entry.Icon), + entry.App?.ToInfo())); + } + return items; + } + + // Index paths are relative and may not climb out of the feed, so an index cannot point + // installs at another host or scheme. + internal static Uri? ResolveWithin(Uri indexUrl, string? relative) + { + if (string.IsNullOrWhiteSpace(relative) || relative.Contains('\\') || relative.StartsWith('/') + || relative.Contains("://", StringComparison.Ordinal) + || relative.Split('/').Any(segment => segment is ".." or "." or "")) + return null; + var resolved = new Uri(indexUrl, string.Join('/', relative.Split('/').Select(Uri.EscapeDataString))); + string root = indexUrl.AbsoluteUri[..(indexUrl.AbsoluteUri.LastIndexOf('/') + 1)]; + return resolved.AbsoluteUri.StartsWith(root, StringComparison.Ordinal) ? resolved : null; + } + + internal static bool IsSha256(string? value) => + value is { Length: 64 } && value.All(Uri.IsHexDigit); + + internal static string ComputeSha256(string file) + { + using var stream = File.OpenRead(file); + return Convert.ToHexStringLower(SHA256.HashData(stream)); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeedIndex.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeedIndex.cs new file mode 100644 index 0000000000..74f8a7d04d --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeedIndex.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// +/// The index.json of a web feed, written by unigetui pkg-index next to the .pkg files. +/// A folder feed needs none: its packages are read directly. +/// +internal sealed class PkgFeedIndex +{ + public const int CurrentVersion = 1; + + [JsonPropertyName("version")] + public required int Version { get; init; } + + [JsonPropertyName("packages")] + public required PkgFeedIndexEntry[] Packages { get; init; } +} + +internal sealed class PkgFeedIndexEntry +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("version")] + public required string Version { get; init; } + + // Relative to the index, with forward slashes. + [JsonPropertyName("file")] + public required string File { get; init; } + + [JsonPropertyName("sha256")] + public required string Sha256 { get; init; } + + [JsonPropertyName("size")] + public long Size { get; init; } + + [JsonPropertyName("architectures")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string[]? Architectures { get; init; } + + [JsonPropertyName("icon")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Icon { get; init; } + + // The app the package installs, which also marks it installed when it got there another way. + [JsonPropertyName("app")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PkgFeedIndexApp? App { get; init; } +} + +internal sealed class PkgFeedIndexApp +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } + + public static PkgFeedIndexApp? From(AppBundleInfo? app) => + app is null ? null : new() { Id = app.BundleId, Path = app.Path, Version = app.Version }; + + // Only an absolute .app path is kept: it is read to detect the app, never written to. + public AppBundleInfo? ToInfo() => + !string.IsNullOrWhiteSpace(Id) && Path is { Length: > 1 } path && path.StartsWith('/') + && path.TrimEnd('/').EndsWith(".app", StringComparison.OrdinalIgnoreCase) && !path.Split('/').Contains("..") + ? new AppBundleInfo(Id.Trim(), path.TrimEnd('/'), string.IsNullOrWhiteSpace(Version) ? null : Version.Trim()) + : null; +} + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(PkgFeedIndex))] +internal partial class PkgFeedJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs new file mode 100644 index 0000000000..10da276a80 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs @@ -0,0 +1,191 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using UniGetUI.Core.Data; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Enums; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// +/// Installs a feed package: stages a private copy, checks it is the package the feed announced, then, +/// as root behind the macOS administrator prompt, runs /usr/sbin/installer or copies a .dmg's app. +/// +internal static class PkgInstaller +{ + internal const string InstallerPath = "/usr/sbin/installer"; + private const string OsaScriptPath = "/usr/bin/osascript"; + private const int UserCanceledAppleScriptError = -128; + + /// Runs a process and returns its exit code with its combined output. Replaceable for tests. + internal static Func, CancellationToken, Task<(int ExitCode, string Output)>> RunProcess + { get; set; } = RunProcessAsync; + + /// Whether UniGetUI already runs as root, so no prompt is needed. Replaceable for tests. + internal static Func IsRoot { get; set; } = CoreTools.IsAdministrator; + + public static async Task InstallAsync(PkgFeedItem item, string displayName, + Action log, Action progress, CancellationToken cancellationToken) + { + string stagingDirectory = Path.Combine(Path.GetTempPath(), "UniGetUI-pkg", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stagingDirectory); + try + { + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(stagingDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + // A fixed name keeps the vendor's file name out of the elevated command line. + string staged = Path.Combine(stagingDirectory, item.IsDiskImage ? "package.dmg" : "package.pkg"); + await StageAsync(item, staged, log, progress, cancellationToken); + VerifyHash(item, staged); + + progress(OperationProgress.ForStage(OperationProgressStage.Installing)); + log($"Installing {item.Id} {item.Version}..."); + (int code, string output) result; + if (item.IsDiskImage) + { + using var image = DiskImage.Mount(staged); + string app = image.FindApp(); + VerifyIdentity(item, DiskImage.FromApp(app)); + string destination = AppBundle.OnDisk("/Applications/" + SafeAppName(app)); + log($"Copying {Path.GetFileName(app)} to {destination}..."); + result = await RunAsRootAsync(CopyAppCommand(app, destination), displayName, cancellationToken); + } + else + { + VerifyIdentity(item, PkgMetadata.Read(staged)); + result = await RunAsRootAsync(InstallPkgCommand(staged), displayName, cancellationToken); + } + var (code, output) = result; + foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + log(line); + if (code == 0) + return OperationVeredict.Success; + if (output.Contains($"({UserCanceledAppleScriptError})", StringComparison.Ordinal)) + { + log("The administrator prompt was canceled."); + return OperationVeredict.Canceled; + } + log($"The installer exited with code {code}."); + return OperationVeredict.Failure; + } + finally + { + try { Directory.Delete(stagingDirectory, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + /// Copies a folder-feed package, or downloads a web-feed one, to the given path. + public static async Task StageAsync(PkgFeedItem item, string destination, Action log, + Action progress, CancellationToken cancellationToken) + { + progress(OperationProgress.ForStage(OperationProgressStage.Downloading)); + if (item.Location.IsFile) + { + log($"Copying {item.Location.LocalPath}..."); + await using var source = File.OpenRead(item.Location.LocalPath); + await using var target = File.Create(destination); + await CopyAsync(source, target, source.Length, progress, cancellationToken); + return; + } + + log($"Downloading {item.Location}..."); + using var client = new HttpClient(CoreTools.GenericHttpClientParameters); + client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); + using var response = await client.GetAsync(item.Location, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + await using (var body = await response.Content.ReadAsStreamAsync(cancellationToken)) + await using (var target = File.Create(destination)) + await CopyAsync(body, target, response.Content.Headers.ContentLength ?? item.Size, progress, cancellationToken); + } + + private static async Task CopyAsync(Stream source, Stream target, long total, Action progress, + CancellationToken cancellationToken) + { + byte[] buffer = new byte[1024 * 1024]; + long copied = 0; + int read; + while ((read = await source.ReadAsync(buffer, cancellationToken)) > 0) + { + await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + copied += read; + progress(OperationProgress.FromDownload((ulong)copied, (ulong)Math.Max(total, 0))); + } + } + + /// Refuses a staged file whose hash differs from the one the feed index announces. + internal static void VerifyHash(PkgFeedItem item, string staged) + { + if (item.Sha256 is not { } expected) + return; + using var stream = File.OpenRead(staged); + string actual = Convert.ToHexStringLower(SHA256.HashData(stream)); + if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"The downloaded package's SHA-256 is {actual}, but the feed index announces {expected}."); + } + + /// Refuses a file that is no longer what the feed listed, such as a folder feed's file replaced since. + internal static void VerifyIdentity(PkgFeedItem item, PkgMetadata staged) + { + if (staged.Id != item.Id || staged.Version != item.Version) + throw new InvalidDataException( + $"The package file is {staged.Id} {staged.Version}, but the feed announced {item.Id} {item.Version}. Reload the feed, then try again."); + } + + internal static string SafeAppName(string app) + { + string name = Path.GetFileName(app.TrimEnd('/')); + if (name.StartsWith('.') || !name.EndsWith(".app", StringComparison.OrdinalIgnoreCase) || name.Any(char.IsControl)) + throw new InvalidDataException($"\"{name}\" is not an app name UniGetUI can install."); + return name; + } + + internal static string InstallPkgCommand(string stagedPackage) => + $"{InstallerPath} -pkg {ShellQuote(stagedPackage)} -target / 2>&1"; + + // The app is copied beside its destination first, so a failed copy leaves the installed app untouched. + internal static string CopyAppCommand(string sourceApp, string destinationApp) + { + string temporary = Path.Combine(Path.GetDirectoryName(destinationApp)!, $".unigetui-{Guid.NewGuid():N}.app"); + string source = ShellQuote(sourceApp), destination = ShellQuote(destinationApp), staging = ShellQuote(temporary); + return $"/usr/bin/ditto {source} {staging} && /bin/rm -rf {destination} && /bin/mv {staging} {destination}" + + $" || {{ /bin/rm -rf {staging}; exit 1; }}"; + } + + private static Task<(int, string)> RunAsRootAsync(string command, string displayName, CancellationToken cancellationToken) => + IsRoot() + ? RunProcess("/bin/sh", ["-c", command], cancellationToken) + : RunProcess(OsaScriptPath, ["-e", ElevatedScript(command, displayName)], cancellationToken); + + // osascript's "with administrator privileges" shows the standard macOS password dialog and + // runs the shell command as root. + internal static string ElevatedScript(string command, string displayName) => + $"do shell script \"{AppleScriptEscape(command)}\"" + + $" with prompt \"{AppleScriptEscape(CoreTools.Translate("UniGetUI wants to install {0}.", displayName))}\"" + + " with administrator privileges"; + + internal static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + + internal static string AppleScriptEscape(string value) => + value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + + private static async Task<(int, string)> RunProcessAsync(string fileName, IReadOnlyList arguments, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo(fileName) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Could not start {fileName}."); + var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderr = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + return (process.ExitCode, (await stdout) + (await stderr)); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgMetadata.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgMetadata.cs new file mode 100644 index 0000000000..fdba967493 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgMetadata.cs @@ -0,0 +1,176 @@ +using System.Runtime.InteropServices; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// +/// What UniGetUI needs from a feed package, read from the file itself so that IT never writes +/// detection rules: its receipt identifier (or bundle id), version, title and the app it installs. +/// +internal sealed record PkgMetadata(string Id, string Version, string? Title, IReadOnlyList Architectures, + AppBundleInfo? App = null) +{ + internal const int MaxMetadataBytes = 4 * 1024 * 1024; + + /// Reads a .pkg or a .dmg; with , also saves its app's icon there as a PNG. + public static PkgMetadata Read(string path, string? iconFile = null) + { + if (DiskImage.IsDiskImage(path)) + return DiskImage.ReadMetadata(path, iconFile); + using var stream = File.OpenRead(path); + return Read(stream, iconFile); + } + + public static PkgMetadata Read(Stream stream, string? iconFile = null) + { + var archive = XarArchive.Open(stream); + if (archive.ReadFile("Distribution", MaxMetadataBytes) is { } distribution) + return FromDistribution(XarArchive.ParseXml(distribution).Root!, archive, iconFile); + if (archive.ReadFile("PackageInfo", MaxMetadataBytes) is { } packageInfo) + return FromPackageInfo(XarArchive.ParseXml(packageInfo).Root!, archive, iconFile); + throw new InvalidDataException("The installer package has neither a Distribution nor a PackageInfo file."); + } + + // A product archive lists its components as #file.pkg. The first + // component is the one whose receipt marks the product as installed. + private static PkgMetadata FromDistribution(XElement script, XarArchive archive, string? iconFile) + { + foreach (XElement pkgRef in script.Elements("pkg-ref")) + { + string? id = pkgRef.Attribute("id")?.Value.Trim(); + string file = pkgRef.Value.Trim(); + if (string.IsNullOrEmpty(id) || !file.StartsWith('#')) + continue; + string component = Uri.UnescapeDataString(file[1..]); + XElement? info = archive.ReadFile(component + "/PackageInfo", MaxMetadataBytes) is { } bytes + ? XarArchive.ParseXml(bytes).Root + : null; + string? version = pkgRef.Attribute("version")?.Value.Trim(); + if (string.IsNullOrEmpty(version)) + version = info?.Attribute("version")?.Value.Trim(); + if (string.IsNullOrEmpty(version)) + continue; + + string? hosts = script.Element("options")?.Attribute("hostArchitectures")?.Value; + var (app, appTitle) = info is null ? (null, null) : ReadComponentApp(info, archive, component + "/Payload", iconFile); + string? title = CleanTitle(script.Element("title")?.Value) + ?? appTitle + ?? AppName(script.Elements("pkg-ref").Where(r => r.Attribute("id")?.Value.Trim() == id).Descendants("bundle")); + return new PkgMetadata(id, version, title, SplitArchitectures(hosts), app); + } + throw new InvalidDataException("The installer package's Distribution lists no component with an identifier and version."); + } + + private static PkgMetadata FromPackageInfo(XElement info, XarArchive archive, string? iconFile) + { + string id = info.Attribute("identifier")?.Value.Trim() ?? ""; + string version = info.Attribute("version")?.Value.Trim() ?? ""; + if (id.Length == 0 || version.Length == 0) + throw new InvalidDataException("The installer package's PackageInfo has no identifier or version."); + var (app, title) = ReadComponentApp(info, archive, "Payload", iconFile); + return new PkgMetadata(id, version, title ?? AppName(info.Elements("bundle")), [], app); + } + + /// + /// The app a component installs, where it lands and its name, saving its icon when asked. Only + /// the payload holds the icon, and the identity of an app installed into its own folder, so it + /// is read (in one pass) only for those. + /// + private static (AppBundleInfo? App, string? Title) ReadComponentApp(XElement info, XarArchive archive, + string payloadPath, string? iconFile) + { + string location = (info.Attribute("install-location")?.Value ?? "/").Trim(); + // A component installed into the app's own folder lists only the bundles nested in it (such as + // Sparkle's Updater.app); the app itself is described by the payload's Contents/Info.plist. + bool locationIsApp = location.TrimEnd('/').EndsWith(".app", StringComparison.OrdinalIgnoreCase); + XElement? bundle = locationIsApp ? null : OutermostApps(info.Elements("bundle")) + .FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.Attribute("id")?.Value)); + string? appInPayload = locationIsApp ? "." : bundle?.Attribute("path")?.Value.Trim(); + if (appInPayload is null || JoinPath(location, appInPayload) is not { } path) + return (null, null); + + IReadOnlyDictionary? plist = null; + if (locationIsApp || iconFile is not null) + { + try + { + using var payload = archive.OpenFile(payloadPath); + if (payload is not null) + { + var (plistBytes, icons) = PkgPayload.FindAppResources(payload, appInPayload); + plist = plistBytes is null ? null : AppBundle.ParsePlist(plistBytes); + if (iconFile is not null) + PkgPayload.SaveIcon(plist, icons, iconFile); + } + } + catch (Exception ex) when (ex is IOException or InvalidDataException or System.Xml.XmlException) + { + Logger.Warn($"Could not read the app in the payload {payloadPath}: {ex.Message}"); + } + } + + AppBundleInfo? app = bundle is not null + ? new AppBundleInfo(bundle.Attribute("id")!.Value.Trim(), path, + bundle.Attribute("CFBundleShortVersionString")?.Value is { Length: > 0 } shortVersion + ? shortVersion + : bundle.Attribute("CFBundleVersion")?.Value) + : plist?.GetValueOrDefault("CFBundleIdentifier") is { Length: > 0 } bundleId + ? new AppBundleInfo(bundleId, path, AppBundle.VersionOf(plist)) + : null; + string? title = plist?.GetValueOrDefault("CFBundleDisplayName") is { Length: > 0 } display + ? display + : Path.GetFileNameWithoutExtension(path); + return (app, title); + } + + // The absolute path of a bundle from its component's install location; null if it climbs out. + private static string? JoinPath(string location, string relative) + { + string[] segments = (location + "/" + relative.TrimEnd('/')).Split('/') + .Where(segment => segment.Length > 0 && segment != ".").ToArray(); + return segments.Contains("..") ? null : "/" + string.Join('/', segments); + } + + private static IEnumerable OutermostApps(IEnumerable bundles) => bundles + .Where(b => b.Attribute("path")?.Value.TrimEnd('/').EndsWith(".app", StringComparison.OrdinalIgnoreCase) == true) + .OrderBy(b => b.Attribute("path")!.Value.Trim().TrimStart('.', '/').TrimEnd('/').Count(c => c == '/')); + + // Without a usable title, the outermost application the package installs: Electron apps list + // their nested helpers (GitKraken.app/Contents/Frameworks/… Helper.app) before the app itself. + private static string? AppName(IEnumerable bundles) => OutermostApps(bundles) + .Select(b => Path.GetFileNameWithoutExtension(b.Attribute("path")!.Value.TrimEnd('/'))) + .FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)); + + // A title such as "SU_TITLE" is a key into the package's localized strings, not a name. + private static string? CleanTitle(string? title) + { + title = title?.Trim(); + return string.IsNullOrEmpty(title) || Regex.IsMatch(title, "^[A-Z0-9_]+$") ? null : title; + } + + private static string[] SplitArchitectures(string? hosts) => + hosts?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + + /// + /// How well a package suits this Mac: 2 runs natively, 1 needs Rosetta, 0 cannot run at all. + /// A package that declares no architectures is assumed to run anywhere. + /// + public static int ArchitectureFit(IReadOnlyList architectures, Architecture machine) + { + if (architectures.Count == 0) + return 2; + string native = machine == Architecture.Arm64 ? "arm64" : "x86_64"; + if (architectures.Contains(native, StringComparer.OrdinalIgnoreCase)) + return 2; + return machine == Architecture.Arm64 + && architectures.Contains("x86_64", StringComparer.OrdinalIgnoreCase) ? 1 : 0; + } +} + +// Lets a feed keep what it read from each file across restarts, instead of mounting every disk +// image and reading payloads again at each launch. +[JsonSerializable(typeof(PkgMetadata))] +internal partial class PkgMetadataJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgPayload.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgPayload.cs new file mode 100644 index 0000000000..532226f411 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgPayload.cs @@ -0,0 +1,150 @@ +using System.IO.Compression; +using System.Text; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// +/// Reads a component's Payload, the gzip-compressed cpio (odc) archive of the files it installs, +/// in one forward pass, keeping only the app's Info.plist and the .icns files of its Resources. +/// +internal static class PkgPayload +{ + private const int HeaderSize = 76; + private const int MaxKeptFileBytes = 8 * 1024 * 1024; + private const int MaxNameBytes = 4096; + + /// Saves, as a PNG, the icon the app's Info.plist names, else AppIcon.icns, else any it carries. + public static bool SaveIcon(IReadOnlyDictionary? plist, IReadOnlyDictionary icons, + string destinationPng) + { + string? named = plist is null ? null : AppBundle.IconFileName(plist); + byte[]? icon = (named is not null ? icons.GetValueOrDefault(named) : null) + ?? icons.GetValueOrDefault("AppIcon.icns") + ?? icons.Values.FirstOrDefault(); + if (icon is null) + return false; + string temporary = Path.Combine(Path.GetTempPath(), $"unigetui-icon-{Guid.NewGuid():N}.icns"); + try + { + File.WriteAllBytes(temporary, icon); + return AppBundle.ConvertIcon(temporary, destinationPng); + } + finally + { + File.Delete(temporary); + } + } + + /// + /// The app's Info.plist and its Contents/Resources/*.icns by name ("." when the payload is the app). Stops early once the plist and + /// the icon it names have both been seen; a payload that is not gzip'd odc cpio yields nothing. + /// + internal static (byte[]? InfoPlist, Dictionary Icons) FindAppResources(Stream payload, string appPath) + { + string app = Normalize(appPath); + string root = app.Length == 0 ? "" : app + "/"; + string plistName = root + "Contents/Info.plist"; + string resources = root + "Contents/Resources/"; + byte[]? plist = null; + string? wanted = null; + var icons = new Dictionary(StringComparer.Ordinal); + + Span magic = stackalloc byte[2]; + if (payload.ReadAtLeast(magic, 2, throwOnEndOfStream: false) < 2 || magic[0] != 0x1F || magic[1] != 0x8B) + return (null, icons); + using var cpio = new GZipStream(new PrefixedStream(magic.ToArray(), payload), CompressionMode.Decompress); + byte[] header = new byte[HeaderSize]; + while (true) + { + if (cpio.ReadAtLeast(header, HeaderSize, throwOnEndOfStream: false) < HeaderSize + || Encoding.ASCII.GetString(header, 0, 6) != "070707") + break; + int nameSize = (int)Octal(header, 59, 6); + long fileSize = Octal(header, 65, 11); + if (nameSize is <= 0 or > MaxNameBytes || fileSize < 0) + break; + byte[] nameBytes = new byte[nameSize]; + cpio.ReadExactly(nameBytes); + string name = Normalize(Encoding.UTF8.GetString(nameBytes, 0, nameSize - 1)); + if (name == "TRAILER!!!") + break; + + bool isPlist = name == plistName; + bool isIcon = name.StartsWith(resources, StringComparison.Ordinal) + && name.EndsWith(".icns", StringComparison.OrdinalIgnoreCase) + && !name[resources.Length..].Contains('/'); + if ((isPlist || isIcon) && fileSize <= MaxKeptFileBytes) + { + byte[] data = new byte[fileSize]; + cpio.ReadExactly(data); + if (isPlist) + { + plist = data; + wanted = AppBundle.IconFileName(AppBundle.ParsePlist(plist)); + } + else + icons[name[resources.Length..]] = data; + if (wanted is not null && icons.ContainsKey(wanted)) + break; + } + else + Skip(cpio, fileSize); + } + return (plist, icons); + } + + private static string Normalize(string path) => path.Trim().TrimStart('.').Trim('/'); + + private static long Octal(byte[] header, int offset, int length) + { + long value = 0; + for (int i = offset; i < offset + length; i++) + { + if (header[i] is < (byte)'0' or > (byte)'7') + return -1; + value = value * 8 + (header[i] - '0'); + } + return value; + } + + private static void Skip(Stream stream, long count) + { + byte[] buffer = new byte[81920]; + while (count > 0) + { + int read = stream.Read(buffer, 0, (int)Math.Min(buffer.Length, count)); + if (read == 0) + throw new EndOfStreamException(); + count -= read; + } + } + + // Puts back the bytes read to sniff the format, since the payload stream cannot seek. + private sealed class PrefixedStream(byte[] prefix, Stream inner) : Stream + { + private int _prefixRead; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_prefixRead < prefix.Length) + { + int n = Math.Min(count, prefix.Length - _prefixRead); + Array.Copy(prefix, _prefixRead, buffer, offset, n); + _prefixRead += n; + return n; + } + return inner.Read(buffer, offset, count); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs new file mode 100644 index 0000000000..accc7fe46d --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.ManagerClasses.Classes; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// +/// Reads the receipts that macOS records for every installed .pkg. They are the detection rule: +/// a package is installed when its identifier has a receipt, at the receipt's version. +/// +internal static class PkgReceipts +{ + internal const string PkgUtilPath = "/usr/sbin/pkgutil"; + + /// Runs pkgutil with the given arguments and returns its exit code and output. Replaceable for tests. + internal static Func RunPkgUtil { get; set; } = Run; + + public static IReadOnlyDictionary GetInstalledVersions(IEnumerable ids, IManagerLogger? logger) + { + var (code, output) = RunPkgUtil(logger, ["--pkgs"]); + if (code != 0) + throw new InvalidOperationException($"pkgutil --pkgs failed with exit code {code}."); + var receipts = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToHashSet(StringComparer.Ordinal); + + var versions = new Dictionary(StringComparer.Ordinal); + foreach (string id in ids.Distinct(StringComparer.Ordinal).Where(receipts.Contains)) + { + var (infoCode, info) = RunPkgUtil(logger, ["--pkg-info", id]); + if (infoCode == 0 && ParseVersion(info) is { } version) + versions[id] = version; + } + return versions; + } + + internal static string? ParseVersion(string pkgInfo) => pkgInfo.Split('\n') + .Select(line => line.Trim()) + .Where(line => line.StartsWith("version:", StringComparison.Ordinal)) + .Select(line => line["version:".Length..].Trim()) + .FirstOrDefault(version => version.Length > 0); + + private static (int, string) Run(IManagerLogger? logger, string[] arguments) + { + var startInfo = new ProcessStartInfo(PkgUtilPath) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = new Process { StartInfo = startInfo }; + var task = logger?.CreateNew(LoggableTaskType.ListInstalledPackages, process); + process.Start(); + var stderr = process.StandardError.ReadToEndAsync(); + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + task?.AddToStdOut(output); + task?.AddToStdErr(stderr.Result); + task?.Close(process.ExitCode); + return (process.ExitCode, output); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/XarArchive.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/XarArchive.cs new file mode 100644 index 0000000000..4592b2e6ba --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/XarArchive.cs @@ -0,0 +1,176 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// +/// Reads named files out of a xar archive, the container format of a flat macOS .pkg. Only the +/// table of contents and the small metadata files are read; the payload is never touched. +/// +internal sealed class XarArchive +{ + private const uint Magic = 0x78617221; // "xar!" + private const int MinHeaderSize = 28; + internal const int MaxTocBytes = 16 * 1024 * 1024; + + private readonly record struct Entry(long Offset, long Length, long Size, string Encoding); + + private readonly Stream _stream; + private readonly long _heapStart; + private readonly Dictionary _files = new(StringComparer.Ordinal); + + private XarArchive(Stream stream, long heapStart) + { + _stream = stream; + _heapStart = heapStart; + } + + public IEnumerable FilePaths => _files.Keys; + + public bool Contains(string path) => _files.ContainsKey(path); + + /// Parses the archive header and table of contents. The stream must be seekable. + public static XarArchive Open(Stream stream) + { + Span header = stackalloc byte[MinHeaderSize]; + stream.Seek(0, SeekOrigin.Begin); + if (stream.ReadAtLeast(header, MinHeaderSize, throwOnEndOfStream: false) < MinHeaderSize + || BinaryPrimitives.ReadUInt32BigEndian(header) != Magic) + throw new InvalidDataException("The file is not a flat installer package (xar archive)."); + ushort headerSize = BinaryPrimitives.ReadUInt16BigEndian(header[4..]); + ulong tocCompressed = BinaryPrimitives.ReadUInt64BigEndian(header[8..]); + ulong tocUncompressed = BinaryPrimitives.ReadUInt64BigEndian(header[16..]); + if (headerSize < MinHeaderSize || tocCompressed is 0 or > MaxTocBytes || tocUncompressed > MaxTocBytes) + throw new InvalidDataException("The installer package has an invalid table of contents."); + + stream.Seek(headerSize, SeekOrigin.Begin); + byte[] compressed = new byte[(int)tocCompressed]; + stream.ReadExactly(compressed); + byte[] toc = Inflate(compressed, MaxTocBytes); + + var archive = new XarArchive(stream, headerSize + (long)tocCompressed); + XElement root = ParseXml(toc).Root?.Element("toc") + ?? throw new InvalidDataException("The installer package has no table of contents."); + archive.AddFiles(root, ""); + return archive; + } + + private void AddFiles(XElement parent, string prefix) + { + foreach (XElement file in parent.Elements("file")) + { + string? name = file.Element("name")?.Value; + if (string.IsNullOrEmpty(name) || name.Contains('/') || name is "." or "..") + continue; + string path = prefix + name; + if (file.Element("type")?.Value == "directory") + { + AddFiles(file, path + "/"); + continue; + } + if (file.Element("data") is not { } data) + continue; + if (!long.TryParse(data.Element("offset")?.Value, out long offset) + || !long.TryParse(data.Element("length")?.Value, out long length) + || !long.TryParse(data.Element("size")?.Value, out long size) + || offset < 0 || length < 0 || size < 0) + continue; + string encoding = data.Element("encoding")?.Attribute("style")?.Value ?? "application/octet-stream"; + _files.TryAdd(path, new Entry(offset, length, size, encoding)); + } + } + + /// Returns the decoded contents of a file, or null when the archive has no such file. + public byte[]? ReadFile(string path, int maxBytes) + { + if (!_files.TryGetValue(path, out Entry entry)) + return null; + if (entry.Size > maxBytes || entry.Length > maxBytes) + throw new InvalidDataException($"{path} in the installer package is larger than {maxBytes} bytes."); + + _stream.Seek(_heapStart + entry.Offset, SeekOrigin.Begin); + byte[] raw = new byte[(int)entry.Length]; + _stream.ReadExactly(raw); + return entry.Encoding switch + { + "application/octet-stream" => raw, + "application/x-gzip" => Inflate(raw, maxBytes), + _ => throw new InvalidDataException($"{path} in the installer package uses the unsupported encoding {entry.Encoding}."), + }; + } + + /// + /// Streams a file without loading it, for the payload, which can weigh gigabytes. Null when the + /// archive has no such file or stores it in an encoding this reader does not decode. + /// + public Stream? OpenFile(string path) + { + if (!_files.TryGetValue(path, out Entry entry)) + return null; + Stream raw = new BoundedStream(_stream, _heapStart + entry.Offset, entry.Length); + return entry.Encoding switch + { + "application/octet-stream" => raw, + "application/x-gzip" => new ZLibStream(raw, CompressionMode.Decompress), + _ => null, + }; + } + + private sealed class BoundedStream(Stream inner, long start, long length) : Stream + { + private long _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_position >= length) + return 0; + inner.Seek(start + _position, SeekOrigin.Begin); + int read = inner.Read(buffer, offset, (int)Math.Min(count, length - _position)); + _position += read; + return read; + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + // xar labels zlib streams "application/x-gzip"; the declared size is checked again while + // inflating, because it can lie. + private static byte[] Inflate(byte[] compressed, int maxBytes) + { + using var input = new ZLibStream(new MemoryStream(compressed), CompressionMode.Decompress); + using var output = new MemoryStream(); + byte[] buffer = new byte[81920]; + int read; + while ((read = input.Read(buffer)) > 0) + { + if (output.Length + read > maxBytes) + throw new InvalidDataException($"A file in the installer package inflates beyond {maxBytes} bytes."); + output.Write(buffer, 0, read); + } + return output.ToArray(); + } + + internal static XDocument ParseXml(byte[] bytes) + { + var settings = new XmlReaderSettings + { + // Ignore rather than prohibit: some vendors' Distribution files carry a DOCTYPE. + DtdProcessing = DtdProcessing.Ignore, + XmlResolver = null, + MaxCharactersInDocument = MaxTocBytes, + }; + using var reader = XmlReader.Create(new MemoryStream(bytes), settings); + return XDocument.Load(reader); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs new file mode 100644 index 0000000000..537e31806a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs @@ -0,0 +1,173 @@ +using System.Runtime.InteropServices; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; +using UniGetUI.PackageEngine.PackageClasses; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager; + +/// +/// macOS .pkg and .dmg files published in the feeds that the software catalog declares (pkgFeed). +/// Detection relies on receipts and app bundles, so IT publishes the vendor's file unchanged. +/// +public sealed class MacPkg : PackageManager +{ + public const string ManagerId = "pkg"; + + public MacPkg() + { + Dependencies = []; + + Capabilities = new ManagerCapabilities + { + CanRunAsAdmin = false, + CanUninstall = false, + CanDownloadInstaller = true, + SupportsCustomSources = true, + SupportsEmptySearchQuery = true, + SupportsCustomPackageIcons = true, + SerializesOperations = true, + Sources = new SourceCapabilities + { + KnowsPackageCount = false, + KnowsUpdateDate = false, + }, + SupportsProxy = ProxySupport.No, + SupportsProxyAuth = false, + KnowsPackageReleaseDate = PackageReleaseDateSupport.No, + }; + + Properties = new ManagerProperties + { + Id = ManagerId, + Name = "Pkg", + DisplayName = "Installer packages", + Description = CoreTools.Translate( + "Apps (.pkg and .dmg) that your organization publishes in its software catalog." + ), + IconId = IconType.Package, + ColorIconId = "pkg_color", + ExecutableFriendlyName = "installer", + InstallVerb = "-pkg", + UpdateVerb = "-pkg", + UninstallVerb = "", + KnownSources = [], + DefaultSource = new ManagerSource(this, "Installer packages", new Uri("file:///")), + }; + + SourcesHelper = new MacPkgSourceHelper(this); + DetailsHelper = new MacPkgDetailsHelper(this); + OperationHelper = new MacPkgOperationHelper(this); + } + + public override IReadOnlyList FindCandidateExecutableFiles() => + OperatingSystem.IsMacOS() && File.Exists(PkgInstaller.InstallerPath) ? [PkgInstaller.InstallerPath] : []; + + protected override void _loadManagerExecutableFile(out bool found, out string path, out string callArguments) + { + var candidates = FindCandidateExecutableFiles(); + found = candidates.Count > 0; + path = found ? candidates[0] : ""; + callArguments = ""; + } + + protected override void _loadManagerVersion(out string version) => + version = $"macOS {Environment.OSVersion.Version}"; + + public override void RefreshPackageIndexes() => PkgFeed.ForgetCachedIndexes(); + + // ── Listing ──────────────────────────────────────────────────────────── + + /// The newest package of each identifier in every feed. An unreachable feed is skipped. + internal IReadOnlyList<(IManagerSource Source, PkgFeedItem Item)> ListLatest() + { + var result = new List<(IManagerSource, PkgFeedItem)>(); + foreach (var source in SourcesHelper.GetSources()) + { + try + { + foreach (var item in PkgFeed.Latest(PkgFeed.List(source.Url), CompareVersions, RuntimeInformation.OSArchitecture)) + result.Add((source, item)); + } + catch (Exception ex) + { + Logger.Warn($"Could not list the package feed {source.Name} at {source.Url}: {ex.Message}"); + } + } + return result; + } + + /// The feed package behind a package, at the requested version or else the newest one. + internal PkgFeedItem? FindFeedItem(IPackage package, string? version = null) + { + var source = SourcesHelper.GetSources().FirstOrDefault(s => s.Name == package.Source.Name); + if (source is null) + return null; + var items = PkgFeed.List(source.Url).Where(i => i.Id == package.Id).ToArray(); + if (!string.IsNullOrEmpty(version)) + return items + .Where(i => i.Version == version) + .OrderByDescending(i => PkgMetadata.ArchitectureFit(i.Architectures, RuntimeInformation.OSArchitecture)) + .FirstOrDefault(); + return PkgFeed.Latest(items, CompareVersions, RuntimeInformation.OSArchitecture).FirstOrDefault(); + } + + internal IReadOnlyList ListFeedItems(IPackage package) + { + var source = SourcesHelper.GetSources().FirstOrDefault(s => s.Name == package.Source.Name); + return source is null ? [] : PkgFeed.List(source.Url).Where(i => i.Id == package.Id).ToArray(); + } + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { + query = query.Trim(); + return ListLatest() + .Where(p => query.Length == 0 + || p.Item.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || p.Item.Id.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Select(p => new Package(p.Item.Name, p.Item.Id, p.Item.Version, p.Source, this)) + .ToArray(); + } + + // Only packages that a feed publishes are listed: other receipts (Apple's, or components of + // software installed by other means) are not this manager's to show. + internal IReadOnlyList<(IManagerSource Source, PkgFeedItem Item, string Installed, bool HasUpdate)> ListInstalled() + { + var latest = ListLatest(); + var receipts = PkgReceipts.GetInstalledVersions( + latest.Where(p => !p.Item.IsDiskImage).Select(p => p.Item.Id), TaskLogger); + var seen = new HashSet(StringComparer.Ordinal); + var installed = new List<(IManagerSource, PkgFeedItem, string, bool)>(); + foreach (var (source, item) in latest) + { + if (seen.Contains(item.Id)) + continue; + // The receipt decides first; without one, the app in place under its bundle id does, which + // is how a .dmg, or an app that Jamf or the user installed another way, is recognized. + if (!item.IsDiskImage && receipts.TryGetValue(item.Id, out string? receipt)) + installed.Add((source, item, receipt, CompareVersions(item.Version, receipt) > 0)); + else if (item.App is { } app && AppBundle.FindInstalled(app) is { } bundle) + installed.Add((source, item, bundle.Version ?? "0", + app.Version is { } offered && bundle.Version is { } present && CompareVersions(offered, present) > 0)); + else + continue; + seen.Add(item.Id); + } + return installed; + } + + protected override IReadOnlyList GetInstalledPackages_UnSafe() => + ListInstalled() + .Select(p => new Package(p.Item.Name, p.Item.Id, p.Installed, p.Source, this)) + .ToArray(); + + protected override IReadOnlyList GetAvailableUpdates_UnSafe() => + ListInstalled() + .Where(p => p.HasUpdate) + .Select(p => new Package(p.Item.Name, p.Item.Id, p.Installed, p.Item.Version, p.Source, this)) + .ToArray(); +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs new file mode 100644 index 0000000000..9ea091e809 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager; + +/// +/// unigetui pkg-index <folder>: writes the index.json that serves a folder of .pkg and .dmg files +/// as a web feed, with each package's identifier, version, app and SHA-256 read from the files themselves. +/// +public static class PkgIndexCommand +{ + public const string Command = "pkg-index"; + + public static bool IsCommand(string[] args) => + args.Length > 0 && string.Equals(args[0], Command, StringComparison.OrdinalIgnoreCase); + + public static int Run(string[] args, TextWriter output, TextWriter error) + { + if (args.Length != 1 || args[0] is "-h" or "--help") + { + output.WriteLine($"Usage: unigetui {Command} "); + output.WriteLine("Writes index.json into , listing every .pkg and .dmg in it and its subfolders,"); + output.WriteLine("so the folder can be published on a web server as a package feed."); + return args.Length == 1 ? 0 : 2; + } + + string folder = Path.GetFullPath(args[0]); + if (!Directory.Exists(folder)) + { + error.WriteLine($"The folder {folder} does not exist."); + return 1; + } + + var (index, skipped) = Build(folder); + string destination = Path.Combine(folder, PkgFeed.IndexFileName); + string temporary = destination + ".tmp"; + File.WriteAllText(temporary, JsonSerializer.Serialize(index, PkgFeedJsonContext.Default.PkgFeedIndex) + Environment.NewLine); + File.Move(temporary, destination, overwrite: true); + + foreach (var entry in index.Packages) + output.WriteLine($" {entry.File}: {entry.Name} ({entry.Id} {entry.Version})"); + foreach (string file in skipped) + error.WriteLine($" Skipped {Path.GetRelativePath(folder, file)}: it is not a readable flat package or a disk image with one app."); + output.WriteLine($"Wrote {destination} with {index.Packages.Length} package(s)."); + return skipped.Count == 0 ? 0 : 3; + } + + internal static (PkgFeedIndex Index, IReadOnlyList Skipped) Build(string folder) + { + var entries = new List(); + var skipped = new List(); + foreach (string file in PkgFeed.EnumeratePackages(folder)) + { + if (PkgFeed.TryReadFile(file) is not { } metadata) + { + skipped.Add(file); + continue; + } + string? icon = PublishIcon(file); + entries.Add(new PkgFeedIndexEntry + { + Id = metadata.Id, + Name = metadata.Title ?? Path.GetFileNameWithoutExtension(file), + Version = metadata.Version, + File = RelativeUrlPath(folder, file), + Sha256 = PkgFeed.ComputeSha256(file), + Size = new FileInfo(file).Length, + Architectures = metadata.Architectures.Count > 0 ? [.. metadata.Architectures] : null, + Icon = icon is null ? null : RelativeUrlPath(folder, icon), + App = PkgFeedIndexApp.From(metadata.App), + }); + } + return (new PkgFeedIndex { Version = PkgFeedIndex.CurrentVersion, Packages = [.. entries] }, skipped); + } + + // A web feed serves only what is in the folder, so an icon taken from a disk image is saved + // next to it, where a sibling image would go. + private static string? PublishIcon(string file) + { + string? icon = PkgFeed.FindIcon(file); + if (icon is null || Path.GetDirectoryName(icon) == Path.GetDirectoryName(file)) + return icon; + string sibling = Path.ChangeExtension(file, ".png"); + File.Copy(icon, sibling, overwrite: false); + return sibling; + } + + private static string RelativeUrlPath(string folder, string file) => + Path.GetRelativePath(folder, file).Replace(Path.DirectorySeparatorChar, '/'); +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/UniGetUI.PackageEngine.Managers.MacPkg.csproj b/src/UniGetUI.PackageEngine.Managers.MacPkg/UniGetUI.PackageEngine.Managers.MacPkg.csproj new file mode 100644 index 0000000000..a0903da9f6 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/UniGetUI.PackageEngine.Managers.MacPkg.csproj @@ -0,0 +1,23 @@ + + + $(SharedTargetFrameworks) + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs index dae71a7a53..5c63cfc0d1 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs +++ b/src/UniGetUI.PackageEngine.PackageEngine/PEInterface.cs @@ -19,6 +19,7 @@ using UniGetUI.PackageEngine.Managers.AptManager; using UniGetUI.PackageEngine.Managers.DnfManager; using UniGetUI.PackageEngine.Managers.HomebrewManager; +using UniGetUI.PackageEngine.Managers.MacPkgManager; using UniGetUI.PackageEngine.Managers.PacmanManager; using UniGetUI.PackageEngine.Managers.SnapManager; using UniGetUI.PackageEngine.Managers.FlatpakManager; @@ -52,6 +53,7 @@ public static class PEInterface public static readonly Dnf? Dnf = Create(() => new Dnf()); public static readonly Pacman? Pacman = Create(() => new Pacman()); public static readonly Homebrew? Homebrew = Create(() => new Homebrew()); + public static readonly MacPkg? MacPkg = Create(() => new MacPkg()); public static readonly Snap? Snap = Create(() => new Snap()); public static readonly Flatpak? Flatpak = Create(() => new Flatpak()); #endif @@ -90,6 +92,8 @@ private static IPackageManager[] CreateManagers() candidates.Add(PowerShell); #else candidates.Insert(0, Homebrew); + if (OperatingSystem.IsMacOS()) + candidates.Insert(1, MacPkg); if (OperatingSystem.IsLinux()) { var families = ReadLinuxDistroFamilies(); diff --git a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj index c614e9d7d1..dd7d6bfe4c 100644 --- a/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj +++ b/src/UniGetUI.PackageEngine.PackageEngine/UniGetUI.PackageEngine.PEInterface.csproj @@ -61,6 +61,9 @@ false + + false + false diff --git a/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs b/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs new file mode 100644 index 0000000000..c020f01a58 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs @@ -0,0 +1,938 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; +using UniGetUI.PackageEngine.Managers.MacPkgManager; +using UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class MacPkgTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), nameof(MacPkgTests), Guid.NewGuid().ToString("N")); + + public MacPkgTests() + { + Directory.CreateDirectory(_directory); + PkgFeed.TestIconDirectoryOverride = Path.Combine(_directory, "extracted-icons"); + } + + public void Dispose() + { + PkgFeed.TestIconDirectoryOverride = null; + Directory.Delete(_directory, recursive: true); + } + + // ── A minimal xar writer, so the reader is exercised without macOS tools ── + + private sealed record XarFile(string Path, byte[] Contents, bool Compressed = true, string? Encoding = null); + + private static byte[] Xar(params XarFile[] files) + { + var heap = new MemoryStream(); + var toc = new StringBuilder(""); + int id = 1; + void WriteFiles(IEnumerable group, int depth) + { + foreach (var byName in group.GroupBy(f => f.Path.Split('/')[depth])) + { + var leaf = byName.FirstOrDefault(f => f.Path.Split('/').Length == depth + 1); + toc.Append($"{SecurityElement.Escape(byName.Key)}"); + if (leaf is null) + { + toc.Append("directory"); + WriteFiles(byName, depth + 1); + } + else + { + byte[] data = leaf.Compressed ? Zlib(leaf.Contents) : leaf.Contents; + string encoding = leaf.Encoding ?? (leaf.Compressed ? "application/x-gzip" : "application/octet-stream"); + toc.Append($"file{data.Length}{heap.Length}" + + $"{leaf.Contents.Length}"); + heap.Write(data); + } + toc.Append(""); + } + } + WriteFiles(files, 0); + toc.Append(""); + + byte[] tocBytes = Encoding.UTF8.GetBytes(toc.ToString()); + byte[] compressedToc = Zlib(tocBytes); + byte[] header = new byte[28]; + BinaryPrimitives.WriteUInt32BigEndian(header, 0x78617221); + BinaryPrimitives.WriteUInt16BigEndian(header.AsSpan(4), 28); + BinaryPrimitives.WriteUInt16BigEndian(header.AsSpan(6), 1); + BinaryPrimitives.WriteUInt64BigEndian(header.AsSpan(8), (ulong)compressedToc.Length); + BinaryPrimitives.WriteUInt64BigEndian(header.AsSpan(16), (ulong)tocBytes.Length); + return [.. header, .. compressedToc, .. heap.ToArray()]; + } + + private static byte[] Zlib(byte[] data) + { + var output = new MemoryStream(); + using (var zlib = new ZLibStream(output, CompressionLevel.Fastest, leaveOpen: true)) + zlib.Write(data); + return output.ToArray(); + } + + private static string Distribution(string id, string version, string? title = null, string? hosts = null, + bool versionOnRef = true, string component = "component.pkg") => $""" + + + {(title is null ? "" : $"{title}")} + + + #{component} + + """; + + private static string PackageInfo(string id, string version) => + $""""""; + + private static byte[] ProductArchive(string id, string version, string? title = null, string? hosts = null) => + Xar(new XarFile("Distribution", Encoding.UTF8.GetBytes(Distribution(id, version, title, hosts))), + new XarFile("component.pkg/PackageInfo", Encoding.UTF8.GetBytes(PackageInfo(id, version))), + new XarFile("component.pkg/Payload", new byte[64], Compressed: false)); + + private string WritePackage(string relativePath, byte[] contents) + { + string path = Path.Combine(_directory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, contents); + return path; + } + + // ── Package metadata ─────────────────────────────────────────────────── + + [Fact] + public void AProductArchiveIsIdentifiedByItsFirstComponent() + { + var metadata = PkgMetadata.Read(new MemoryStream(ProductArchive("com.example.app.pkg", "4.2.1", "Example", "x86_64,arm64"))); + Assert.Equal("com.example.app.pkg", metadata.Id); + Assert.Equal("4.2.1", metadata.Version); + Assert.Equal("Example", metadata.Title); + Assert.Equal(["x86_64", "arm64"], metadata.Architectures); + } + + [Theory] + [InlineData(null)] + [InlineData("SU_TITLE")] + [InlineData(" ")] + public void WithoutAUsableTitleTheInstalledAppNamesThePackage(string? title) + { + var metadata = PkgMetadata.Read(new MemoryStream(ProductArchive("com.example.app.pkg", "1.0", title))); + Assert.Equal("Tool", metadata.Title); + } + + [Fact] + public void AComponentVersionMissingFromTheDistributionIsReadFromItsPackageInfo() + { + var archive = Xar( + new XarFile("Distribution", Encoding.UTF8.GetBytes(Distribution("com.example.app.pkg", "9", versionOnRef: false, component: "My%20App.pkg"))), + new XarFile("My App.pkg/PackageInfo", Encoding.UTF8.GetBytes(PackageInfo("com.example.app.pkg", "3.1")))); + Assert.Equal("3.1", PkgMetadata.Read(new MemoryStream(archive)).Version); + } + + [Fact] + public void AComponentPackageIsIdentifiedByItsPackageInfo() + { + var archive = Xar(new XarFile("PackageInfo", Encoding.UTF8.GetBytes(PackageInfo("com.example.tool.pkg", "2.0")), Compressed: false)); + var metadata = PkgMetadata.Read(new MemoryStream(archive)); + Assert.Equal(("com.example.tool.pkg", "2.0", "Tool"), (metadata.Id, metadata.Version, metadata.Title)); + Assert.Empty(metadata.Architectures); + } + + [Fact] + public void TheOutermostAppNamesThePackageEvenWhenItsHelpersAreListedFirst() + { + string info = """ + + + + + + """; + var metadata = PkgMetadata.Read(new MemoryStream(Xar(new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info))))); + Assert.Equal("GitKraken", metadata.Title); + } + + [Fact] + public void FilesThatAreNotInstallerPackagesAreRejected() + { + Assert.Throws(() => PkgMetadata.Read(new MemoryStream(Encoding.UTF8.GetBytes("PK\u0003\u0004 not a pkg at all")))); + Assert.Throws(() => PkgMetadata.Read(new MemoryStream(Xar(new XarFile("Bom", new byte[8]))))); + Assert.Throws(() => PkgMetadata.Read(new MemoryStream(Xar( + new XarFile("Distribution", Encoding.UTF8.GetBytes(Distribution("x", "1")), Encoding: "application/x-bzip2"))))); + } + + [Fact] + public void AMetadataFileThatInflatesBeyondTheLimitIsRejected() + { + byte[] huge = new byte[PkgMetadata.MaxMetadataBytes + 1]; + var archive = Xar(new XarFile("PackageInfo", huge)); + Assert.Throws(() => PkgMetadata.Read(new MemoryStream(archive))); + } + + [Fact] + public void ADistributionWithAnEntityDeclarationDoesNotExpandIt() + { + string distribution = Distribution("com.example.app.pkg", "1.0", "&boom;") + .Replace("]>\n(() => PkgMetadata.Read(new MemoryStream(archive))); + } + + [Theory] + [InlineData(new string[0], Architecture.Arm64, 2)] + [InlineData(new[] { "x86_64", "arm64" }, Architecture.Arm64, 2)] + [InlineData(new[] { "x86_64" }, Architecture.Arm64, 1)] + [InlineData(new[] { "arm64" }, Architecture.X64, 0)] + [InlineData(new[] { "x86_64" }, Architecture.X64, 2)] + public void ArchitectureFitPrefersNativeThenRosetta(string[] architectures, Architecture machine, int fit) => + Assert.Equal(fit, PkgMetadata.ArchitectureFit(architectures, machine)); + + // ── Feeds ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("https://software.example.com/mac/", true)] + [InlineData("http://127.0.0.1:8080/feed", true)] + [InlineData("http://software.example.com/mac/", false)] + [InlineData("ftp://software.example.com/mac/", false)] + [InlineData("relative/folder", false)] + [InlineData("", false)] + public void OnlyHttpsFileAndLoopbackLocationsAreFeeds(string text, bool accepted) => + Assert.Equal(accepted, PkgFeed.TryParseLocation(text, out _)); + + [Fact] + public void AnAbsolutePathIsAFolderFeed() + { + Assert.True(PkgFeed.TryParseLocation(_directory, out Uri location)); + Assert.True(location.IsFile); + Assert.Equal(Path.GetFullPath(_directory), location.LocalPath); + } + + [Theory] + [InlineData("https://h/mac/", "https://h/mac/index.json")] + [InlineData("https://h/mac", "https://h/mac/index.json")] + [InlineData("https://h/mac/catalog.json", "https://h/mac/catalog.json")] + public void TheIndexSitsBesideThePackages(string location, string index) => + Assert.Equal(index, PkgFeed.IndexUrl(new Uri(location)).AbsoluteUri); + + [Theory] + [InlineData("Zoom.pkg", "https://h/mac/Zoom.pkg")] + [InlineData("apps/My App.pkg", "https://h/mac/apps/My%20App.pkg")] + [InlineData("../other/Zoom.pkg", null)] + [InlineData("apps/../../Zoom.pkg", null)] + [InlineData("/Zoom.pkg", null)] + [InlineData("https://evil.example.com/Zoom.pkg", null)] + [InlineData("//evil.example.com/Zoom.pkg", null)] + [InlineData("apps\\Zoom.pkg", null)] + [InlineData("", null)] + public void IndexPathsCannotLeaveTheFeed(string relative, string? resolved) => + Assert.Equal(resolved, PkgFeed.ResolveWithin(new Uri("https://h/mac/index.json"), relative)?.AbsoluteUri); + + [Fact] + public void AnIndexSkipsEntriesWithoutAHashOrWithAnEscapingPath() + { + string hash = new('a', 64); + string json = $$""" + {"version":1,"packages":[ + {"id":"good","name":"Good","version":"1.0","file":"Good.pkg","sha256":"{{hash.ToUpperInvariant()}}","size":10,"icon":"Good.png"}, + {"id":"nohash","name":"No hash","version":"1.0","file":"NoHash.pkg","sha256":"","size":10}, + {"id":"escape","name":"Escape","version":"1.0","file":"../Escape.pkg","sha256":"{{hash}}","size":10}, + {"id":"","name":"No id","version":"1.0","file":"NoId.pkg","sha256":"{{hash}}","size":10} + ]} + """; + var item = Assert.Single(PkgFeed.ParseIndex(Encoding.UTF8.GetBytes(json), new Uri("https://h/mac/index.json"))); + Assert.Equal("good", item.Id); + Assert.Equal(hash, item.Sha256); + Assert.Equal("https://h/mac/Good.pkg", item.Location.AbsoluteUri); + Assert.Equal("https://h/mac/Good.png", item.Icon?.AbsoluteUri); + } + + [Fact] + public void AnIndexOfAnotherVersionIsRejected() => + Assert.Throws(() => PkgFeed.ParseIndex( + Encoding.UTF8.GetBytes("""{"version":2,"packages":[]}"""), new Uri("https://h/index.json"))); + + private static PkgFeedItem Item(string id, string version, params string[] architectures) => + new(id, id, version, new Uri($"https://h/{id}-{version}-{string.Join('-', architectures)}.pkg"), null, 0, architectures, null); + + [Fact] + public void TheNewestRunnableBuildOfEachPackageIsOffered() + { + var items = new[] + { + Item("app", "1.0"), + Item("app", "2.0", "x86_64"), + Item("app", "2.0", "arm64"), + Item("app", "3.0", "x86_64"), + Item("tool", "5.0", "arm64"), + }; + // On an Intel Mac the arm64-only tool cannot run, so it is not offered at all. + var latest = Assert.Single(PkgFeed.Latest(items, (a, b) => new Version(a).CompareTo(new Version(b)), Architecture.X64)); + Assert.Equal(("app", "3.0"), (latest.Id, latest.Version)); + } + + [Fact] + public void ANativeBuildBeatsARosettaBuildOfTheSameVersion() + { + var items = new[] { Item("app", "2.0", "x86_64"), Item("app", "2.0", "arm64"), Item("app", "1.0", "arm64") }; + var best = Assert.Single(PkgFeed.Latest(items, (a, b) => new Version(a).CompareTo(new Version(b)), Architecture.Arm64)); + Assert.Equal(["arm64"], best.Architectures); + Assert.Equal("2.0", best.Version); + } + + [Fact] + public void AFolderFeedListsItsPackagesAndSkipsUnreadableFiles() + { + WritePackage("Example-4.2.pkg", ProductArchive("com.example.app.pkg", "4.2", "Example")); + WritePackage("Example-4.2.png", [0x89, 0x50, 0x4E, 0x47]); + WritePackage("older/Example-4.1.pkg", ProductArchive("com.example.app.pkg", "4.1", "Example")); + WritePackage("broken.pkg", Encoding.UTF8.GetBytes("not a package")); + WritePackage("readme.txt", Encoding.UTF8.GetBytes("hello")); + WritePackage("a/b/c/d/too-deep.pkg", ProductArchive("com.example.deep.pkg", "1.0")); + + var items = PkgFeed.ScanFolder(_directory, CancellationToken.None); + Assert.Equal(["4.1", "4.2"], items.Select(i => i.Version).Order()); + var newest = items.Single(i => i.Version == "4.2"); + Assert.Equal("Example", newest.Name); + Assert.True(newest.Location.IsFile); + Assert.Null(newest.Sha256); + Assert.Equal(Path.Combine(_directory, "Example-4.2.png"), newest.Icon?.LocalPath); + } + + [Fact] + public void AMissingFolderFeedIsReported() => + Assert.Throws(() => PkgFeed.ScanFolder(Path.Combine(_directory, "unmounted"), CancellationToken.None)); + + [Fact] + public void AReplacedPackageIsReadAgain() + { + string path = WritePackage("App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + Assert.Equal("1.0", PkgFeed.TryReadFile(path)?.Version); + File.WriteAllBytes(path, ProductArchive("com.example.app.pkg", "1.10")); + File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddMinutes(1)); + Assert.Equal("1.10", PkgFeed.TryReadFile(path)?.Version); + } + + [Fact] + public void AnUnreachableFolderFeedKeepsItsLastListing() + { + string feed = Path.Combine(_directory, "share"); + WritePackage("share/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + var location = new Uri(feed); + Assert.Single(PkgFeed.List(location)); + Directory.Delete(feed, recursive: true); + Assert.Equal("com.example.app.pkg", Assert.Single(PkgFeed.List(location)).Id); + Assert.Throws(() => PkgFeed.List(new Uri(Path.Combine(_directory, "never-seen")))); + } + + // ── Index generation ─────────────────────────────────────────────────── + + [Fact] + public void TheIndexCommandRecordsEveryPackageWithItsHash() + { + string app = WritePackage("App.pkg", ProductArchive("com.example.app.pkg", "1.0", "App", "arm64")); + WritePackage("App.png", [1, 2, 3]); + WritePackage("tools/Tool 2.pkg", Xar(new XarFile("PackageInfo", Encoding.UTF8.GetBytes(PackageInfo("com.example.tool.pkg", "2.0"))))); + WritePackage("junk.pkg", [1, 2, 3]); + + var output = new StringWriter(); + var error = new StringWriter(); + Assert.Equal(3, PkgIndexCommand.Run([_directory], output, error)); + Assert.Contains("junk.pkg", error.ToString()); + + byte[] json = File.ReadAllBytes(Path.Combine(_directory, "index.json")); + var items = PkgFeed.ParseIndex(json, new Uri("https://h/feed/index.json")); + var appItem = items.Single(i => i.Id == "com.example.app.pkg"); + Assert.Equal(PkgFeed.ComputeSha256(app), appItem.Sha256); + Assert.Equal(["arm64"], appItem.Architectures); + Assert.Equal("https://h/feed/App.png", appItem.Icon?.AbsoluteUri); + var tool = items.Single(i => i.Id == "com.example.tool.pkg"); + Assert.Equal(("Tool", "2.0", "https://h/feed/tools/Tool%202.pkg"), (tool.Name, tool.Version, tool.Location.AbsoluteUri)); + } + + [Fact] + public void TheIndexCommandExplainsItsUsage() + { + var output = new StringWriter(); + Assert.Equal(2, PkgIndexCommand.Run([], output, new StringWriter())); + Assert.Contains("pkg-index ", output.ToString()); + Assert.Equal(1, PkgIndexCommand.Run([Path.Combine(_directory, "missing")], new StringWriter(), new StringWriter())); + Assert.True(PkgIndexCommand.IsCommand(["PKG-INDEX", "x"])); + Assert.False(PkgIndexCommand.IsCommand(["tui"])); + } + + // ── Catalog feeds ────────────────────────────────────────────────────── + + [Fact] + public void FeedsAreReadFromTheCatalogsThatDeclareThem() + { + string json = $$""" + {"version":1,"catalogs":[ + {"id":"a","name":"Mac apps","packages":[],"pkgFeed":"https://software.example.com/mac/"}, + {"id":"b","name":"Developer tools","packages":[]}, + {"id":"c","name":"Local","packages":[],"pkgFeed":"{{_directory.Replace("\\", "\\\\")}}"}, + {"id":"d","name":"mac apps","packages":[],"pkgFeed":"https://other.example.com/"}, + {"id":"e","name":"Insecure","packages":[],"pkgFeed":"http://software.example.com/"} + ]} + """; + var feeds = PkgCatalogFeeds.Parse(Encoding.UTF8.GetBytes(json)); + Assert.Equal(["Mac apps", "Local"], feeds.Select(f => f.Name)); + Assert.Equal("https://software.example.com/mac/", feeds[0].Location.AbsoluteUri); + Assert.True(feeds[1].Location.IsFile); + } + + [Fact] + public void AMissingOrBrokenCatalogDeclaresNoFeeds() + { + Assert.Empty(PkgCatalogFeeds.Load(Path.Combine(_directory, "missing.json"))); + string broken = WritePackage("broken.json", Encoding.UTF8.GetBytes("{ not json")); + Assert.Empty(PkgCatalogFeeds.Load(broken)); + } + + // ── Receipts ─────────────────────────────────────────────────────────── + + [Fact] + public void InstalledVersionsComeFromTheReceiptsOfFeedPackagesOnly() + { + var previous = PkgReceipts.RunPkgUtil; + var calls = new List(); + PkgReceipts.RunPkgUtil = (_, args) => + { + calls.Add(string.Join(' ', args)); + return args switch + { + ["--pkgs"] => (0, "com.apple.pkg.Core\ncom.example.app.pkg\ncom.example.other.pkg\n"), + ["--pkg-info", "com.example.app.pkg"] => (0, "package-id: com.example.app.pkg\nversion: 4.2.1\nvolume: /\nlocation: /\ninstall-time: 1700000000\n"), + _ => (1, ""), + }; + }; + try + { + var versions = PkgReceipts.GetInstalledVersions(["com.example.app.pkg", "com.example.missing.pkg"], null); + Assert.Equal("4.2.1", Assert.Single(versions).Value); + Assert.Equal(["--pkgs", "--pkg-info com.example.app.pkg"], calls); + } + finally { PkgReceipts.RunPkgUtil = previous; } + } + + // ── Installation ─────────────────────────────────────────────────────── + + [Fact] + public void TheElevatedCommandQuotesThePathForAppleScriptAndTheShell() + { + string command = PkgInstaller.InstallPkgCommand("/tmp/a \"b\" 'c'/package.pkg"); + Assert.Equal("/usr/sbin/installer -pkg '/tmp/a \"b\" '\\''c'\\''/package.pkg' -target / 2>&1", command); + string script = PkgInstaller.ElevatedScript(command, "Zoom \"Pro\""); + Assert.StartsWith("do shell script \"/usr/sbin/installer -pkg '/tmp/a \\\"b\\\" '\\\\''c'", script); + Assert.Contains("Zoom \\\"Pro\\\"", script); + Assert.EndsWith("with administrator privileges", script); + } + + [Theory] + [InlineData("/Volumes/x/GitKraken.app", "GitKraken.app")] + [InlineData("/Volumes/x/My 'App'.app/", "My 'App'.app")] + [InlineData("/Volumes/x/.hidden.app", null)] + [InlineData("/Volumes/x/Tool", null)] + [InlineData("/Volumes/x/Bad\nName.app", null)] + public void OnlyAPlainAppNameIsCopiedToApplications(string app, string? name) + { + if (name is null) + Assert.Throws(() => PkgInstaller.SafeAppName(app)); + else + Assert.Equal(name, PkgInstaller.SafeAppName(app)); + } + + [Fact] + public void AStagedPackageMustMatchTheFeedHashAndIdentity() + { + string staged = WritePackage("staged.pkg", ProductArchive("com.example.app.pkg", "1.0")); + string hash = PkgFeed.ComputeSha256(staged); + var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri("https://h/App.pkg"), hash, 0, [], null); + + PkgInstaller.VerifyHash(item, staged); + PkgInstaller.VerifyHash(item with { Sha256 = null }, staged); + Assert.Throws(() => PkgInstaller.VerifyHash(item with { Sha256 = new string('0', 64) }, staged)); + + var metadata = PkgMetadata.Read(staged); + PkgInstaller.VerifyIdentity(item, metadata); + Assert.Throws(() => PkgInstaller.VerifyIdentity(item with { Version = "2.0" }, metadata)); + Assert.Throws(() => PkgInstaller.VerifyIdentity(item with { Id = "com.example.other.pkg" }, metadata)); + } + + [Fact] + public async Task AFolderFeedPackageIsCopiedThenInstalledThroughTheAdministratorPrompt() + { + string source = WritePackage("feed/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, new FileInfo(source).Length, [], null); + var previous = PkgInstaller.RunProcess; + (string File, IReadOnlyList Args)? ran = null; + PkgInstaller.RunProcess = (file, args, _) => + { + ran = (file, args); + return Task.FromResult((0, "installer: The install was successful.")); + }; + try + { + var log = new List(); + var verdict = await PkgInstaller.InstallAsync(item, "App", log.Add, _ => { }, CancellationToken.None); + Assert.Equal(Enums.OperationVeredict.Success, verdict); + Assert.Equal("/usr/bin/osascript", ran?.File); + Assert.Contains("installer: The install was successful.", log); + } + finally { PkgInstaller.RunProcess = previous; } + } + + [Fact] + public async Task CancelingTheAdministratorPromptCancelsTheOperation() + { + string source = WritePackage("feed/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, 0, [], null); + var previous = PkgInstaller.RunProcess; + PkgInstaller.RunProcess = (_, _, _) => Task.FromResult((1, "0:61: execution error: User canceled. (-128)")); + try + { + Assert.Equal(Enums.OperationVeredict.Canceled, + await PkgInstaller.InstallAsync(item, "App", _ => { }, _ => { }, CancellationToken.None)); + } + finally { PkgInstaller.RunProcess = previous; } + } + + // ── The manager ──────────────────────────────────────────────────────── + + [Fact] + public void TheManagerListsFeedPackagesTheirReceiptsAndTheirUpdates() + { + string feed = Path.Combine(_directory, "feed"); + WritePackage("feed/App-1.0.pkg", ProductArchive("com.example.app.pkg", "1.0", "App")); + WritePackage("feed/App-2.0.pkg", ProductArchive("com.example.app.pkg", "2.0", "App")); + WritePackage("feed/Tool.pkg", ProductArchive("com.example.tool.pkg", "3.0", "Tool")); + WritePackage("feed/Current.pkg", ProductArchive("com.example.current.pkg", "5.0", "Current")); + string catalog = WritePackage("SoftwareCatalog.json", Encoding.UTF8.GetBytes($$""" + {"version":1,"catalogs":[{"id":"mac","name":"Mac apps","packages":[],"pkgFeed":"{{feed.Replace("\\", "\\\\")}}"}]} + """)); + + var previousPkgUtil = PkgReceipts.RunPkgUtil; + PkgReceipts.RunPkgUtil = (_, args) => args switch + { + ["--pkgs"] => (0, "com.example.app.pkg\ncom.example.current.pkg\ncom.apple.pkg.Core\n"), + ["--pkg-info", "com.example.app.pkg"] => (0, "version: 1.0\n"), + ["--pkg-info", "com.example.current.pkg"] => (0, "version: 5.0\n"), + _ => (1, ""), + }; + PkgCatalogFeeds.TestCatalogPathOverride = catalog; + try + { + var manager = new MacPkg(); + var source = Assert.Single(manager.SourcesHelper.GetSources()); + Assert.Equal("Mac apps", source.Name); + + var available = manager.ListLatest(); + Assert.Equal(["com.example.app.pkg 2.0", "com.example.current.pkg 5.0", "com.example.tool.pkg 3.0"], + available.Select(p => $"{p.Item.Id} {p.Item.Version}").Order()); + + var installed = manager.ListInstalled(); + Assert.Equal(["com.example.app.pkg 1.0", "com.example.current.pkg 5.0"], + installed.Select(p => $"{p.Item.Id} {p.Installed}").Order()); + Assert.All(installed, p => Assert.Same(source, p.Source)); + + var package = new PackageClasses.Package("App", "com.example.app.pkg", "1.0", source, manager); + Assert.Equal("2.0", manager.FindFeedItem(package)?.Version); + Assert.Equal("1.0", manager.FindFeedItem(package, "1.0")?.Version); + Assert.Null(manager.FindFeedItem(package, "9.9")); + Assert.False(manager.Capabilities.CanUninstall); + + // Without SupportsCustomPackageIcons the base helper never asks the manager for icons. + WritePackage("feed/App-2.0.png", [0x89, 0x50, 0x4E, 0x47]); + Assert.Equal(Path.Combine(feed, "App-2.0.png"), manager.DetailsHelper.GetIcon(package)?.LocalPath); + } + finally + { + PkgReceipts.RunPkgUtil = previousPkgUtil; + PkgCatalogFeeds.TestCatalogPathOverride = null; + } + } + + // ── Apps ─────────────────────────────────────────────────────────────── + + [Fact] + public void APackageRecordsTheOutermostAppAtItsInstallLocation() + { + string info = """ + + + + + """; + var component = PkgMetadata.Read(new MemoryStream(Xar(new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info))))); + Assert.Equal(new AppBundleInfo("com.axosoft.gitkraken", "/Applications/GitKraken.app", "12.6.1"), component.App); + + var product = PkgMetadata.Read(new MemoryStream(ProductArchive("com.example.tool.pkg", "2.0", "Tool"))); + Assert.Equal(new AppBundleInfo("com.example.tool", "/Applications/Tool.app", null), product.App); + } + + [Fact] + public void APackageWhoseAppPathClimbsOutRecordsNoApp() + { + string info = """"""; + Assert.Null(PkgMetadata.Read(new MemoryStream(Xar(new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info))))).App); + } + + private static byte[] FatHeader(params int[] cpuTypes) + { + byte[] header = new byte[8 + 20 * cpuTypes.Length]; + BinaryPrimitives.WriteUInt32BigEndian(header, 0xCAFEBABE); + BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(4), (uint)cpuTypes.Length); + for (int i = 0; i < cpuTypes.Length; i++) + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(8 + i * 20), cpuTypes[i]); + return header; + } + + [Fact] + public void ExecutableArchitecturesComeFromTheMachOHeader() + { + Assert.Equal(["x86_64", "arm64"], AppBundle.ParseArchitectures(FatHeader(0x01000007, 0x0100000C))); + byte[] thin = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(thin, 0xFEEDFACF); + BinaryPrimitives.WriteInt32LittleEndian(thin.AsSpan(4), 0x0100000C); + Assert.Equal(["arm64"], AppBundle.ParseArchitectures(thin)); + Assert.Empty(AppBundle.ParseArchitectures("#!/bin/sh\n"u8)); + Assert.Empty(AppBundle.ParseArchitectures([1, 2])); + } + + [Fact] + public void AnXmlPlistYieldsItsTopLevelStrings() + { + var values = AppBundle.ParseXmlPlist(Encoding.UTF8.GetBytes(""" + + + + CFBundleIdentifiercom.example.app + LSRequiresNativeExecution + CFBundleShortVersionString 3.1 + CFBundleURLTypesCFBundleURLNamenested + + """)); + Assert.Equal(2, values.Count); + Assert.Equal(("com.example.app", "3.1"), (values["CFBundleIdentifier"], values["CFBundleShortVersionString"])); + } + + private string WriteApp(string root, string name, string id, string version, string? executableFrom = null) + { + string app = Path.Combine(root, name); + Directory.CreateDirectory(Path.Combine(app, "Contents", "MacOS")); + Directory.CreateDirectory(Path.Combine(app, "Contents", "Resources")); + File.WriteAllText(Path.Combine(app, "Contents", "Info.plist"), $""" + + + CFBundleIdentifier{id} + CFBundleShortVersionString{version} + CFBundleExecutableMain + CFBundleIconFileAppIcon + + """); + if (executableFrom is not null) + File.Copy(executableFrom, Path.Combine(app, "Contents", "MacOS", "Main")); + return app; + } + + [Fact] + public void AnInstalledAppIsRecognizedByItsBundleIdAtItsPath() + { + string root = Path.Combine(_directory, "disk"); + WriteApp(Path.Combine(root, "Applications"), "GitKraken.app", "com.axosoft.gitkraken", "12.5.0"); + AppBundle.TestRootOverride = root; + try + { + var expected = new AppBundleInfo("com.axosoft.gitkraken", "/Applications/GitKraken.app", "12.6.1"); + Assert.Equal("12.5.0", AppBundle.FindInstalled(expected)?.Version); + Assert.Null(AppBundle.FindInstalled(expected with { BundleId = "com.other.app" })); + Assert.Null(AppBundle.FindInstalled(expected with { Path = "/Applications/Missing.app" })); + } + finally { AppBundle.TestRootOverride = null; } + } + + [Fact] + public void AnIndexKeepsOnlyAbsoluteAppPaths() + { + string hash = new('a', 64); + string Entry(string id, string path) => + $$$"""{"id":"{{{id}}}","name":"{{{id}}}","version":"1","file":"{{{id}}}.dmg","sha256":"{{{hash}}}","size":1,"app":{"id":"com.{{{id}}}","path":"{{{path}}}","version":"1"}}"""; + string json = $$"""{"version":1,"packages":[{{Entry("good", "/Applications/Good.app")}},{{Entry("relative", "Applications/R.app")}},{{Entry("climb", "/Applications/../etc/C.app")}},{{Entry("notapp", "/usr/local/bin/tool")}}]}"""; + var items = PkgFeed.ParseIndex(Encoding.UTF8.GetBytes(json), new Uri("https://h/index.json")); + Assert.Equal(4, items.Count); + Assert.Equal(new AppBundleInfo("com.good", "/Applications/Good.app", "1"), items[0].App); + Assert.All(items.Skip(1), i => Assert.Null(i.App)); + Assert.All(items, i => Assert.True(i.IsDiskImage)); + } + + [Fact] + public void WithoutAReceiptTheAppInPlaceMarksAPackageInstalled() + { + string feed = Path.Combine(_directory, "feed"); + string root = Path.Combine(_directory, "disk"); + string info = """ + + + + """; + WritePackage("feed/GitKraken.pkg", Xar(new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info)))); + WritePackage("feed/Other.pkg", ProductArchive("com.example.other.pkg", "1.0", "Other")); + WriteApp(Path.Combine(root, "Applications"), "GitKraken.app", "com.axosoft.gitkraken", "12.5.0"); + string catalog = WritePackage("SoftwareCatalog.json", Encoding.UTF8.GetBytes($$""" + {"version":1,"catalogs":[{"id":"mac","name":"Mac apps","packages":[],"pkgFeed":"{{feed.Replace("\\", "\\\\")}}"}]} + """)); + + var previousPkgUtil = PkgReceipts.RunPkgUtil; + PkgReceipts.RunPkgUtil = (_, args) => args is ["--pkgs"] ? (0, "com.jamf.appinstallers.GitKraken\n") : (1, ""); + PkgCatalogFeeds.TestCatalogPathOverride = catalog; + AppBundle.TestRootOverride = root; + try + { + var installed = Assert.Single(new MacPkg().ListInstalled()); + Assert.Equal(("com.axosoft.gitkraken.pkg", "12.5.0", true), (installed.Item.Id, installed.Installed, installed.HasUpdate)); + } + finally + { + PkgReceipts.RunPkgUtil = previousPkgUtil; + PkgCatalogFeeds.TestCatalogPathOverride = null; + AppBundle.TestRootOverride = null; + } + } + + // ── Disk images (macOS only: they are mounted with hdiutil) ─────────── + + private string BuildDiskImage(string name, string id, string version) + { + string source = Path.Combine(_directory, "dmg-" + version); + string app = WriteApp(source, name, id, version, "/usr/bin/true"); + File.Copy("/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns", + Path.Combine(app, "Contents", "Resources", "AppIcon.icns")); + WriteApp(source, "Uninstall Tool.app", id + ".uninstaller", version); + File.CreateSymbolicLink(Path.Combine(source, "Applications"), "/Applications"); + string image = Path.Combine(_directory, $"{Path.GetFileNameWithoutExtension(name)}-{version}.dmg"); + Run("/usr/bin/hdiutil", "create", "-quiet", "-fs", "HFS+", "-format", "UDZO", "-srcfolder", source, "-volname", "Test", image); + return image; + } + + [Fact] + public void ADiskImageIsDescribedByTheAppAtItsRoot() + { + if (!OperatingSystem.IsMacOS()) + return; + + string image = BuildDiskImage("Example App.app", "com.example.dmgapp", "3.4.5"); + string icon = Path.Combine(_directory, "icons", "example.png"); + var metadata = PkgMetadata.Read(image, icon); + Assert.Equal(("com.example.dmgapp", "3.4.5", "Example App"), (metadata.Id, metadata.Version, metadata.Title)); + Assert.Equal(new AppBundleInfo("com.example.dmgapp", "/Applications/Example App.app", "3.4.5"), metadata.App); + Assert.Contains("arm64", metadata.Architectures); + Assert.True(new FileInfo(icon).Length > 0); + Assert.DoesNotContain(Directory.GetDirectories(Path.Combine(Path.GetTempPath(), "UniGetUI-dmg")), + d => Directory.EnumerateFileSystemEntries(d).Any()); + } + + [Fact] + public async Task ParallelListingsReadTheSameDiskImage() + { + if (!OperatingSystem.IsMacOS()) + return; + + // A second mount of an attached image fails as busy; the startup listings must not skip it. + string image = BuildDiskImage("Parallel.app", "com.example.parallel", "1.0"); + var reads = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => Task.Run(() => PkgFeed.TryReadFile(image)))); + Assert.All(reads, metadata => Assert.Equal("com.example.parallel", metadata?.Id)); + } + + [Fact] + public async Task ADiskImageAppReplacesTheInstalledOneInApplications() + { + if (!OperatingSystem.IsMacOS()) + return; + + string image = BuildDiskImage("Example App.app", "com.example.dmgapp", "2.0"); + string root = Path.Combine(_directory, "disk"); + string installed = WriteApp(Path.Combine(root, "Applications"), "Example App.app", "com.example.dmgapp", "1.0"); + File.WriteAllText(Path.Combine(installed, "Contents", "Resources", "stale.txt"), "from 1.0"); + var item = new PkgFeedItem("com.example.dmgapp", "Example App", "2.0", new Uri(image), null, 0, [], null, + new AppBundleInfo("com.example.dmgapp", "/Applications/Example App.app", "2.0")); + + var previousIsRoot = PkgInstaller.IsRoot; + PkgInstaller.IsRoot = () => true; + AppBundle.TestRootOverride = root; + try + { + var log = new List(); + var verdict = await PkgInstaller.InstallAsync(item, "Example App", log.Add, _ => { }, CancellationToken.None); + Assert.True(verdict == Enums.OperationVeredict.Success, string.Join('\n', log)); + Assert.Equal("2.0", AppBundle.FindInstalled(item.App!)?.Version); + Assert.False(File.Exists(Path.Combine(installed, "Contents", "Resources", "stale.txt"))); + Assert.Equal(["Example App.app"], Directory.GetFileSystemEntries(Path.Combine(root, "Applications")).Select(Path.GetFileName)); + + Assert.Throws(() => PkgInstaller.VerifyIdentity(item with { Version = "3.0" }, + PkgMetadata.Read(image))); + } + finally + { + PkgInstaller.IsRoot = previousIsRoot; + AppBundle.TestRootOverride = null; + } + } + + // ── Payload icons ────────────────────────────────────────────────────── + + private static byte[] GzipCpio(params (string Name, byte[] Data)[] files) + { + static string O(long value, int width) => Convert.ToString(value, 8).PadLeft(width, '0'); + var cpio = new MemoryStream(); + void Entry(string name, byte[] data, int mode) + { + byte[] nameBytes = Encoding.UTF8.GetBytes(name); + string header = "070707" + O(0, 6) + O(0, 6) + O(mode, 6) + O(0, 6) + O(0, 6) + O(1, 6) + O(0, 6) + + O(0, 11) + O(nameBytes.Length + 1, 6) + O(data.Length, 11); + cpio.Write(Encoding.ASCII.GetBytes(header)); + cpio.Write(nameBytes); + cpio.WriteByte(0); + cpio.Write(data); + } + foreach (var (name, data) in files) + Entry(name, data, Convert.ToInt32("100644", 8)); + Entry("TRAILER!!!", [], 0); + var gzip = new MemoryStream(); + using (var stream = new GZipStream(gzip, CompressionLevel.Fastest, leaveOpen: true)) + stream.Write(cpio.ToArray()); + return gzip.ToArray(); + } + + private static byte[] Plist(string iconFile) => Encoding.UTF8.GetBytes( + $"""CFBundleIconFile{iconFile}"""); + + [Fact] + public void ThePayloadYieldsTheAppPlistAndItsTopLevelIcons() + { + byte[] payload = GzipCpio( + ("./App.app/Contents/MacOS/App", new byte[5000]), + ("./App.app/Contents/Resources/Other.icns", [1]), + ("./App.app/Contents/Resources/Nested/Deep.icns", [2]), + ("./App.app/Contents/Frameworks/Helper.app/Contents/Resources/AppIcon.icns", [3]), + ("./App.app/Contents/Info.plist", Plist("AppIcon")), + ("./App.app/Contents/Resources/AppIcon.icns", [4]), + ("./App.app/Contents/Resources/Late.icns", [5])); + var (plist, icons) = PkgPayload.FindAppResources(new MemoryStream(payload), "./App.app"); + Assert.NotNull(plist); + // Stops once the named icon is found, so Late.icns is never read. + Assert.Equal(["AppIcon.icns", "Other.icns"], icons.Keys.Order()); + Assert.Equal([4], icons["AppIcon.icns"]); + } + + [Fact] + public void APayloadThatIsNotGzipCpioYieldsNothing() + { + var (plist, icons) = PkgPayload.FindAppResources(new MemoryStream("pbzx not gzip"u8.ToArray()), "./App.app"); + Assert.Null(plist); + Assert.Empty(icons); + } + + [Fact] + public void APackageWithoutAnIconIsReadOnce() + { + string info = """"""; + string file = WritePackage("NoIcon.pkg", Xar( + new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info)), + new XarFile("Payload", GzipCpio(("./NoIcon.app/Contents/Info.plist", Plist("AppIcon"))), Compressed: false))); + Assert.NotNull(PkgFeed.TryReadFile(file)); + Assert.Null(PkgFeed.FindIcon(file)); + // The saved reading answers next time, so the payload is not searched again for an icon. + Assert.Empty(Directory.GetFiles(PkgFeed.TestIconDirectoryOverride!, "*.png")); + Assert.Single(Directory.GetFiles(PkgFeed.TestIconDirectoryOverride!, "*.json")); + } + + [Fact] + public void APackageInstalledIntoItsOwnAppFolderIsThatApp() + { + // Devolutions Workspace installs to /Applications/.app and lists only nested bundles. + string info = """ + + + + + """; + byte[] plist = Encoding.UTF8.GetBytes(""" + CFBundleIdentifiernet.devolutions.authenticator + CFBundleShortVersionString2026.3.2 + """); + byte[] package = Xar( + new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info)), + new XarFile("Payload", GzipCpio(("./Contents/Frameworks/Big.bin", new byte[100]), ("./Contents/Info.plist", plist)), Compressed: false)); + + var metadata = PkgMetadata.Read(new MemoryStream(package)); + Assert.Equal("Devolutions Password Manager", metadata.Title); + Assert.Equal(new AppBundleInfo("net.devolutions.authenticator", "/Applications/Devolutions Password Manager.app", "2026.3.2"), + metadata.App); + } + + [Fact] + public void WhatWasReadFromAFileIsKeptOnDiskAcrossRestarts() + { + string file = WritePackage("App.pkg", ProductArchive("com.example.app.pkg", "1.0", "App")); + Assert.Equal("App", PkgFeed.TryReadFile(file)?.Title); + string saved = Assert.Single(Directory.GetFiles(PkgFeed.TestIconDirectoryOverride!, "*.json")); + + // A restart forgets the memory cache; the saved copy then answers without reading the file. + File.WriteAllText(saved, File.ReadAllText(saved).Replace("\"App\"", "\"From disk\"", StringComparison.Ordinal)); + PkgFeed.ForgetReadFiles(); + Assert.Equal("From disk", PkgFeed.TryReadFile(file)?.Title); + + // A replaced file is read again. + File.WriteAllBytes(file, ProductArchive("com.example.app.pkg", "2.0", "App")); + File.SetLastWriteTimeUtc(file, DateTime.UtcNow.AddMinutes(1)); + Assert.Equal(("App", "2.0"), (PkgFeed.TryReadFile(file)?.Title, PkgFeed.TryReadFile(file)?.Version)); + } + + // ── Real packages ────────────────────────────────────────────────────── + + [Fact] + public void PackagesBuiltByApplesToolsAreRead() + { + if (!OperatingSystem.IsMacOS()) + return; + + string root = Path.Combine(_directory, "root", "Applications", "Hello.app", "Contents"); + Directory.CreateDirectory(root); + File.WriteAllText(Path.Combine(root, "Info.plist"), """ + + CFBundleIdentifiercom.example.hello + CFBundleIconFileHello + """); + Directory.CreateDirectory(Path.Combine(root, "Resources")); + File.Copy("/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns", + Path.Combine(root, "Resources", "Hello.icns")); + string component = Path.Combine(_directory, "component.pkg"); + string product = Path.Combine(_directory, "Hello.pkg"); + Run("/usr/bin/pkgbuild", "--root", Path.Combine(_directory, "root"), "--identifier", "com.example.hello.pkg", + "--version", "1.2.0", "--install-location", "/", component); + Run("/usr/bin/productbuild", "--package", component, product); + + Assert.Equal(("com.example.hello.pkg", "1.2.0", "Hello"), + (PkgMetadata.Read(component).Id, PkgMetadata.Read(component).Version, PkgMetadata.Read(component).Title)); + string icon = Path.Combine(_directory, "hello.png"); + var metadata = PkgMetadata.Read(product, icon); + Assert.Equal(("com.example.hello.pkg", "1.2.0", "Hello"), (metadata.Id, metadata.Version, metadata.Title)); + Assert.True(new FileInfo(icon).Length > 0, "The icon was not extracted from the payload."); + } + + private static void Run(string file, params string[] arguments) + { + var startInfo = new ProcessStartInfo(file) { RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (string argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + Assert.True(process.ExitCode == 0, process.StandardError.ReadToEnd()); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs index a958d14e1d..5df24114fa 100644 --- a/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs @@ -33,6 +33,29 @@ public void GetManifestUrlAndNuPkgUrl_UsePackageSourceAndVersion() ); } + [Fact] + public void GetManifestUrl_WithoutAVersionAsksForTheLatestOne() + { + var manager = new PackageManagerBuilder().Build(); + var source = new SourceBuilder() + .WithManager(manager) + .WithUrl("https://www.powershellgallery.com/api/v2") + .Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("Devolutions.PowerShell") + .WithVersion("") + .Build(); + + Assert.Equal( + "https://www.powershellgallery.com/api/v2/Packages()?$filter=Id eq 'Devolutions.PowerShell' and IsLatestVersion", + NuGetManifestLoader.GetManifestUrl(package).ToString() + ); + Assert.EndsWith("Packages(Id='Devolutions.PowerShell',Version='2026.3.2')", + NuGetManifestLoader.GetManifestUrl(package, "2026.3.2").ToString()); + } + [Fact] public void GetCacheKey_MatchesTheVersionedHashForThePackagesOwnVersion() { diff --git a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj index 6c17b44902..2f0de906ae 100644 --- a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj +++ b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj @@ -32,6 +32,7 @@ + diff --git a/src/UniGetUI.Tests/CatalogEditorTests.cs b/src/UniGetUI.Tests/CatalogEditorTests.cs index bc1412e85d..4890cfb60f 100644 --- a/src/UniGetUI.Tests/CatalogEditorTests.cs +++ b/src/UniGetUI.Tests/CatalogEditorTests.cs @@ -718,4 +718,23 @@ public void ACatalogPathThatExistsButCannotBeOpenedIsNotMissing() } public void Dispose() => Directory.Delete(_directory, true); + + [Fact] + public async Task SavingKeepsACatalogsPkgFeed() + { + await SoftwareCatalog.SaveAsync(FilePath, + [ + new() { Id = "mac", Name = "Mac apps", Packages = [], PkgFeed = "https://software.example.com/mac/" }, + new() { Id = "tools", Name = "Tools", Packages = [Entry()] }, + ]); + var vm = new CatalogEditorViewModel(); + Assert.True(await vm.LoadAsync(FilePath)); + vm.SelectedCatalog = vm.Catalogs.Single(c => c.Id == "mac"); + vm.SelectedCatalog.Name = "Mac software"; + Assert.True(await vm.SaveAsync(FilePath)); + + var saved = await SoftwareCatalog.LoadAsync(FilePath); + Assert.Equal(("Mac software", "https://software.example.com/mac/"), (saved[0].Name, saved[0].PkgFeed)); + Assert.Null(saved[1].PkgFeed); + } } diff --git a/src/UniGetUI.Tests/SoftwareCatalogTests.cs b/src/UniGetUI.Tests/SoftwareCatalogTests.cs index 50f9685d74..3ae019ccd4 100644 --- a/src/UniGetUI.Tests/SoftwareCatalogTests.cs +++ b/src/UniGetUI.Tests/SoftwareCatalogTests.cs @@ -766,8 +766,33 @@ public void OnlyACatalogWithPackagesCountsAsUsable() File.WriteAllText(file, """{"version":1,"catalogs":[{"id":"a","name":"A","packages":[{"Id":"jq","Name":"jq","Version":"","Source":"Homebrew","ManagerName":"Homebrew"}]}]}"""); Assert.True(SoftwareCatalog.HasUsableCatalog(file)); + + // A package feed fills its catalog at run time, so an empty package list is still usable. + File.WriteAllText(file, """{"version":1,"catalogs":[{"id":"a","name":"A","packages":[],"pkgFeed":"https://h/mac/"}]}"""); + Assert.True(SoftwareCatalog.HasUsableCatalog(file)); } + [Fact] + public async Task APkgFeedIsKeptOnSaveAndOmittedWhenAbsent() + { + var catalogs = SoftwareCatalog.Parse(DocumentJson( + """{"id":"mac","name":"Mac apps","packages":[],"pkgFeed":"https://software.example.com/mac/"},""" + + CatalogJson(EntryJson))); + Assert.Equal("https://software.example.com/mac/", catalogs[0].PkgFeed); + Assert.Null(catalogs[1].PkgFeed); + + string file = Path.Combine(_testRoot, "saved.json"); + await SoftwareCatalog.SaveAsync(file, catalogs); + string json = File.ReadAllText(file); + Assert.Single(System.Text.RegularExpressions.Regex.Matches(json, "pkgFeed")); + Assert.Equal(catalogs.Select(c => c.PkgFeed), SoftwareCatalog.Parse(json).Select(c => c.PkgFeed)); + } + + [Fact] + public void AnEmptyPkgFeedIsRejected() => + Assert.Throws(() => SoftwareCatalog.Parse(DocumentJson( + """{"id":"mac","name":"Mac apps","packages":[],"pkgFeed":" "}"""))); + [Fact] public void CatalogIsAnIpcNavigationTarget() { diff --git a/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs b/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs index 6f23a1d798..de971b4b0e 100644 --- a/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs +++ b/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs @@ -181,7 +181,8 @@ private void Build() TuiShell.Navigate(TuiPageIds.Logs); }); - if (m.Capabilities.SupportsCustomSources && m.Name != "vcpkg") + // vcpkg has no source commands; .pkg feeds are declared in the software catalog. + if (m.Capabilities.SupportsCustomSources && m.Name != "vcpkg" && m.Id != "pkg") BuildSources(); BuildExtras(); diff --git a/src/UniGetUI.Windows.slnx b/src/UniGetUI.Windows.slnx index a34d7bf009..2b92445283 100644 --- a/src/UniGetUI.Windows.slnx +++ b/src/UniGetUI.Windows.slnx @@ -124,6 +124,10 @@ + + + + From 2f97f5f5fa350ef2852de60f9ef4e6ac3541912b Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Mon, 26 Oct 2026 14:24:21 -0400 Subject: [PATCH 02/16] Fix the review findings on the macOS .pkg and .dmg catalog feeds --- AGENTS.md | 3 +- docs/PKG_FEEDS.md | 18 ++- .../Infrastructure/AvaloniaAutoUpdater.cs | 8 +- .../Infrastructure/MacAdminFileInstaller.cs | 8 +- .../Models/SoftwareCatalog.cs | 5 +- .../SoftwarePages/SoftwareCatalogViewModel.cs | 32 ++-- .../SettingsPages/PackageManagerPage.axaml.cs | 4 +- src/UniGetUI.Core.Tools/Tools.cs | 8 + .../ManagerCapabilities.cs | 3 + .../Internal/NuGetManifestLoader.cs | 26 ++- .../Helpers/MacPkgDetailsHelper.cs | 7 +- .../Helpers/MacPkgOperationHelper.cs | 2 +- .../Helpers/MacPkgSourceHelper.cs | 16 +- .../Internal/AppBundle.cs | 4 +- .../Internal/PkgCatalogFeeds.cs | 17 +- .../Internal/PkgFeed.cs | 80 ++++++++-- .../Internal/PkgInstaller.cs | 33 ++-- .../MacPkg.cs | 17 +- .../PkgIndexCommand.cs | 2 +- .../MacPkgTests.cs | 150 +++++++++++++++++- .../NuGetManifestLoaderTests.cs | 23 +++ src/UniGetUI.Tests/SoftwareCatalogTests.cs | 4 +- src/UniGetUI.Tui/Views/Pages/ManagersPage.cs | 3 +- 23 files changed, 386 insertions(+), 87 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ce4df1aab1..edd8032c16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,8 @@ in the next section. `.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 after the catalog (`PkgCatalogFeeds`). The catalog page + 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`). diff --git a/docs/PKG_FEEDS.md b/docs/PKG_FEEDS.md index 0bedf6eca8..2138609c63 100644 --- a/docs/PKG_FEEDS.md +++ b/docs/PKG_FEEDS.md @@ -1,15 +1,15 @@ # Publishing macOS apps (.pkg and .dmg) -The software catalog can offer apps that no package manager carries, the way Jamf Self Service -does: an administrator publishes the vendor's `.pkg` or `.dmg` unchanged, and users install and -update it from the **Software Catalog** page. +The software catalog can offer apps that no package manager carries: an administrator publishes +the vendor's `.pkg` or `.dmg` 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 Jamf or the user installed another way is recognized. + 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. @@ -36,6 +36,10 @@ Every package in the feed appears in that catalog. `packages` may stay empty; an 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. To list a feed's package by hand in another catalog, use +`"ManagerName": "pkg"` and the feed catalog's `id` as `"Source"`. + A feed can be: | Location | Example | What UniGetUI reads | @@ -76,8 +80,10 @@ to an Intel Mac. - **Install** and **Update** download the file (or copy it from the share) and check its hash and identity. Behind the standard macOS administrator prompt, a `.pkg` then runs through - `installer`, and a `.dmg`'s app is copied to `/Applications`, replacing the previous version. -- Packages cannot be uninstalled from UniGetUI, as with Jamf Self Service. + `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. - When the feed is unreachable (off the VPN, share not mounted), UniGetUI keeps showing the packages it last listed. Installing needs the feed again. 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/SoftwareCatalog.cs b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs index 25639d2083..50b6065e9d 100644 --- a/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs +++ b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs @@ -143,8 +143,9 @@ internal static bool HasUsableCatalog(string filePath) { try { - return File.Exists(filePath) - && Parse(File.ReadAllText(filePath)).Any(c => c.Packages.Length > 0 || !string.IsNullOrWhiteSpace(c.PkgFeed)); + // 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) { diff --git a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs index 6b063be911..1f1a3477fc 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; } @@ -400,7 +409,7 @@ 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 of the catalog's name. + // 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"; /// @@ -423,7 +432,7 @@ internal static async Task ExpandPkgFeedsAsync(CatalogDefin [ .. c.Packages, .. published - .Where(p => string.Equals(p.Source.Name, c.Name.Trim(), StringComparison.OrdinalIgnoreCase) + .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), @@ -443,20 +452,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), }; @@ -650,7 +659,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, }; @@ -836,7 +845,10 @@ 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; diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs index 7ff73bdc6d..ad9af599e8 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/PackageManagerPage.axaml.cs @@ -425,8 +425,8 @@ private void BuildExtraControls(CheckboxCard_Dict disableNotifsCard) { ExtraControls.Children.Clear(); var manager = ViewModel.Manager; - // vcpkg has no source commands; .pkg feeds are declared in the software catalog. - bool managerHasSources = manager.Capabilities.SupportsCustomSources && manager.Name != "vcpkg" && manager.Id != "pkg"; + bool managerHasSources = manager.Capabilities.SupportsCustomSources && manager.Name != "vcpkg" + && manager.Capabilities.Sources.CanBeEdited; if (managerHasSources) { diff --git a/src/UniGetUI.Core.Tools/Tools.cs b/src/UniGetUI.Core.Tools/Tools.cs index befc262808..f705944d4e 100644 --- a/src/UniGetUI.Core.Tools/Tools.cs +++ b/src/UniGetUI.Core.Tools/Tools.cs @@ -2049,6 +2049,14 @@ string argument in new[] } } + /// Quotes a value as one word for a POSIX shell (sh, bash, zsh). + public static string QuoteForPosixShell(string value) => + "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + + /// Escapes a value for use inside a double-quoted AppleScript string. + public static string EscapeAppleScriptString(string value) => + value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + public static string EscapePowerShellSingleQuoted(string value) { StringBuilder builder = new(); diff --git a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs index 0324fe4d9d..d6d40627cd 100644 --- a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs +++ b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs @@ -19,6 +19,9 @@ public struct SourceCapabilities public bool KnowsUpdateDate { get; set; } = false; public bool KnowsPackageCount { get; set; } = false; public bool MustBeInstalledAsAdmin { get; set; } = false; + // False when sources are declared elsewhere (such as .pkg feeds in the software catalog), so + // UniGetUI shows no add or remove controls for them. + public bool CanBeEdited { get; set; } = true; public SourceCapabilities() { } } diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs index 8a188fe6ec..d62f1ada7f 100644 --- a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/Internal/NuGetManifestLoader.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.Tools; @@ -77,11 +78,22 @@ public static Uri GetNuPkgUrl(IPackage package) /// The package for which to obtain the manifest /// The version to fetch; the package's own when omitted /// A string containing the contents of the manifest + // A version's manifest never changes, but "the latest" does when a version is published, so + // the version-less one is kept only for a while. + internal static readonly ConcurrentDictionary LatestManifests = new(); + internal static readonly TimeSpan LatestManifestLifetime = TimeSpan.FromHours(1); + public static string? GetManifestContent(IPackage package, string? version = null) { version ??= package.VersionString; long cacheKey = GetCacheKey(package, version); - if (BaseNuGet.Manifests.TryGetValue(cacheKey, out string? manifest)) + if (version.Length == 0) + { + if (LatestManifests.TryGetValue(cacheKey, out var latest) + && DateTime.UtcNow - latest.FetchedUtc < LatestManifestLifetime) + return latest.Content; + } + else if (BaseNuGet.Manifests.TryGetValue(cacheKey, out string? manifest)) { Logger.Debug( $"Loading cached NuGet manifest for package {package.Id} on manager {package.Manager.Name}" @@ -109,10 +121,10 @@ public static Uri GetNuPkgUrl(IPackage package) new Uri(PackageManifestUrl.ToString().Replace(".0')", "')")) ); using HttpResponseMessage fallbackResponse = client.Send(fallbackRequest); - return CacheManifestContent(cacheKey, package, PackageManifestUrl, fallbackResponse); + return CacheManifestContent(cacheKey, package, PackageManifestUrl, fallbackResponse, latest: false); } - return CacheManifestContent(cacheKey, package, PackageManifestUrl, initialResponse); + return CacheManifestContent(cacheKey, package, PackageManifestUrl, initialResponse, latest: version.Length == 0); } } catch (Exception e) @@ -129,7 +141,8 @@ public static Uri GetNuPkgUrl(IPackage package) long cacheKey, IPackage package, string packageManifestUrl, - HttpResponseMessage response + HttpResponseMessage response, + bool latest ) { if (!response.IsSuccessStatusCode) @@ -141,7 +154,10 @@ HttpResponseMessage response } string packageManifestContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - BaseNuGet.Manifests[cacheKey] = packageManifestContent; + if (latest) + LatestManifests[cacheKey] = (DateTime.UtcNow, packageManifestContent); + else + BaseNuGet.Manifests[cacheKey] = packageManifestContent; return packageManifestContent; } } diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs index 3486cfb319..8507274d43 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgDetailsHelper.cs @@ -2,6 +2,7 @@ using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; namespace UniGetUI.PackageEngine.Managers.MacPkgManager; @@ -31,7 +32,11 @@ protected override void GetDetails_UnSafe(IPackageDetails details) protected override CacheableIcon? GetIcon_UnSafe(IPackage package) { - var icon = _manager.FindFeedItem(package)?.Icon; + var item = _manager.FindFeedItem(package); + var icon = item?.Icon + ?? (item?.Location.IsFile == true && PkgFeed.FindOrExtractIcon(item.Location.LocalPath) is { } extracted + ? new Uri(extracted) + : null); if (icon is null) return null; return icon.IsFile ? new CacheableIcon(icon.LocalPath) : new CacheableIcon(icon, package.VersionString); diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs index ab47d2dff8..011d50d29a 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs @@ -9,7 +9,7 @@ namespace UniGetUI.PackageEngine.Managers.MacPkgManager; /// /// Installs and updates run in-process (). macOS has no uninstaller for -/// .pkg, and neither does Jamf Self Service, so uninstalling is refused. +/// .pkg, so uninstalling is refused. /// internal sealed class MacPkgOperationHelper : BasePkgOperationHelper, IManagedPackageOperationHelper { diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs index a8dd6ba2c0..9a33f97b7b 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs @@ -17,7 +17,7 @@ public MacPkgSourceHelper(MacPkg manager) protected override IReadOnlyList GetSources_UnSafe() => PkgCatalogFeeds.Load() - .Select(feed => (IManagerSource)new ManagerSource(Manager, feed.Name, feed.Location)) + .Select(feed => (IManagerSource)new PkgFeedSource(Manager, feed.Id, feed.Name, feed.Location)) .ToArray(); public override string[] GetAddSourceParameters(IManagerSource source) => @@ -32,3 +32,17 @@ protected override OperationVeredict _getAddSourceOperationVeredict(IManagerSour protected override OperationVeredict _getRemoveSourceOperationVeredict(IManagerSource source, int ReturnCode, string[] Output) => OperationVeredict.Failure; } + +/// +/// A feed's source: named by its catalog's id, so packages keep their source when the catalog is +/// renamed and two catalogs cannot collide, and shown by the catalog's name. +/// +internal sealed class PkgFeedSource : ManagerSource +{ + public PkgFeedSource(IPackageManager manager, string catalogId, string catalogName, Uri location) + : base(manager, catalogId, location) + { + AsString = $"{manager.Properties.Name}: {catalogName}"; + AsString_DisplayName = $"{manager.DisplayName}: {catalogName}"; + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs index 5e55e8faf8..ac0afc1fe8 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs @@ -114,6 +114,8 @@ private static byte[] ConvertBinaryPlist(byte[] binary) foreach (string argument in new[] { "-convert", "xml1", "-o", "-", "-" }) startInfo.ArgumentList.Add(argument); using var process = Process.Start(startInfo) ?? throw new InvalidDataException("Could not start plutil."); + // Drained alongside stdout, so a long error message cannot fill the pipe and stall plutil. + var errors = process.StandardError.ReadToEndAsync(); var writing = Task.Run(() => { process.StandardInput.BaseStream.Write(binary); @@ -124,7 +126,7 @@ private static byte[] ConvertBinaryPlist(byte[] binary) process.WaitForExit(); writing.Wait(); if (process.ExitCode != 0 || output.Length > MaxPlistBytes) - throw new InvalidDataException("plutil could not read a binary property list."); + throw new InvalidDataException($"plutil could not read a binary property list: {errors.Result.Trim()}"); return output.ToArray(); } diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs index c9bf520e50..12756b963c 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs @@ -5,7 +5,8 @@ namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; -internal readonly record struct PkgFeedDeclaration(string Name, Uri Location); +// A feed is identified by its catalog's id, which is unique and survives renames; its name is for display. +internal readonly record struct PkgFeedDeclaration(string Id, string Name, Uri Location); /// /// Reads the feeds that the machine-wide software catalog declares through each catalog's @@ -35,23 +36,24 @@ internal static IReadOnlyList Load(string path) } } - // Catalogs that share a name share a source, so the first one wins. internal static IReadOnlyList Parse(byte[] json) { var document = JsonSerializer.Deserialize(json, PkgCatalogJsonContext.Default.PkgCatalogDocument); var feeds = new List(); foreach (var catalog in document?.Catalogs ?? []) { - if (catalog?.PkgFeed is null || string.IsNullOrWhiteSpace(catalog.Name)) + if (catalog?.PkgFeed is null || string.IsNullOrWhiteSpace(catalog.Id) || string.IsNullOrWhiteSpace(catalog.Name)) continue; if (!PkgFeed.TryParseLocation(catalog.PkgFeed, out Uri location)) { Logger.Warn($"Ignoring the package feed of catalog {catalog.Name}: \"{catalog.PkgFeed}\" is not an https URL or an absolute folder path."); continue; } - string name = catalog.Name.Trim(); - if (!feeds.Any(f => string.Equals(f.Name, name, StringComparison.OrdinalIgnoreCase))) - feeds.Add(new PkgFeedDeclaration(name, location)); + string id = catalog.Id.Trim(); + if (feeds.Any(f => string.Equals(f.Id, id, StringComparison.OrdinalIgnoreCase))) + Logger.Warn($"Ignoring the package feed of a second catalog with the id {id}."); + else + feeds.Add(new PkgFeedDeclaration(id, catalog.Name.Trim(), location)); } return feeds; } @@ -65,6 +67,9 @@ internal sealed class PkgCatalogDocument internal sealed class PkgCatalogDefinition { + [JsonPropertyName("id")] + public string? Id { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs index b97acafa44..3358755c48 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgFeed.cs @@ -68,7 +68,11 @@ public static bool TryParseLocation(string? text, out Uri location) return false; } - public static void ForgetCachedIndexes() => Indexes.Clear(); + public static void ForgetCachedIndexes() + { + Indexes.Clear(); + Folders.Clear(); + } private static readonly ConcurrentDictionary> LastKnown = new(); @@ -80,7 +84,7 @@ public static IReadOnlyList List(Uri location, CancellationToken ca { try { - var items = location.IsFile ? ScanFolder(location.LocalPath, cancellationToken) : ReadIndex(location, cancellationToken); + var items = location.IsFile ? ScanFolderCached(location.LocalPath, cancellationToken) : ReadIndex(location, cancellationToken); LastKnown[location] = items; return items; } @@ -164,19 +168,32 @@ public static IReadOnlyList Latest(IEnumerable items, // ── Folder feeds ─────────────────────────────────────────────────────── + // Icon and details lookups list the feed once per package, so a scan is reused briefly instead of + // walking a network share again for each; a refresh of the manager's indexes forgets it. + private static readonly TimeSpan FolderLifetime = TimeSpan.FromSeconds(30); + private static readonly ConcurrentDictionary Folders = new(StringComparer.Ordinal); + + private static IReadOnlyList ScanFolderCached(string directory, CancellationToken cancellationToken) + { + if (Folders.TryGetValue(directory, out var cached) && DateTime.UtcNow - cached.FetchedUtc < FolderLifetime) + return cached.Items; + var items = ScanFolder(directory, cancellationToken); + Folders[directory] = new CachedIndex(DateTime.UtcNow, items); + return items; + } + + // A file read for the first time mounts a disk image or reads a package's table of contents, so + // several are read at once to keep a cold scan of a large feed within the listing timeout. internal static IReadOnlyList ScanFolder(string directory, CancellationToken cancellationToken) { if (!Directory.Exists(directory)) throw new DirectoryNotFoundException($"The package feed folder {directory} does not exist or is not mounted."); - var items = new List(); - foreach (string file in EnumeratePackages(directory)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (TryReadFile(file) is { } metadata) - items.Add(ToItem(metadata, file, sha256: null)); - } - return items; + string[] files = EnumeratePackages(directory).ToArray(); + var items = new PkgFeedItem?[files.Length]; + Parallel.For(0, files.Length, new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = cancellationToken }, + i => items[i] = TryReadFile(files[i]) is { } metadata ? ToItem(metadata, files[i], sha256: null) : null); + return items.OfType().ToArray(); } internal static IEnumerable EnumeratePackages(string directory) @@ -210,14 +227,15 @@ internal static IEnumerable EnumeratePackages(string directory) if (ParsedFiles.TryGetValue(file, out var cached) && cached.Size == info.Length && cached.WriteTimeUtc == info.LastWriteTimeUtc) return cached.Metadata; - // What was read from a file, its icon included, is kept on disk per file version, so a - // file is read once; one without an icon is not searched again at every launch. + // What was read from a file is kept on disk per file version, so a file is read once. A disk + // image's icon comes with the mount; a package's waits for TryExtractIcon, as it costs a + // pass through the payload. string icon = ExtractedIconPath(info); string saved = Path.ChangeExtension(icon, ".json"); var metadata = TryLoadMetadata(saved); if (metadata is null) { - metadata = PkgMetadata.Read(file, File.Exists(icon) ? null : icon); + metadata = PkgMetadata.Read(file, DiskImage.IsDiskImage(file) && !File.Exists(icon) ? icon : null); TryWriteFile(saved, JsonSerializer.SerializeToUtf8Bytes(metadata, PkgMetadataJsonContext.Default.PkgMetadata)); } ParsedFiles[file] = new ParsedFile(info.Length, info.LastWriteTimeUtc, metadata); @@ -243,7 +261,7 @@ internal static IEnumerable EnumeratePackages(string directory) FindIcon(file) is { } icon ? new Uri(icon) : null, metadata.App); - /// An image next to the package with the same name, else the icon taken from the package's app. + /// An image next to the package with the same name, else the icon already taken from the package's app. internal static string? FindIcon(string packageFile) { string stem = Path.Combine(Path.GetDirectoryName(packageFile)!, Path.GetFileNameWithoutExtension(packageFile)); @@ -253,6 +271,40 @@ internal static IEnumerable EnumeratePackages(string directory) return File.Exists(extracted) ? extracted : null; } + private const string NoIconSuffix = ".none"; + + /// + /// The package's icon, extracted from its app on first request. A file found to have none is + /// remembered, per reader version and file version, so its payload is not searched again. + /// + internal static string? FindOrExtractIcon(string packageFile) + { + if (FindIcon(packageFile) is { } icon) + return icon; + try + { + lock (FileLocks.GetOrAdd(packageFile, _ => new object())) + { + string destination = ExtractedIconPath(new FileInfo(packageFile)); + if (File.Exists(destination)) + return destination; + if (File.Exists(destination + NoIconSuffix)) + return null; + PkgMetadata.Read(packageFile, destination); + if (File.Exists(destination)) + return destination; + TryWriteFile(destination + NoIconSuffix, []); + return null; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException + or System.Xml.XmlException or EndOfStreamException) + { + Logger.Warn($"Could not extract the icon of {packageFile}: {ex.Message}"); + return null; + } + } + /// Where extracted icons are kept. Replaceable so tests stay out of the user's cache. internal static string? TestIconDirectoryOverride { get; set; } diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs index 10da276a80..a9fcf70139 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs @@ -39,6 +39,8 @@ public static async Task InstallAsync(PkgFeedItem item, strin progress(OperationProgress.ForStage(OperationProgressStage.Installing)); log($"Installing {item.Id} {item.Version}..."); + // From here on the operation cannot be canceled: a root installer or copy cut short leaves + // a half-installed app, and the staged file and mounted image must outlive it. (int code, string output) result; if (item.IsDiskImage) { @@ -46,13 +48,15 @@ public static async Task InstallAsync(PkgFeedItem item, strin string app = image.FindApp(); VerifyIdentity(item, DiskImage.FromApp(app)); string destination = AppBundle.OnDisk("/Applications/" + SafeAppName(app)); + cancellationToken.ThrowIfCancellationRequested(); log($"Copying {Path.GetFileName(app)} to {destination}..."); - result = await RunAsRootAsync(CopyAppCommand(app, destination), displayName, cancellationToken); + result = await RunAsRootAsync(CopyAppCommand(app, destination), displayName, CancellationToken.None); } else { VerifyIdentity(item, PkgMetadata.Read(staged)); - result = await RunAsRootAsync(InstallPkgCommand(staged), displayName, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + result = await RunAsRootAsync(InstallPkgCommand(staged), displayName, CancellationToken.None); } var (code, output) = result; foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) @@ -141,15 +145,21 @@ internal static string SafeAppName(string app) } internal static string InstallPkgCommand(string stagedPackage) => - $"{InstallerPath} -pkg {ShellQuote(stagedPackage)} -target / 2>&1"; + $"{InstallerPath} -pkg {CoreTools.QuoteForPosixShell(stagedPackage)} -target / 2>&1"; - // The app is copied beside its destination first, so a failed copy leaves the installed app untouched. + // The app is copied beside its destination and the installed one is moved aside, not deleted, + // until the new one is in place: any failed step leaves the previous version installed. internal static string CopyAppCommand(string sourceApp, string destinationApp) { - string temporary = Path.Combine(Path.GetDirectoryName(destinationApp)!, $".unigetui-{Guid.NewGuid():N}.app"); - string source = ShellQuote(sourceApp), destination = ShellQuote(destinationApp), staging = ShellQuote(temporary); - return $"/usr/bin/ditto {source} {staging} && /bin/rm -rf {destination} && /bin/mv {staging} {destination}" - + $" || {{ /bin/rm -rf {staging}; exit 1; }}"; + string directory = Path.GetDirectoryName(destinationApp)!; + string id = Guid.NewGuid().ToString("N"); + string source = CoreTools.QuoteForPosixShell(sourceApp), destination = CoreTools.QuoteForPosixShell(destinationApp); + string staging = CoreTools.QuoteForPosixShell(Path.Combine(directory, $".unigetui-new-{id}.app")); + string previous = CoreTools.QuoteForPosixShell(Path.Combine(directory, $".unigetui-old-{id}.app")); + return $"/usr/bin/ditto {source} {staging} || {{ /bin/rm -rf {staging}; exit 1; }}; " + + $"if [ -e {destination} ]; then /bin/mv {destination} {previous} || {{ /bin/rm -rf {staging}; exit 1; }}; fi; " + + $"if /bin/mv {staging} {destination}; then /bin/rm -rf {previous}; " + + $"else /bin/rm -rf {staging}; [ -e {previous} ] && /bin/mv {previous} {destination}; exit 1; fi"; } private static Task<(int, string)> RunAsRootAsync(string command, string displayName, CancellationToken cancellationToken) => @@ -160,14 +170,11 @@ internal static string CopyAppCommand(string sourceApp, string destinationApp) // osascript's "with administrator privileges" shows the standard macOS password dialog and // runs the shell command as root. internal static string ElevatedScript(string command, string displayName) => - $"do shell script \"{AppleScriptEscape(command)}\"" - + $" with prompt \"{AppleScriptEscape(CoreTools.Translate("UniGetUI wants to install {0}.", displayName))}\"" + $"do shell script \"{CoreTools.EscapeAppleScriptString(command)}\"" + + $" with prompt \"{CoreTools.EscapeAppleScriptString(CoreTools.Translate("UniGetUI wants to install {0}.", displayName))}\"" + " with administrator privileges"; - internal static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; - internal static string AppleScriptEscape(string value) => - value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); private static async Task<(int, string)> RunProcessAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken) diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs index 537e31806a..ba30c4b1ef 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs @@ -35,6 +35,7 @@ public MacPkg() { KnowsPackageCount = false, KnowsUpdateDate = false, + CanBeEdited = false, }, SupportsProxy = ProxySupport.No, SupportsProxyAuth = false, @@ -138,8 +139,18 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) internal IReadOnlyList<(IManagerSource Source, PkgFeedItem Item, string Installed, bool HasUpdate)> ListInstalled() { var latest = ListLatest(); - var receipts = PkgReceipts.GetInstalledVersions( - latest.Where(p => !p.Item.IsDiskImage).Select(p => p.Item.Id), TaskLogger); + IReadOnlyDictionary receipts; + try + { + receipts = PkgReceipts.GetInstalledVersions( + latest.Where(p => !p.Item.IsDiskImage).Select(p => p.Item.Id), TaskLogger); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or System.ComponentModel.Win32Exception) + { + // Without receipts, packages are still recognized by their apps, as disk images always are. + Logger.Warn($"Could not read the installer package receipts ({ex.Message}); recognizing installed apps by their bundles only."); + receipts = new Dictionary(); + } var seen = new HashSet(StringComparer.Ordinal); var installed = new List<(IManagerSource, PkgFeedItem, string, bool)>(); foreach (var (source, item) in latest) @@ -147,7 +158,7 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) if (seen.Contains(item.Id)) continue; // The receipt decides first; without one, the app in place under its bundle id does, which - // is how a .dmg, or an app that Jamf or the user installed another way, is recognized. + // is how a .dmg, or an app that a management tool or the user installed another way, is recognized. if (!item.IsDiskImage && receipts.TryGetValue(item.Id, out string? receipt)) installed.Add((source, item, receipt, CompareVersions(item.Version, receipt) > 0)); else if (item.App is { } app && AppBundle.FindInstalled(app) is { } bundle) diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs index 9ea091e809..e16d772c68 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/PkgIndexCommand.cs @@ -77,7 +77,7 @@ internal static (PkgFeedIndex Index, IReadOnlyList Skipped) Build(string // next to it, where a sibling image would go. private static string? PublishIcon(string file) { - string? icon = PkgFeed.FindIcon(file); + string? icon = PkgFeed.FindOrExtractIcon(file); if (icon is null || Path.GetDirectoryName(icon) == Path.GetDirectoryName(file)) return icon; string sibling = Path.ChangeExtension(file, ".png"); diff --git a/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs b/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs index c020f01a58..9190a50664 100644 --- a/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs @@ -305,6 +305,19 @@ public void AFolderFeedListsItsPackagesAndSkipsUnreadableFiles() Assert.Equal(Path.Combine(_directory, "Example-4.2.png"), newest.Icon?.LocalPath); } + [Fact] + public void AFolderScanIsReusedUntilTheIndexesAreRefreshed() + { + string feed = Path.Combine(_directory, "reused"); + WritePackage("reused/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + var location = new Uri(feed); + Assert.Single(PkgFeed.List(location)); + WritePackage("reused/Tool.pkg", ProductArchive("com.example.tool.pkg", "1.0")); + Assert.Single(PkgFeed.List(location)); + PkgFeed.ForgetCachedIndexes(); + Assert.Equal(2, PkgFeed.List(location).Count); + } + [Fact] public void AMissingFolderFeedIsReported() => Assert.Throws(() => PkgFeed.ScanFolder(Path.Combine(_directory, "unmounted"), CancellationToken.None)); @@ -377,13 +390,18 @@ public void FeedsAreReadFromTheCatalogsThatDeclareThem() {"id":"a","name":"Mac apps","packages":[],"pkgFeed":"https://software.example.com/mac/"}, {"id":"b","name":"Developer tools","packages":[]}, {"id":"c","name":"Local","packages":[],"pkgFeed":"{{_directory.Replace("\\", "\\\\")}}"}, - {"id":"d","name":"mac apps","packages":[],"pkgFeed":"https://other.example.com/"}, + {"id":"d","name":"Mac apps","packages":[],"pkgFeed":"https://other.example.com/"}, + {"id":"A","name":"Duplicate id","packages":[],"pkgFeed":"https://third.example.com/"}, + {"name":"No id","packages":[],"pkgFeed":"https://fourth.example.com/"}, {"id":"e","name":"Insecure","packages":[],"pkgFeed":"http://software.example.com/"} ]} """; + // Feeds are told apart by catalog id: two catalogs may share a name, not an id. var feeds = PkgCatalogFeeds.Parse(Encoding.UTF8.GetBytes(json)); - Assert.Equal(["Mac apps", "Local"], feeds.Select(f => f.Name)); + Assert.Equal(["a", "c", "d"], feeds.Select(f => f.Id)); + Assert.Equal(["Mac apps", "Local", "Mac apps"], feeds.Select(f => f.Name)); Assert.Equal("https://software.example.com/mac/", feeds[0].Location.AbsoluteUri); + Assert.Equal("https://other.example.com/", feeds[2].Location.AbsoluteUri); Assert.True(feeds[1].Location.IsFile); } @@ -488,6 +506,67 @@ public async Task AFolderFeedPackageIsCopiedThenInstalledThroughTheAdministrator finally { PkgInstaller.RunProcess = previous; } } + [Fact] + public async Task CancelingDuringTheRootInstallWaitsForItToFinish() + { + string source = WritePackage("feed/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, 0, [], null); + using var cancellation = new CancellationTokenSource(); + var previous = PkgInstaller.RunProcess; + bool stagedFileOutlivedTheCancel = false, tokenCouldCancel = true; + PkgInstaller.RunProcess = async (_, args, token) => + { + tokenCouldCancel = token.CanBeCanceled; + cancellation.Cancel(); + await Task.Delay(50, CancellationToken.None); + stagedFileOutlivedTheCancel = Directory.GetFiles(Path.Combine(Path.GetTempPath(), "UniGetUI-pkg"), "package.pkg", + SearchOption.AllDirectories).Length > 0; + return (0, "installer: The install was successful."); + }; + try + { + Assert.Equal(Enums.OperationVeredict.Success, + await PkgInstaller.InstallAsync(item, "App", _ => { }, _ => { }, cancellation.Token)); + Assert.False(tokenCouldCancel); + Assert.True(stagedFileOutlivedTheCancel); + } + finally { PkgInstaller.RunProcess = previous; } + } + + [Fact] + public async Task ACanceledOperationStopsBeforeTheRootInstall() + { + string source = WritePackage("feed/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, 0, [], null); + using var cancellation = new CancellationTokenSource(); + var previous = PkgInstaller.RunProcess; + bool ran = false; + PkgInstaller.RunProcess = (_, _, _) => { ran = true; return Task.FromResult((0, "")); }; + try + { + await Assert.ThrowsAnyAsync(() => PkgInstaller.InstallAsync(item, "App", _ => { }, + progress => { if (progress.Stage == Enums.OperationProgressStage.Installing) cancellation.Cancel(); }, cancellation.Token)); + Assert.False(ran); + } + finally { PkgInstaller.RunProcess = previous; } + } + + [Fact] + public async Task AFailedCopyLeavesTheInstalledAppInPlace() + { + if (OperatingSystem.IsWindows()) + return; + + string applications = Path.Combine(_directory, "Applications"); + string installed = WriteApp(applications, "Example App.app", "com.example.app", "1.0"); + string command = PkgInstaller.CopyAppCommand(Path.Combine(_directory, "missing", "Example App.app"), installed); + var (code, _) = await PkgInstaller.RunProcess("/bin/sh", ["-c", command], CancellationToken.None); + + Assert.NotEqual(0, code); + Assert.Equal("1.0", AppBundle.VersionOf(AppBundle.ReadInfoPlist(installed))); + Assert.Equal(["Example App.app"], Directory.GetFileSystemEntries(applications).Select(Path.GetFileName)); + } + [Fact] public async Task CancelingTheAdministratorPromptCancelsTheOperation() { @@ -530,7 +609,7 @@ public void TheManagerListsFeedPackagesTheirReceiptsAndTheirUpdates() { var manager = new MacPkg(); var source = Assert.Single(manager.SourcesHelper.GetSources()); - Assert.Equal("Mac apps", source.Name); + Assert.Equal(("mac", "Installer packages: Mac apps"), (source.Name, source.AsString_DisplayName)); var available = manager.ListLatest(); Assert.Equal(["com.example.app.pkg 2.0", "com.example.current.pkg 5.0", "com.example.tool.pkg 3.0"], @@ -546,6 +625,7 @@ public void TheManagerListsFeedPackagesTheirReceiptsAndTheirUpdates() Assert.Equal("1.0", manager.FindFeedItem(package, "1.0")?.Version); Assert.Null(manager.FindFeedItem(package, "9.9")); Assert.False(manager.Capabilities.CanUninstall); + Assert.False(manager.Capabilities.Sources.CanBeEdited); // Without SupportsCustomPackageIcons the base helper never asks the manager for icons. WritePackage("feed/App-2.0.png", [0x89, 0x50, 0x4E, 0x47]); @@ -705,6 +785,55 @@ public void WithoutAReceiptTheAppInPlaceMarksAPackageInstalled() } } + [Fact] + public void WhenReceiptsCannotBeReadAppsAreStillRecognized() + { + string feed = Path.Combine(_directory, "feed"); + string root = Path.Combine(_directory, "disk"); + string info = """ + + + + """; + WritePackage("feed/GitKraken.pkg", Xar(new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info)))); + WriteApp(Path.Combine(root, "Applications"), "GitKraken.app", "com.axosoft.gitkraken", "12.6.1"); + string catalog = WritePackage("SoftwareCatalog.json", Encoding.UTF8.GetBytes($$""" + {"version":1,"catalogs":[{"id":"mac","name":"Mac apps","packages":[],"pkgFeed":"{{feed.Replace("\\", "\\\\")}}"}]} + """)); + + var previousPkgUtil = PkgReceipts.RunPkgUtil; + PkgReceipts.RunPkgUtil = (_, _) => (1, ""); + PkgCatalogFeeds.TestCatalogPathOverride = catalog; + AppBundle.TestRootOverride = root; + try + { + var installed = Assert.Single(new MacPkg().ListInstalled()); + Assert.Equal(("com.axosoft.gitkraken.pkg", "12.6.1", false), (installed.Item.Id, installed.Installed, installed.HasUpdate)); + } + finally + { + PkgReceipts.RunPkgUtil = previousPkgUtil; + PkgCatalogFeeds.TestCatalogPathOverride = null; + AppBundle.TestRootOverride = null; + } + } + + [Fact] + public async Task BinaryPlistsAreReadAndCorruptOnesFailCleanly() + { + if (!OperatingSystem.IsMacOS()) + return; + + string plist = Path.Combine(_directory, "Info.plist"); + File.WriteAllText(plist, """CFBundleIdentifiercom.example.binary"""); + Run("/usr/bin/plutil", "-convert", "binary1", plist); + Assert.Equal("com.example.binary", AppBundle.ParsePlist(File.ReadAllBytes(plist))["CFBundleIdentifier"]); + + byte[] corrupt = [.. "bplist00"u8.ToArray(), .. Enumerable.Repeat((byte)0xFF, 200_000)]; + var error = await Record.ExceptionAsync(() => Task.Run(() => AppBundle.ParsePlist(corrupt)).WaitAsync(TimeSpan.FromSeconds(30))); + Assert.IsType(error); + } + // ── Disk images (macOS only: they are mounted with hdiutil) ─────────── private string BuildDiskImage(string name, string id, string version) @@ -846,10 +975,12 @@ public void APackageWithoutAnIconIsReadOnce() new XarFile("PackageInfo", Encoding.UTF8.GetBytes(info)), new XarFile("Payload", GzipCpio(("./NoIcon.app/Contents/Info.plist", Plist("AppIcon"))), Compressed: false))); Assert.NotNull(PkgFeed.TryReadFile(file)); - Assert.Null(PkgFeed.FindIcon(file)); - // The saved reading answers next time, so the payload is not searched again for an icon. - Assert.Empty(Directory.GetFiles(PkgFeed.TestIconDirectoryOverride!, "*.png")); Assert.Single(Directory.GetFiles(PkgFeed.TestIconDirectoryOverride!, "*.json")); + // Listing does not search the payload; the first icon request does, and remembers it found none. + Assert.Null(PkgFeed.FindIcon(file)); + Assert.Null(PkgFeed.FindOrExtractIcon(file)); + Assert.Single(Directory.GetFiles(PkgFeed.TestIconDirectoryOverride!, "*.png.none")); + Assert.Null(PkgFeed.FindOrExtractIcon(file)); } [Fact] @@ -920,10 +1051,15 @@ public void PackagesBuiltByApplesToolsAreRead() Assert.Equal(("com.example.hello.pkg", "1.2.0", "Hello"), (PkgMetadata.Read(component).Id, PkgMetadata.Read(component).Version, PkgMetadata.Read(component).Title)); - string icon = Path.Combine(_directory, "hello.png"); + string icon = Path.Combine(_directory, "read-icon.png"); var metadata = PkgMetadata.Read(product, icon); Assert.Equal(("com.example.hello.pkg", "1.2.0", "Hello"), (metadata.Id, metadata.Version, metadata.Title)); Assert.True(new FileInfo(icon).Length > 0, "The icon was not extracted from the payload."); + + // In a feed, listing leaves the payload alone and the first icon request extracts it. + Assert.NotNull(PkgFeed.TryReadFile(product)); + Assert.Null(PkgFeed.FindIcon(product)); + Assert.True(new FileInfo(PkgFeed.FindOrExtractIcon(product)!).Length > 0); } private static void Run(string file, params string[] arguments) diff --git a/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs index 5df24114fa..fdad364d6c 100644 --- a/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs @@ -56,6 +56,29 @@ public void GetManifestUrl_WithoutAVersionAsksForTheLatestOne() NuGetManifestLoader.GetManifestUrl(package, "2026.3.2").ToString()); } + [Fact] + public void TheLatestManifestIsKeptOnlyForAWhile() + { + var manager = new PackageManagerBuilder().Build(); + // Nothing listens on the discard port, so a fetch fails and only the cache can answer. + var source = new SourceBuilder().WithManager(manager).WithUrl("http://127.0.0.1:9/api/v2").Build(); + var package = new PackageBuilder().WithManager(manager).WithSource(source).WithId("Contoso.Latest").WithVersion("").Build(); + long key = NuGetManifestLoader.GetCacheKey(package); + try + { + NuGetManifestLoader.LatestManifests[key] = (DateTime.UtcNow, "fresh"); + Assert.Equal("fresh", NuGetManifestLoader.GetManifestContent(package)); + + NuGetManifestLoader.LatestManifests[key] = + (DateTime.UtcNow - NuGetManifestLoader.LatestManifestLifetime - TimeSpan.FromMinutes(1), "stale"); + Assert.Null(NuGetManifestLoader.GetManifestContent(package)); + } + finally + { + NuGetManifestLoader.LatestManifests.TryRemove(key, out _); + } + } + [Fact] public void GetCacheKey_MatchesTheVersionedHashForThePackagesOwnVersion() { diff --git a/src/UniGetUI.Tests/SoftwareCatalogTests.cs b/src/UniGetUI.Tests/SoftwareCatalogTests.cs index 3ae019ccd4..6e1f3eb50b 100644 --- a/src/UniGetUI.Tests/SoftwareCatalogTests.cs +++ b/src/UniGetUI.Tests/SoftwareCatalogTests.cs @@ -767,9 +767,9 @@ public void OnlyACatalogWithPackagesCountsAsUsable() File.WriteAllText(file, """{"version":1,"catalogs":[{"id":"a","name":"A","packages":[{"Id":"jq","Name":"jq","Version":"","Source":"Homebrew","ManagerName":"Homebrew"}]}]}"""); Assert.True(SoftwareCatalog.HasUsableCatalog(file)); - // A package feed fills its catalog at run time, so an empty package list is still usable. + // A package feed fills its catalog at run time, but only on macOS, where feeds are read. File.WriteAllText(file, """{"version":1,"catalogs":[{"id":"a","name":"A","packages":[],"pkgFeed":"https://h/mac/"}]}"""); - Assert.True(SoftwareCatalog.HasUsableCatalog(file)); + Assert.Equal(OperatingSystem.IsMacOS(), SoftwareCatalog.HasUsableCatalog(file)); } [Fact] diff --git a/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs b/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs index de971b4b0e..6e0d4170bf 100644 --- a/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs +++ b/src/UniGetUI.Tui/Views/Pages/ManagersPage.cs @@ -181,8 +181,7 @@ private void Build() TuiShell.Navigate(TuiPageIds.Logs); }); - // vcpkg has no source commands; .pkg feeds are declared in the software catalog. - if (m.Capabilities.SupportsCustomSources && m.Name != "vcpkg" && m.Id != "pkg") + if (m.Capabilities.SupportsCustomSources && m.Name != "vcpkg" && m.Capabilities.Sources.CanBeEdited) BuildSources(); BuildExtras(); From 6905b9362ac617f7d2f604922dc114138b2a79a6 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Mon, 26 Oct 2026 14:37:58 -0400 Subject: [PATCH 03/16] Check signatures and fix the second review's findings on catalog feeds --- AGENTS.md | 9 +- docs/PKG_FEEDS.md | 17 ++- .../Models/CatalogPackageIdentitySet.cs | 9 +- .../Models/SoftwareCatalog.cs | 8 +- .../DialogPages/CatalogEditorViewModel.cs | 14 +- .../SoftwarePages/SoftwareCatalogViewModel.cs | 8 +- .../ManagerCapabilities.cs | 3 + .../Chocolatey.cs | 1 + .../Helpers/MacPkgOperationHelper.cs | 3 +- .../Helpers/MacPkgSourceHelper.cs | 14 +- .../Internal/AppBundle.cs | 36 ++--- .../Internal/DiskImage.cs | 28 +--- .../Internal/PkgCatalogFeeds.cs | 7 +- .../Internal/PkgInstaller.cs | 73 +++++++--- .../Internal/PkgReceipts.cs | 26 +--- .../Internal/Tool.cs | 70 +++++++++ .../MacPkg.cs | 6 +- .../MacPkgTests.cs | 136 ++++++++++++++++-- .../CatalogPackageIdentitySetTests.cs | 15 ++ 19 files changed, 356 insertions(+), 127 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/Tool.cs diff --git a/AGENTS.md b/AGENTS.md index edd8032c16..974dc147a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,9 +131,12 @@ in the next section. `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 and the package identity, then `installer -pkg`, or `ditto` of a `.dmg`'s app into - `/Applications`, as root through `osascript ... with administrator privileges`. `ManagerCapabilities.CanUninstall` is false, and the UI refuses - uninstalls for such managers. + 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. ## Version comparison across ecosystems diff --git a/docs/PKG_FEEDS.md b/docs/PKG_FEEDS.md index 2138609c63..b4986401ac 100644 --- a/docs/PKG_FEEDS.md +++ b/docs/PKG_FEEDS.md @@ -37,8 +37,21 @@ there as well is shown alongside the feed's packages. Keep `"packages": []` even 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. To list a feed's package by hand in another catalog, use -`"ManagerName": "pkg"` and the feed catalog's `id` as `"Source"`. +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: 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 50b6065e9d..ff79b6bb7d 100644 --- a/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs +++ b/src/UniGetUI.Avalonia/Models/SoftwareCatalog.cs @@ -30,6 +30,11 @@ public sealed class CatalogDefinition [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 @@ -76,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)); } diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs index 02d868739c..34ddf22467 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs @@ -21,8 +21,9 @@ public partial class CatalogEditorCatalog : ViewModelBase public ObservableCollection Packages { get; } - // Not edited here, but kept so that saving does not drop it. + // Not edited here, but kept so that saving does not drop them. public string? PkgFeed { get; } + public bool? AllowUnsignedPackages { get; } public CatalogEditorCatalog(CatalogDefinition definition) { @@ -30,9 +31,17 @@ public CatalogEditorCatalog(CatalogDefinition definition) _name = definition.Name; Packages = new(definition.Packages); PkgFeed = definition.PkgFeed; + AllowUnsignedPackages = definition.AllowUnsignedPackages; } - public CatalogDefinition ToDefinition() => new() { Id = Id, Name = Name, Packages = Packages.ToArray(), PkgFeed = PkgFeed }; + 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); @@ -370,6 +379,7 @@ 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/SoftwarePages/SoftwareCatalogViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs index 1f1a3477fc..7cdd77ac90 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SoftwarePages/SoftwareCatalogViewModel.cs @@ -422,12 +422,18 @@ internal static async Task ExpandPkgFeedsAsync(CatalogDefin || PEInterface.Managers.FirstOrDefault(m => m.Id == PkgManagerId) is not { } manager || !manager.IsReady()) return catalogs; - var published = await Task.Run(() => manager.FindPackages("")); + // 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, diff --git a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs index d6d40627cd..e1c2de70a9 100644 --- a/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs +++ b/src/UniGetUI.PackageEngine.Enums/ManagerCapabilities.cs @@ -35,6 +35,9 @@ public struct ManagerCapabilities public bool CanRemoveDataOnUninstall = false; // False for managers that install but cannot remove, such as macOS .pkg files. public bool CanUninstall = true; + // False when the installed inventory cannot tell which source a package came from (Chocolatey + // assigns its default source; a macOS .pkg receipt or app records no feed). + public bool InstalledPackagesKeepTheirSource = true; public bool CanDownloadInstaller = false; public bool CanUninstallPreviousVersionsAfterUpdate = false; public bool CanListDependencies = false; diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs index 1697d73b9e..a5fc745451 100644 --- a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs @@ -92,6 +92,7 @@ public Chocolatey() { Capabilities = new ManagerCapabilities { + InstalledPackagesKeepTheirSource = false, CanRunAsAdmin = true, CanDownloadInstaller = true, SupportsReleaseNotes = true, diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs index 011d50d29a..648ec29905 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgOperationHelper.cs @@ -43,7 +43,8 @@ protected override OperationVeredict _getOperationResult( ?? throw new InvalidOperationException( $"The package feed {package.Source.Name} does not publish {package.Id}{(version is null ? "" : " " + version)} for this Mac."); - var verdict = await PkgInstaller.InstallAsync(item, package.Name, log, progress, cancellationToken); + bool allowUnsigned = _manager.FindFeedSource(package)?.AllowUnsignedPackages is true; + var verdict = await PkgInstaller.InstallAsync(item, package.Name, log, progress, cancellationToken, allowUnsigned); if (verdict == OperationVeredict.Success) _manager.InvalidateInstalledPackages(); return verdict; diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs index 9a33f97b7b..1ab68754f9 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Helpers/MacPkgSourceHelper.cs @@ -17,7 +17,7 @@ public MacPkgSourceHelper(MacPkg manager) protected override IReadOnlyList GetSources_UnSafe() => PkgCatalogFeeds.Load() - .Select(feed => (IManagerSource)new PkgFeedSource(Manager, feed.Id, feed.Name, feed.Location)) + .Select(feed => (IManagerSource)new PkgFeedSource(Manager, feed.Id, feed.Location, feed.AllowUnsignedPackages)) .ToArray(); public override string[] GetAddSourceParameters(IManagerSource source) => @@ -34,15 +34,17 @@ protected override OperationVeredict _getRemoveSourceOperationVeredict(IManagerS } /// -/// A feed's source: named by its catalog's id, so packages keep their source when the catalog is -/// renamed and two catalogs cannot collide, and shown by the catalog's name. +/// A feed's source, named by its catalog's id: unique, kept when the catalog is renamed, and what +/// "Installer packages: <id>" (how sources are written in bundles and the CLI) resolves back to. /// internal sealed class PkgFeedSource : ManagerSource { - public PkgFeedSource(IPackageManager manager, string catalogId, string catalogName, Uri location) + public PkgFeedSource(IPackageManager manager, string catalogId, Uri location, bool allowUnsignedPackages) : base(manager, catalogId, location) { - AsString = $"{manager.Properties.Name}: {catalogName}"; - AsString_DisplayName = $"{manager.DisplayName}: {catalogName}"; + AllowUnsignedPackages = allowUnsignedPackages; } + + /// Whether the catalog lets packages that Gatekeeper rejects (unsigned, not notarized) install. + public bool AllowUnsignedPackages { get; } } diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs index ac0afc1fe8..192008123b 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/AppBundle.cs @@ -71,11 +71,11 @@ public static IReadOnlyDictionary ParsePlist(byte[] bytes) => public static bool ConvertIcon(string icns, string destinationPng) { Directory.CreateDirectory(System.IO.Path.GetDirectoryName(destinationPng)!); - var (code, output) = DiskImage.RunTool("/usr/bin/sips", - ["-s", "format", "png", "-Z", "256", icns, "--out", destinationPng], null); - if (code == 0) + var result = Tool.Run("/usr/bin/sips", ["-s", "format", "png", "-Z", "256", icns, "--out", destinationPng], + timeout: TimeSpan.FromMinutes(1)); + if (result.ExitCode == 0) return true; - Logger.Warn($"Could not convert the icon {icns}: {output.Trim()}"); + Logger.Warn($"Could not convert the icon {icns}: {result.Output.Trim()}"); return false; } @@ -104,30 +104,10 @@ private static byte[] ConvertBinaryPlist(byte[] binary) { if (!OperatingSystem.IsMacOS()) throw new InvalidDataException("Binary property lists can only be read on macOS."); - var startInfo = new ProcessStartInfo("/usr/bin/plutil") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - startInfo.RedirectStandardInput = true; - foreach (string argument in new[] { "-convert", "xml1", "-o", "-", "-" }) - startInfo.ArgumentList.Add(argument); - using var process = Process.Start(startInfo) ?? throw new InvalidDataException("Could not start plutil."); - // Drained alongside stdout, so a long error message cannot fill the pipe and stall plutil. - var errors = process.StandardError.ReadToEndAsync(); - var writing = Task.Run(() => - { - process.StandardInput.BaseStream.Write(binary); - process.StandardInput.Close(); - }); - using var output = new MemoryStream(); - process.StandardOutput.BaseStream.CopyTo(output); - process.WaitForExit(); - writing.Wait(); - if (process.ExitCode != 0 || output.Length > MaxPlistBytes) - throw new InvalidDataException($"plutil could not read a binary property list: {errors.Result.Trim()}"); - return output.ToArray(); + var result = Tool.Run("/usr/bin/plutil", ["-convert", "xml1", "-o", "-", "-"], binary, TimeSpan.FromSeconds(30)); + if (result.ExitCode != 0 || result.StandardOutput.Length > MaxPlistBytes) + throw new InvalidDataException($"plutil could not read a binary property list: {result.StandardError.Trim()}"); + return result.StandardOutput; } private const uint FatMagic = 0xCAFEBABE; diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs index b6338f1717..fdd4f125ae 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/DiskImage.cs @@ -115,32 +115,10 @@ private static void TryExtractIcon(string app, string destination) } } - private static (int, string) Run(string[] arguments, string? input) => RunTool(HdiUtilPath, arguments, input); - - internal static (int, string) RunTool(string tool, string[] arguments, string? input) + private static (int, string) Run(string[] arguments, string? input) { - var startInfo = new ProcessStartInfo(tool) - { - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - foreach (string argument in arguments) - startInfo.ArgumentList.Add(argument); - using var process = Process.Start(startInfo) ?? throw new InvalidDataException($"Could not start {tool}."); - if (input is not null) - process.StandardInput.Write(input); - process.StandardInput.Close(); - var stderr = process.StandardError.ReadToEndAsync(); - var stdout = process.StandardOutput.ReadToEndAsync(); - if (!process.WaitForExit(MountTimeout)) - { - process.Kill(entireProcessTree: true); - throw new InvalidDataException($"{Path.GetFileName(tool)} did not finish within {MountTimeout.TotalMinutes} minutes."); - } - return (process.ExitCode, stdout.Result + stderr.Result); + var result = Tool.Run(HdiUtilPath, arguments, input is null ? null : System.Text.Encoding.UTF8.GetBytes(input), MountTimeout); + return (result.ExitCode, result.Output); } private static void TryDeleteDirectory(string path) diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs index 12756b963c..bc1de8ef10 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgCatalogFeeds.cs @@ -6,7 +6,7 @@ namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; // A feed is identified by its catalog's id, which is unique and survives renames; its name is for display. -internal readonly record struct PkgFeedDeclaration(string Id, string Name, Uri Location); +internal readonly record struct PkgFeedDeclaration(string Id, string Name, Uri Location, bool AllowUnsignedPackages = false); /// /// Reads the feeds that the machine-wide software catalog declares through each catalog's @@ -53,7 +53,7 @@ internal static IReadOnlyList Parse(byte[] json) if (feeds.Any(f => string.Equals(f.Id, id, StringComparison.OrdinalIgnoreCase))) Logger.Warn($"Ignoring the package feed of a second catalog with the id {id}."); else - feeds.Add(new PkgFeedDeclaration(id, catalog.Name.Trim(), location)); + feeds.Add(new PkgFeedDeclaration(id, catalog.Name.Trim(), location, catalog.AllowUnsignedPackages is true)); } return feeds; } @@ -75,6 +75,9 @@ internal sealed class PkgCatalogDefinition [JsonPropertyName("pkgFeed")] public string? PkgFeed { get; init; } + + [JsonPropertyName("allowUnsignedPackages")] + public bool? AllowUnsignedPackages { get; init; } } [JsonSerializable(typeof(PkgCatalogDocument))] diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs index a9fcf70139..c9c49faeed 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgInstaller.cs @@ -24,8 +24,11 @@ internal static class PkgInstaller internal static Func IsRoot { get; set; } = CoreTools.IsAdministrator; public static async Task InstallAsync(PkgFeedItem item, string displayName, - Action log, Action progress, CancellationToken cancellationToken) + Action log, Action progress, CancellationToken cancellationToken, + bool allowUnsigned = false) { + if (allowUnsigned && item.Location.IsFile) + RefuseFilesOthersCanWrite(item.Location.LocalPath); string stagingDirectory = Path.Combine(Path.GetTempPath(), "UniGetUI-pkg", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(stagingDirectory); try @@ -47,6 +50,7 @@ public static async Task InstallAsync(PkgFeedItem item, strin using var image = DiskImage.Mount(staged); string app = image.FindApp(); VerifyIdentity(item, DiskImage.FromApp(app)); + await VerifySignatureAsync(app, "execute", allowUnsigned, log, cancellationToken); string destination = AppBundle.OnDisk("/Applications/" + SafeAppName(app)); cancellationToken.ThrowIfCancellationRequested(); log($"Copying {Path.GetFileName(app)} to {destination}..."); @@ -55,6 +59,7 @@ public static async Task InstallAsync(PkgFeedItem item, strin else { VerifyIdentity(item, PkgMetadata.Read(staged)); + await VerifySignatureAsync(staged, "install", allowUnsigned, log, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); result = await RunAsRootAsync(InstallPkgCommand(staged), displayName, CancellationToken.None); } @@ -136,6 +141,41 @@ internal static void VerifyIdentity(PkgFeedItem item, PkgMetadata staged) $"The package file is {staged.Id} {staged.Version}, but the feed announced {item.Id} {item.Version}. Reload the feed, then try again."); } + internal const string SpctlPath = "/usr/sbin/spctl"; + + /// + /// Asks Gatekeeper, as macOS does for downloads, whether the package or app is signed by an identified + /// developer and notarized; only a catalog that allows unsigned packages lets a rejected one through. + /// + internal static async Task VerifySignatureAsync(string path, string type, bool allowUnsigned, Action log, + CancellationToken cancellationToken) + { + var (code, output) = await RunProcess(SpctlPath, ["--assess", "--type", type, "-vv", path], cancellationToken); + string verdict = string.Join("; ", output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Skip(1)); + if (code == 0) + log($"Gatekeeper accepts the package: {verdict}"); + else if (allowUnsigned) + log($"Gatekeeper rejects the package ({verdict}), but its catalog allows unsigned packages."); + else + throw new InvalidDataException($"macOS Gatekeeper rejects this package ({verdict}). Publish a package signed " + + "with a Developer ID and notarized by Apple, or set allowUnsignedPackages on its catalog."); + } + + // Unsigned packages are trusted only for where they come from: a file, or folder, that any user + // on the Mac can write to could hold anyone's package, so it is refused. + internal static void RefuseFilesOthersCanWrite(string file) + { + if (OperatingSystem.IsWindows()) + return; + foreach (string path in new[] { file, Path.GetDirectoryName(file)! }) + { + if (File.GetUnixFileMode(path).HasFlag(UnixFileMode.OtherWrite)) + throw new InvalidDataException($"{path} can be written by any user on this Mac, so its unsigned package " + + "is not installed. Keep a feed that allows unsigned packages in a folder only administrators can change."); + } + } + internal static string SafeAppName(string app) { string name = Path.GetFileName(app.TrimEnd('/')); @@ -147,16 +187,18 @@ internal static string SafeAppName(string app) internal static string InstallPkgCommand(string stagedPackage) => $"{InstallerPath} -pkg {CoreTools.QuoteForPosixShell(stagedPackage)} -target / 2>&1"; - // The app is copied beside its destination and the installed one is moved aside, not deleted, - // until the new one is in place: any failed step leaves the previous version installed. + // The old app is moved aside, not deleted, until the new one is in place; fixed non-.app working + // names let the next update restore an app that a cut-off swap left aside, then clear the rest. internal static string CopyAppCommand(string sourceApp, string destinationApp) { string directory = Path.GetDirectoryName(destinationApp)!; - string id = Guid.NewGuid().ToString("N"); + string name = Path.GetFileNameWithoutExtension(destinationApp); string source = CoreTools.QuoteForPosixShell(sourceApp), destination = CoreTools.QuoteForPosixShell(destinationApp); - string staging = CoreTools.QuoteForPosixShell(Path.Combine(directory, $".unigetui-new-{id}.app")); - string previous = CoreTools.QuoteForPosixShell(Path.Combine(directory, $".unigetui-old-{id}.app")); - return $"/usr/bin/ditto {source} {staging} || {{ /bin/rm -rf {staging}; exit 1; }}; " + string staging = CoreTools.QuoteForPosixShell(Path.Combine(directory, $".{name}.unigetui-new")); + string previous = CoreTools.QuoteForPosixShell(Path.Combine(directory, $".{name}.unigetui-old")); + return $"if [ ! -e {destination} ] && [ -e {previous} ]; then /bin/mv {previous} {destination}; fi; " + + $"/bin/rm -rf {staging} {previous}; " + + $"/usr/bin/ditto {source} {staging} || {{ /bin/rm -rf {staging}; exit 1; }}; " + $"if [ -e {destination} ]; then /bin/mv {destination} {previous} || {{ /bin/rm -rf {staging}; exit 1; }}; fi; " + $"if /bin/mv {staging} {destination}; then /bin/rm -rf {previous}; " + $"else /bin/rm -rf {staging}; [ -e {previous} ] && /bin/mv {previous} {destination}; exit 1; fi"; @@ -179,20 +221,7 @@ internal static string ElevatedScript(string command, string displayName) => private static async Task<(int, string)> RunProcessAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken) { - var startInfo = new ProcessStartInfo(fileName) - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - foreach (string argument in arguments) - startInfo.ArgumentList.Add(argument); - using var process = Process.Start(startInfo) - ?? throw new InvalidOperationException($"Could not start {fileName}."); - var stdout = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stderr = process.StandardError.ReadToEndAsync(cancellationToken); - await process.WaitForExitAsync(cancellationToken); - return (process.ExitCode, (await stdout) + (await stderr)); + var result = await Tool.RunAsync(fileName, arguments, cancellationToken: cancellationToken); + return (result.ExitCode, result.Output); } } diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs index accc7fe46d..392c93e535 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgReceipts.cs @@ -41,24 +41,12 @@ public static IReadOnlyDictionary GetInstalledVersions(IEnumerab private static (int, string) Run(IManagerLogger? logger, string[] arguments) { - var startInfo = new ProcessStartInfo(PkgUtilPath) - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - foreach (string argument in arguments) - startInfo.ArgumentList.Add(argument); - using var process = new Process { StartInfo = startInfo }; - var task = logger?.CreateNew(LoggableTaskType.ListInstalledPackages, process); - process.Start(); - var stderr = process.StandardError.ReadToEndAsync(); - string output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(); - task?.AddToStdOut(output); - task?.AddToStdErr(stderr.Result); - task?.Close(process.ExitCode); - return (process.ExitCode, output); + IProcessTaskLogger? task = null; + var result = Tool.Run(PkgUtilPath, arguments, + started: process => task = logger?.CreateNew(LoggableTaskType.ListInstalledPackages, process)); + task?.AddToStdOut(result.Text); + task?.AddToStdErr(result.StandardError); + task?.Close(result.ExitCode); + return (result.ExitCode, result.Text); } } diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/Tool.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/Tool.cs new file mode 100644 index 0000000000..455009aa66 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/Tool.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Text; + +namespace UniGetUI.PackageEngine.Managers.MacPkgManager.Internal; + +/// What a command-line tool returned: its exit code, raw standard output and error text. +internal sealed record ToolResult(int ExitCode, byte[] StandardOutput, string StandardError) +{ + public string Text => Encoding.UTF8.GetString(StandardOutput); + + /// Standard output followed by standard error, as a person reading a log expects. + public string Output => Text + StandardError; +} + +/// +/// Runs the macOS tools this manager drives (pkgutil, hdiutil, plutil, sips, spctl, installer). +/// Both pipes are drained while the tool runs, so a chatty tool can never stall on a full pipe. +/// +internal static class Tool +{ + public static ToolResult Run(string file, IReadOnlyList arguments, byte[]? input = null, + TimeSpan? timeout = null, Action? started = null) => + RunAsync(file, arguments, input, timeout, started, CancellationToken.None).GetAwaiter().GetResult(); + + public static async Task RunAsync(string file, IReadOnlyList arguments, byte[]? input = null, + TimeSpan? timeout = null, Action? started = null, CancellationToken cancellationToken = default) + { + var startInfo = new ProcessStartInfo(file) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in arguments) + startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) ?? throw new InvalidDataException($"Could not start {file}."); + started?.Invoke(process); + + using var output = new MemoryStream(); + var reading = process.StandardOutput.BaseStream.CopyToAsync(output, CancellationToken.None); + var errors = process.StandardError.ReadToEndAsync(CancellationToken.None); + try + { + if (input is not null) + await process.StandardInput.BaseStream.WriteAsync(input, CancellationToken.None); + process.StandardInput.Close(); + } + catch (IOException) + { + // The tool exited without reading all of its input; its exit code says why. + } + + using var limit = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (timeout is { } duration) + limit.CancelAfter(duration); + try + { + await process.WaitForExitAsync(limit.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + throw new InvalidDataException($"{Path.GetFileName(file)} did not finish within {timeout}."); + } + await Task.WhenAll(reading, errors); + return new ToolResult(process.ExitCode, output.ToArray(), errors.Result); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs b/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs index ba30c4b1ef..e17d26e391 100644 --- a/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs +++ b/src/UniGetUI.PackageEngine.Managers.MacPkg/MacPkg.cs @@ -26,6 +26,7 @@ public MacPkg() { CanRunAsAdmin = false, CanUninstall = false, + InstalledPackagesKeepTheirSource = false, CanDownloadInstaller = true, SupportsCustomSources = true, SupportsEmptySearchQuery = true, @@ -103,9 +104,12 @@ protected override void _loadManagerVersion(out string version) => } /// The feed package behind a package, at the requested version or else the newest one. + internal PkgFeedSource? FindFeedSource(IPackage package) => + SourcesHelper.GetSources().OfType().FirstOrDefault(s => s.Name == package.Source.Name); + internal PkgFeedItem? FindFeedItem(IPackage package, string? version = null) { - var source = SourcesHelper.GetSources().FirstOrDefault(s => s.Name == package.Source.Name); + var source = FindFeedSource(package); if (source is null) return null; var items = PkgFeed.List(source.Url).Where(i => i.Id == package.Id).ToArray(); diff --git a/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs b/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs index 9190a50664..8fde5cbaad 100644 --- a/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/MacPkgTests.cs @@ -340,6 +340,8 @@ public void AnUnreachableFolderFeedKeepsItsLastListing() var location = new Uri(feed); Assert.Single(PkgFeed.List(location)); Directory.Delete(feed, recursive: true); + // Forget the brief scan cache, so the answer comes from the fallback for an unreachable feed. + PkgFeed.ForgetCachedIndexes(); Assert.Equal("com.example.app.pkg", Assert.Single(PkgFeed.List(location)).Id); Assert.Throws(() => PkgFeed.List(new Uri(Path.Combine(_directory, "never-seen")))); } @@ -513,14 +515,20 @@ public async Task CancelingDuringTheRootInstallWaitsForItToFinish() var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, 0, [], null); using var cancellation = new CancellationTokenSource(); var previous = PkgInstaller.RunProcess; + var previousIsRoot = PkgInstaller.IsRoot; + string? staged = null; bool stagedFileOutlivedTheCancel = false, tokenCouldCancel = true; - PkgInstaller.RunProcess = async (_, args, token) => + PkgInstaller.IsRoot = () => true; + PkgInstaller.RunProcess = async (file, args, token) => { + if (file != "/bin/sh") + return (0, "accepted"); + // The root step names this install's own staged file; it must survive a cancel while it runs. + staged = System.Text.RegularExpressions.Regex.Match(args[1], "-pkg '([^']+)'").Groups[1].Value; tokenCouldCancel = token.CanBeCanceled; cancellation.Cancel(); await Task.Delay(50, CancellationToken.None); - stagedFileOutlivedTheCancel = Directory.GetFiles(Path.Combine(Path.GetTempPath(), "UniGetUI-pkg"), "package.pkg", - SearchOption.AllDirectories).Length > 0; + stagedFileOutlivedTheCancel = File.Exists(staged); return (0, "installer: The install was successful."); }; try @@ -529,8 +537,13 @@ public async Task CancelingDuringTheRootInstallWaitsForItToFinish() await PkgInstaller.InstallAsync(item, "App", _ => { }, _ => { }, cancellation.Token)); Assert.False(tokenCouldCancel); Assert.True(stagedFileOutlivedTheCancel); + Assert.False(File.Exists(staged), "The staged file is cleaned up once the install finishes."); + } + finally + { + PkgInstaller.RunProcess = previous; + PkgInstaller.IsRoot = previousIsRoot; } - finally { PkgInstaller.RunProcess = previous; } } [Fact] @@ -541,7 +554,11 @@ public async Task ACanceledOperationStopsBeforeTheRootInstall() using var cancellation = new CancellationTokenSource(); var previous = PkgInstaller.RunProcess; bool ran = false; - PkgInstaller.RunProcess = (_, _, _) => { ran = true; return Task.FromResult((0, "")); }; + PkgInstaller.RunProcess = (file, _, _) => + { + ran |= file != PkgInstaller.SpctlPath; + return Task.FromResult((0, "")); + }; try { await Assert.ThrowsAnyAsync(() => PkgInstaller.InstallAsync(item, "App", _ => { }, @@ -567,13 +584,110 @@ public async Task AFailedCopyLeavesTheInstalledAppInPlace() Assert.Equal(["Example App.app"], Directory.GetFileSystemEntries(applications).Select(Path.GetFileName)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task APackageGatekeeperRejectsInstallsOnlyWhenTheCatalogAllowsIt(bool allowUnsigned) + { + string source = WritePackage("feed/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, 0, [], null); + var previous = PkgInstaller.RunProcess; + bool installed = false; + PkgInstaller.RunProcess = (file, args, _) => + { + if (file == PkgInstaller.SpctlPath) + return Task.FromResult((3, $"{args[^1]}: rejected\nsource=no usable signature")); + installed = true; + return Task.FromResult((0, "installer: The install was successful.")); + }; + try + { + if (allowUnsigned) + Assert.Equal(Enums.OperationVeredict.Success, await PkgInstaller.InstallAsync(item, "App", _ => { }, _ => { }, + CancellationToken.None, allowUnsigned: true)); + else + { + var error = await Assert.ThrowsAsync(() => + PkgInstaller.InstallAsync(item, "App", _ => { }, _ => { }, CancellationToken.None)); + Assert.Contains("no usable signature", error.Message); + } + Assert.Equal(allowUnsigned, installed); + } + finally { PkgInstaller.RunProcess = previous; } + } + + [Fact] + public async Task AnUnsignedPackageAnyUserCanReplaceIsRefused() + { + if (OperatingSystem.IsWindows()) + return; + + string source = WritePackage("shared/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); + File.SetUnixFileMode(Path.GetDirectoryName(source)!, (UnixFileMode)Convert.ToInt32("1777", 8)); + var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, 0, [], null); + var previous = PkgInstaller.RunProcess; + bool ran = false; + PkgInstaller.RunProcess = (_, _, _) => { ran = true; return Task.FromResult((0, "")); }; + try + { + await Assert.ThrowsAsync(() => PkgInstaller.InstallAsync(item, "App", _ => { }, _ => { }, + CancellationToken.None, allowUnsigned: true)); + Assert.False(ran); + } + finally { PkgInstaller.RunProcess = previous; } + } + + [Fact] + public void ACatalogCanAllowItsFeedsUnsignedPackages() + { + var feeds = PkgCatalogFeeds.Parse(Encoding.UTF8.GetBytes(""" + {"version":1,"catalogs":[ + {"id":"signed","name":"Signed","packages":[],"pkgFeed":"https://h/a/"}, + {"id":"internal","name":"Internal","packages":[],"pkgFeed":"https://h/b/","allowUnsignedPackages":true} + ]} + """)); + Assert.Equal([false, true], feeds.Select(f => f.AllowUnsignedPackages)); + } + + [Fact] + public async Task AnUpdateCutOffMidSwapIsRestoredNextTime() + { + if (OperatingSystem.IsWindows()) + return; + + // A swap cut off after moving the old app aside: no app in place, the old one and a partial copy beside it. + string applications = Path.Combine(_directory, "Applications"); + string destination = Path.Combine(applications, "Example App.app"); + WriteApp(applications, ".Example App.unigetui-old", "com.example.app", "1.0"); + WriteApp(applications, ".Example App.unigetui-new", "com.example.app", "partial"); + string command = PkgInstaller.CopyAppCommand(Path.Combine(_directory, "missing", "Example App.app"), destination); + var (code, _) = await PkgInstaller.RunProcess("/bin/sh", ["-c", command], CancellationToken.None); + + Assert.NotEqual(0, code); + Assert.Equal("1.0", AppBundle.VersionOf(AppBundle.ReadInfoPlist(destination))); + Assert.Equal(["Example App.app"], Directory.GetFileSystemEntries(applications).Select(Path.GetFileName)); + } + + [Fact] + public void AToolIsDrainedAndStoppedWhenItOverrunsItsTime() + { + if (OperatingSystem.IsWindows()) + return; + + var chatty = Tool.Run("/bin/sh", ["-c", "head -c 300000 /dev/zero >&2; echo done"], timeout: TimeSpan.FromSeconds(30)); + Assert.Equal((0, "done\n", 300000), (chatty.ExitCode, chatty.Text, chatty.StandardError.Length)); + Assert.Throws(() => Tool.Run("/bin/sleep", ["30"], timeout: TimeSpan.FromMilliseconds(300))); + } + [Fact] public async Task CancelingTheAdministratorPromptCancelsTheOperation() { string source = WritePackage("feed/App.pkg", ProductArchive("com.example.app.pkg", "1.0")); var item = new PkgFeedItem("com.example.app.pkg", "App", "1.0", new Uri(source), null, 0, [], null); var previous = PkgInstaller.RunProcess; - PkgInstaller.RunProcess = (_, _, _) => Task.FromResult((1, "0:61: execution error: User canceled. (-128)")); + PkgInstaller.RunProcess = (file, _, _) => Task.FromResult(file == PkgInstaller.SpctlPath + ? (0, "accepted") + : (1, "0:61: execution error: User canceled. (-128)")); try { Assert.Equal(Enums.OperationVeredict.Canceled, @@ -609,7 +723,9 @@ public void TheManagerListsFeedPackagesTheirReceiptsAndTheirUpdates() { var manager = new MacPkg(); var source = Assert.Single(manager.SourcesHelper.GetSources()); - Assert.Equal(("mac", "Installer packages: Mac apps"), (source.Name, source.AsString_DisplayName)); + // Bundles and the CLI write a source as "Manager: name" and look the part after ": " up again. + Assert.Equal(("mac", "Installer packages: mac"), (source.Name, source.AsString_DisplayName)); + Assert.Same(source, manager.SourcesHelper.Factory.GetSourceIfExists(source.AsString_DisplayName.Split(": ")[^1])); var available = manager.ListLatest(); Assert.Equal(["com.example.app.pkg 2.0", "com.example.current.pkg 5.0", "com.example.tool.pkg 3.0"], @@ -897,7 +1013,11 @@ public async Task ADiskImageAppReplacesTheInstalledOneInApplications() try { var log = new List(); - var verdict = await PkgInstaller.InstallAsync(item, "Example App", log.Add, _ => { }, CancellationToken.None); + // The test app is unsigned, so Gatekeeper rejects it and only the catalog opt-in lets it install. + await Assert.ThrowsAsync(() => + PkgInstaller.InstallAsync(item, "Example App", log.Add, _ => { }, CancellationToken.None)); + var verdict = await PkgInstaller.InstallAsync(item, "Example App", log.Add, _ => { }, CancellationToken.None, + allowUnsigned: true); Assert.True(verdict == Enums.OperationVeredict.Success, string.Join('\n', log)); Assert.Equal("2.0", AppBundle.FindInstalled(item.App!)?.Version); Assert.False(File.Exists(Path.Combine(installed, "Contents", "Resources", "stale.txt"))); diff --git a/src/UniGetUI.Tests/CatalogPackageIdentitySetTests.cs b/src/UniGetUI.Tests/CatalogPackageIdentitySetTests.cs index ccf96fa2e9..529a11b7ef 100644 --- a/src/UniGetUI.Tests/CatalogPackageIdentitySetTests.cs +++ b/src/UniGetUI.Tests/CatalogPackageIdentitySetTests.cs @@ -82,6 +82,21 @@ public void ChocolateySourceExceptionIsBasedOnManagerIdNotAnAlias() Assert.False(identities.Contains(Entry("CHOCOLATEY", source: "different"))); } + [Fact] + public void AnInventoryWithoutSourcesMatchesTheEntryOfEverySource() + { + // A macOS .pkg receipt records no feed, so two catalogs sharing one feed both see it installed. + var manager = CreateManager(); + var capabilities = manager.Capabilities; + capabilities.InstalledPackagesKeepTheirSource = false; + manager.Capabilities = capabilities; + var installed = Package(manager, source: "mac-apps"); + var identities = new CatalogPackageIdentitySet([installed], installedInventory: true); + Assert.True(identities.Contains(Entry(manager.Id, source: "mac-apps-beta"))); + Assert.True(Entry(manager.Id, source: "mac-apps-beta").MatchesInstalled(installed)); + Assert.False(new CatalogPackageIdentitySet([installed]).Contains(Entry(manager.Id, source: "mac-apps-beta"))); + } + [Fact] public void RebuildingSnapshotsReflectsInventoryRemovalAndSourceChanges() { From d1672d4f3fda1808018c00bfc643ff79f957f29e Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Mon, 26 Oct 2026 15:13:16 -0400 Subject: [PATCH 04/16] Add .zip apps, packages in disk images, a root helper and feed editing --- AGENTS.md | 11 +- docs/CLI.md | 3 +- docs/PKG_FEEDS.md | 46 ++- src/Languages/lang_en.json | 13 + src/UniGetUI.Avalonia/Program.cs | 6 + .../DialogPages/CatalogEditorViewModel.cs | 13 +- .../DialogPages/PkgFeedEditorViewModel.cs | 116 +++++++ .../Views/DialogPages/PkgFeedEditor.axaml | 70 ++++ .../Views/DialogPages/PkgFeedEditor.axaml.cs | 11 + .../Views/SoftwarePages/CatalogEditorPage.cs | 36 ++ .../Helpers/MacPkgDetailsHelper.cs | 9 +- .../Helpers/MacPkgOperationHelper.cs | 3 +- .../Internal/AppBundle.cs | 14 + .../Internal/DiskImage.cs | 70 ++-- .../Internal/FeedFile.cs | 29 ++ .../Internal/PkgFeed.cs | 74 +++-- .../Internal/PkgFeedIndex.cs | 5 + .../Internal/PkgHelperClient.cs | 95 ++++++ .../Internal/PkgInstaller.cs | 152 +++++++-- .../Internal/PkgMetadata.cs | 28 +- .../Internal/TrustedPath.cs | 48 +++ .../Internal/ZipApp.cs | 97 ++++++ .../MacPkg.cs | 4 +- .../PkgFeedInspector.cs | 70 ++++ .../PkgHelperCommand.cs | 276 +++++++++++++++ .../PkgIndexCommand.cs | 1 + .../MacPkgTests.cs | 313 +++++++++++++++++- src/UniGetUI.Tests/CatalogEditorTests.cs | 29 ++ 28 files changed, 1516 insertions(+), 126 deletions(-) create mode 100644 src/UniGetUI.Avalonia/ViewModels/DialogPages/PkgFeedEditorViewModel.cs create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/PkgFeedEditor.axaml create mode 100644 src/UniGetUI.Avalonia/Views/DialogPages/PkgFeedEditor.axaml.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/FeedFile.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/PkgHelperClient.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/TrustedPath.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/Internal/ZipApp.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/PkgFeedInspector.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.MacPkg/PkgHelperCommand.cs diff --git a/AGENTS.md b/AGENTS.md index 974dc147a9..3f85d8fb83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ 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 and .dmg) +## 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`. @@ -137,6 +137,15 @@ in the next section. 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. +- The Catalog Editor's **Package feed** dialog (`PkgFeedEditor`, `PkgFeedEditorViewModel`) edits + `pkgFeed`/`allowUnsignedPackages` and previews a feed through `PkgFeedInspector`. ## Version comparison across ecosystems diff --git a/docs/CLI.md b/docs/CLI.md index eaa88426c6..a87fdd79bd 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -356,7 +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-index ` | Writes the `index.json` that publishes a folder of macOS `.pkg` and `.dmg` 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). | +| `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 index b4986401ac..0196354b0b 100644 --- a/docs/PKG_FEEDS.md +++ b/docs/PKG_FEEDS.md @@ -1,7 +1,7 @@ -# Publishing macOS apps (.pkg and .dmg) +# 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` or `.dmg` unchanged, and users install and update it from the +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 @@ -11,12 +11,17 @@ identifier, version and app from the file itself, then checks the Mac: 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. + 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` and `.dmg` files. Declare it as the `pkgFeed` of a catalog in the -machine-wide catalog file, `/Library/Application Support/Devolutions/UniGetUI/SoftwareCatalog.json`: +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 { @@ -97,14 +102,41 @@ to an Intel Mac. 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`. + +When the helper is not running, UniGetUI falls back to the administrator prompt. + ## Limits - Only flat packages are read; legacy bundle packages (a `.pkg` that is a folder) are skipped. -- A `.dmg` must hold exactly one app at its root (an uninstaller app beside it is ignored). An - image that holds a `.pkg` instead is not supported. +- 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 diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index b807c3d7cf..1e4cd72df9 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -122,6 +122,19 @@ "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", diff --git a/src/UniGetUI.Avalonia/Program.cs b/src/UniGetUI.Avalonia/Program.cs index 0378ca0684..a7d3c25df1 100644 --- a/src/UniGetUI.Avalonia/Program.cs +++ b/src/UniGetUI.Avalonia/Program.cs @@ -47,6 +47,12 @@ public static void Main(string[] args) Environment.ExitCode = PkgIndexCommand.Run(args[1..], Console.Out, Console.Error); return; } + + if (PkgHelperCommand.IsCommand(args)) + { + Environment.ExitCode = PkgHelperCommand.Run(args[1..], Console.Out, Console.Error); + return; + } #endif #if WINDOWS diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs index 34ddf22467..2facd6f783 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/CatalogEditorViewModel.cs @@ -21,17 +21,20 @@ public partial class CatalogEditorCatalog : ViewModelBase public ObservableCollection Packages { get; } - // Not edited here, but kept so that saving does not drop them. - public string? PkgFeed { get; } - public bool? AllowUnsignedPackages { 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; + _pkgFeed = definition.PkgFeed; + _allowUnsignedPackages = definition.AllowUnsignedPackages; } public CatalogDefinition ToDefinition() => new() 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/Views/DialogPages/PkgFeedEditor.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/PkgFeedEditor.axaml new file mode 100644 index 0000000000..c9bc8e2bce --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/PkgFeedEditor.axaml @@ -0,0 +1,70 @@ + + + + + + + +