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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,46 @@ retried against a fabricated V2 endpoint. V2-only feeds never enter the V3 path
V3 has no `IsLatestVersion`, so the client picks "latest" itself using the comparator described
in the next section.

## macOS installer packages (.pkg, .dmg and .zip)

`UniGetUI.PackageEngine.Managers.MacPkg` (id `pkg`, macOS only) installs IT-published `.pkg` and
`.dmg` files through the Software Catalog; the administrator workflow is in `docs/PKG_FEEDS.md`.

- Its sources are not added from the UI: each catalog of the machine-wide `SoftwareCatalog.json`
that has a `pkgFeed` declares one, named by the catalog's id and shown by its name
(`PkgCatalogFeeds`, `PkgFeedSource`), with `Sources.CanBeEdited` false. The catalog page
then lists every package of that feed in the catalog (`SoftwareCatalogViewModel.ExpandPkgFeedsAsync`).
- A feed is a `file:` folder whose `.pkg` files are read directly, or an `https:` folder read through
the `index.json` that `unigetui pkg-index <folder>` writes (`PkgIndexCommand`).
- Identity comes from the file itself: for a `.pkg`, `XarArchive` + `PkgMetadata` (the first
component of a Distribution, else `PackageInfo`, plus its outermost app); for a `.dmg`,
`DiskImage` mounts it read-only and reads the app at its root. Installed state comes from
`pkgutil` receipts (`PkgReceipts`), else from that app being in place under its bundle id (`AppBundle`).
- Icons come from the app too: a `.dmg`'s app, or the app's `Info.plist` and `.icns` found in one
forward pass through a `.pkg` payload (`PkgPayload`, gzip'd odc cpio). They are converted with
`sips` once per file version into the icon cache; the manager sets `SupportsCustomPackageIcons`,
without which the base `GetIcon` never asks it.
- Installs run in-process (`IManagedPackageOperationHelper`): stage a private copy, verify the
index hash, the package identity and its Gatekeeper verdict (`spctl --assess`, waived only by a
catalog's `allowUnsignedPackages`), then `installer -pkg`, or `ditto` of a `.dmg`'s app into
`/Applications`, as root through `osascript ... with administrator privileges`. Every tool is run
through `Tool`, which drains both pipes. `ManagerCapabilities.CanUninstall` is false, and the UI
refuses uninstalls for such managers; `InstalledPackagesKeepTheirSource` is false (as for
Chocolatey), so catalogs sharing a feed all see its installed packages.
- A `.dmg` may hold one app or one `.pkg` (`WrapsPackage`, recognized by receipt); a `.zip` holds one
app, read with `ZipApp` and extracted with `ditto`. `PkgInstaller.PrepareAsync` stages and checks
every kind and returns the one root command to run.
- `unigetui pkg-helper` (`PkgHelperCommand`) is an optional root LaunchDaemon on a Unix socket. A
client sends only catalog id, package id and version (`PkgHelperClient`); the helper resolves the
feed itself from the catalog, refuses paths standard users could change (`TrustedPath`), then
runs the same `PrepareAsync`. Installs fall back to the administrator prompt when it is absent.
- Behind the prompt, a UniGetUI that standard users cannot replace runs itself as root
(`pkg-helper install-item`, `PkgElevatedInstall`), so staging and checks happen where the user's
own processes cannot swap the package; only an unprotected build (such as a dev build) checks it
before the prompt instead.
- The Catalog Editor's **Package feed** dialog (`PkgFeedEditor`, `PkgFeedEditorViewModel`) edits
`pkgFeed`/`allowUnsignedPackages` and previews a feed through `PkgFeedInspector`.

## Version comparison across ecosystems

`CoreTools.VersionStringToStruct` treats `-`, `_`, `/` and `#` as plain numeric separators and
Expand Down
2 changes: 2 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ These parameters are accepted by the app executables in addition to the automati
| `--enable-secure-setting-for-user <user> <key>` / `--disable-secure-setting-for-user <user> <key>` | Toggles one secure setting for a specified user. | May require elevation. |
| `--delete-shortcuts <path> [<path>...]` | 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. |
| `<bundle-file>` | Loads a valid bundle file into the Package Bundles page. | Supported extensions include `.ubundle`, `.json`, `.yaml`, and `.xml`. |
| `pkg-helper install-daemon` / `remove-daemon` | Installs (or removes) the root service that lets standard users install catalog packages without an administrator password. | macOS, as root. `pkg-helper serve` is what the service runs. See [PKG_FEEDS.md](PKG_FEEDS.md). |
| `pkg-index <folder>` | 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

Expand Down
149 changes: 149 additions & 0 deletions docs/PKG_FEEDS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Publishing macOS apps (.pkg, .dmg and .zip)

The software catalog can offer apps that no package manager carries: an administrator publishes
the vendor's `.pkg`, `.dmg` or `.zip` unchanged, and users install and update it from the
**Software Catalog** page.

There is nothing to repackage and no detection rule to write. UniGetUI reads each file's
identifier, version and app from the file itself, then checks the Mac:

- **`.pkg`:** installed when macOS holds its receipt (`pkgutil --pkgs`), at the receipt's version.
Without a receipt, the app the package installs counts too when it is in place under its bundle
id, so an app that a management tool or the user installed another way is recognized.
- **`.dmg`:** a disk image with one app at its root (the usual "drag to Applications" image).
Installed when that app is in `/Applications` under its bundle id, at its bundle version. A disk
image holding an installer package instead is treated as that `.pkg`.
- **`.zip`:** an archive holding one app, at its root or one folder down, as some vendors ship
it (Visual Studio Code, for one). Recognized like a `.dmg`'s app.

## Set up a feed once

A feed is a location holding `.pkg`, `.dmg` and `.zip` files. In the Catalog Editor, select a
catalog and choose **Package feed**: enter the folder or web address, then **Check feed** lists
every package it publishes and every file it skips, with the reason. Or declare it as the
`pkgFeed` of a catalog in the machine-wide catalog file, `/Library/Application Support/Devolutions/UniGetUI/SoftwareCatalog.json`:

```json
{
"version": 1,
"catalogs": [
{
"id": "mac-apps",
"name": "Mac apps",
"packages": [],
"pkgFeed": "https://software.example.com/mac/"
}
]
}
```

Every package in the feed appears in that catalog. `packages` may stay empty; any entry listed
there as well is shown alongside the feed's packages. Keep `"packages": []` even when it is empty:
UniGetUI versions without feed support reject a catalog without it.

A feed is identified by its catalog's `id`, so renaming the catalog keeps everything in place, and
two catalogs may share a name or even a feed. Outside the catalog page (Installed, Updates, bundles,
the command line) the id is the package's source, as in `Installer packages: mac-apps`, so choose
a readable one. To list a feed's package by hand in another catalog, use `"ManagerName": "pkg"`
and the feed catalog's `id` as `"Source"`.

### Signatures

Before installing, UniGetUI asks Gatekeeper about the package the same way macOS checks anything
downloaded: it must be signed with a Developer ID and notarized by Apple. Vendors' own packages
and disk images usually are. A package Gatekeeper rejects is not installed.

To publish your own unsigned packages, add `"allowUnsignedPackages": true` to that catalog. Its
packages are then installed as they are, so keep its feed where only administrators can change
it: UniGetUI refuses an unsigned file, or a file in a folder, that any user on the Mac can write
to (such as `/Users/Shared`).

A feed can be:

| Location | Example | What UniGetUI reads |
|---|---|---|
| A web folder (https) | `https://software.example.com/mac/` | `index.json` in that folder |
| A local folder or a mounted share | `/Volumes/Software/mac` | the `.pkg` and `.dmg` files directly |

Plain `http` is refused (except on `localhost`, for testing, and never by the package helper below),
because the index is what pins each package's hash.

## Publish a package

**Folder or share feed:** copy the `.pkg` or `.dmg` into the folder. Subfolders up to three levels
deep are read too.

**Web feed:** copy the file into the folder you publish, then regenerate its index and upload the
folder:

```bash
/Applications/UniGetUI.app/Contents/MacOS/uniget pkg-index /path/to/mac
```

The command reads every `.pkg` and `.dmg` in the folder and writes `index.json` next to them, with
each package's identifier, version, supported architectures, app and SHA-256. Run it on a Mac (disk
images are read with `hdiutil`), and again after every change. A file it cannot read is reported and
left out of the index.

Each package shows the icon of the app it installs, taken from the `.dmg`'s app or from the
`.pkg`'s payload. To use another icon, put a `.png` or `.jpg` with the same name next to the file
(`Zoom.pkg` → `Zoom.png`). `pkg-index` saves the app's icon next to each file that has none, so a
web feed serves it too.

To offer an update, publish the newer file. You can delete the older one or keep it. When the
feed holds several versions of a package, users get the newest one that runs on their Mac. A
native build is preferred over one that needs Rosetta, and an `arm64`-only build is never offered
to an Intel Mac.

## What users see

- **Install** and **Update** ask for an administrator password with the standard macOS prompt,
then, as root, download the file (or copy it from the share) and check its hash, identity and
signature. A `.pkg` then runs through
`installer`, and a `.dmg`'s app is copied to `/Applications`, replacing the previous version
only once the new one is in place. Once that step starts it cannot be canceled, since stopping
it halfway would leave a broken app.
- Packages cannot be uninstalled from UniGetUI.
- With the package helper installed (below), no password is asked, and standard users can
install too.
- When the feed is unreachable (off the VPN, share not mounted), UniGetUI keeps showing the
packages it last listed. Installing needs the feed again.

## Installing without an administrator password

By default, installing asks for an administrator password, which standard users do not have. The
package helper is a small root service, like a management agent's, that installs what the catalog
publishes without asking. Set it up once per Mac from a deployment script, after UniGetUI itself:

```bash
sudo /Applications/UniGetUI.app/Contents/MacOS/UniGetUI pkg-helper install-daemon
```

It runs from `/Library/LaunchDaemons/com.devolutions.unigetui.pkghelper.plist` and logs to
`/Library/Logs/UniGetUI-pkg-helper.log`. `pkg-helper remove-daemon` removes it.

A user can only ask it for a package by catalog id, package id and version. The helper reads the
catalog, lists the feed, stages and checks the package itself, with the same hash, identity and
Gatekeeper checks as any install, and refuses:

- anything a catalog does not publish;
- a catalog file, folder feed or package file that a standard user could change: each must belong
to root or an administrator and be writable by no one else. So install UniGetUI with its
installer package (so that the app belongs to root), and keep feeds out of shared folders such as
`/Users/Shared`.
- a web feed that is not served over `https`, since any user could answer on a `localhost` port
the real server left free.

When the helper is not running, or refuses a package (such as one from an unprotected feed), UniGetUI
falls back to the administrator prompt, which an administrator can still approve.

## Limits

- Only flat packages are read; legacy bundle packages (a `.pkg` that is a folder) are skipped.
- A `.dmg` must hold exactly one installer package or one app at its root (an uninstaller beside
it is ignored); a `.zip` exactly one app.
- A product archive is identified by its first component. Without that component's receipt or the
app it installs, the product shows as not installed, even if other components are present.
- An app is looked for only where the package installs it: an app renamed or moved elsewhere is
not recognized.
- A `.pkg` receipt survives the user deleting the app by hand, so the app still shows as installed.
20 changes: 20 additions & 0 deletions src/Languages/lang_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,26 @@
"Enable the Catalog Editor": "Enable the Catalog Editor",
"Catalog Editor is disabled": "Catalog Editor is disabled",
"Enable the Catalog Editor in Experimental settings and developer options, then restart UniGetUI.": "Enable the Catalog Editor in Experimental settings and developer options, then restart UniGetUI.",
"Apps (.pkg and .dmg) that your organization publishes in its software catalog.": "Apps (.pkg and .dmg) that your organization publishes in its software catalog.",
"Installer package (.pkg)": "Installer package (.pkg)",
"Disk image (.dmg)": "Disk image (.dmg)",
"Zip archive (.zip)": "Zip archive (.zip)",
"Enter an https address or the full path of a folder.": "Enter an https address or the full path of a folder.",
"Reading the feed...": "Reading the feed...",
"{0} packages found, {1} files skipped.": "{0} packages found, {1} files skipped.",
"The feed could not be read: {0}": "The feed could not be read: {0}",
"Every .pkg, .dmg and .zip file in this folder or web address appears in the catalog. Leave it empty for a catalog without a feed.": "Every .pkg, .dmg and .zip file in this folder or web address appears in the catalog. Leave it empty for a catalog without a feed.",
"Package feed location": "Package feed location",
"Choose folder...": "Choose folder...",
"Allow packages that are not signed and notarized (for your own packages; keep the feed where only administrators can change it)": "Allow packages that are not signed and notarized (for your own packages; keep the feed where only administrators can change it)",
"Check feed": "Check feed",
"Package feed": "Package feed",
"Choose the folder of the package feed": "Choose the folder of the package feed",
"Package feed of {0}": "Package feed of {0}",
"Published by your organization in {0}.": "Published by your organization in {0}.",
"UniGetUI wants to install {0}.": "UniGetUI wants to install {0}.",
"Some packages cannot be uninstalled": "Some packages cannot be uninstalled",
"{0} cannot be uninstalled from UniGetUI. Ask your administrator to remove it.": "{0} cannot be uninstalled from UniGetUI. Ask your administrator to remove it.",
"Software Catalog": "Software Catalog",
"Software selected for you, ready to install.": "Software selected for you, ready to install.",
"Refresh software catalog": "Refresh software catalog",
Expand Down
6 changes: 6 additions & 0 deletions src/UniGetUI.Avalonia.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/UniGetUI.PackageEngine.Managers.MacPkg/">
<Project Path="UniGetUI.PackageEngine.Managers.MacPkg/UniGetUI.PackageEngine.Managers.MacPkg.csproj">
<Platform Solution="*|arm64" Project="arm64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/UniGetUI.PackageEngine.Managers.Snap/">
<Project Path="UniGetUI.PackageEngine.Managers.Snap/UniGetUI.PackageEngine.Managers.Snap.csproj">
<Platform Solution="*|arm64" Project="arm64" />
Expand Down
8 changes: 4 additions & 4 deletions src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 3 additions & 5 deletions src/UniGetUI.Avalonia/Infrastructure/MacAdminFileInstaller.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Runtime.Versioning;
using UniGetUI.Core.Tools;

namespace UniGetUI.Avalonia.Infrastructure;

Expand All @@ -25,7 +26,7 @@ public static async Task<bool> 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
Expand All @@ -48,7 +49,7 @@ public static async Task<bool> 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.");
Expand All @@ -59,7 +60,4 @@ public static async Task<bool> 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("\"", "\\\"");
}
9 changes: 4 additions & 5 deletions src/UniGetUI.Avalonia/Models/CatalogPackageIdentitySet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ public CatalogPackageIdentitySet(IEnumerable<IPackage> 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));
Expand Down
Loading
Loading