Files
amethyst/desktopApp/plans/2026-10-15-desktop-self-update-study.md
Vitor PamplonaandClaude Opus 5.5 2bd63f83b3 fix(update): own-cask Homebrew check, release notes, check at every start
Found by installing a test DMG on a Mac that also has the Homebrew cask
and updating it from a signed test release:

- Homebrew detection matched the machine, not the app: any copy counted
  as the cask's once Caskroom/amethyst-nostr existed, so a DMG copy in
  ~/Applications was told to run `brew upgrade`. The cask now owns a
  bundle only when one of its Caskroom/<cask>/<version>/*.app links
  resolves to it.
- The update prompt now shows the release notes ("What's new"), in a box
  that scrolls when they run long. They were read but never shown.
- Every start checks again, as the auto-check setting says. The saved
  last-check time used to put the startup check off for 12 hours, and
  Settings then said "Not checked yet". It now only feeds a
  "Last checked: <time>" line in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:09:17 -04:00

34 KiB
Raw Permalink Blame History

Desktop self-update: study

Status: built (phases 1 to 5), not yet tried on a real macOS or Windows machine. Written 2026-10-15; see Implementation for what was built, how it was checked and what is left.

Question: when someone installs Amethyst Desktop from a file on the GitHub Release page (not from Homebrew, winget, Flathub or Orchard), how can the app update itself on macOS, Windows and Linux?

TL;DR

  1. Don't build one updater. Build one update check and one installer per artifact. We ship nine desktop artifacts, and each one updates differently. A shared headless checker in commons finds the new version. A small per-format installer in desktopApp applies it.
  2. Never fight a package manager. If Homebrew, winget, apt/dnf or Flatpak owns the install, the app only tells the user an update exists and how to get it. Self-replacement is only for installs nobody else manages: the DMG, the MSI, the Windows zip, the AppImage and the Linux tarball.
  3. The trust anchor should be a Nostr event, not GitHub. Amethyst already parses NIP-82 software releases (quartz/…/experimental/nip82SoftwareApps/). A kind 3063 asset event signed by Amethyst's key carries the asset's URL, SHA-256, size, version and platform (f tag). The app pins that pubkey, verifies the signature, downloads from GitHub and checks the hash. A compromised GitHub release then can't ship a binary the app will install.
  4. Do it in-house. No framework fits well. Conveyor would replace our whole packaging pipeline. Sparkle and WinSparkle are native libraries a JVM app would have to bind. Velopack has no JVM SDK we could confirm. The in-house installers are each about 100–200 lines (download, verify, swap, relaunch).
  5. Suggested order: notify-only for everyone → AppImage + Linux tarball + Windows zip (rename-swap, no privileges) → MSI (UAC) → DMG → signed apt/dnf repos for the .deb/.rpm.

What ships today

From .github/workflows/create-release.yml, scripts/asset-name.sh and desktopApp/build.gradle.kts:

Artifact Archs Installs to Signed? Who updates it today
.dmg macOS arm64 only /Applications/Amethyst.app (user drags) Developer ID + notarized + stapled Homebrew cask amethyst-nostr, or nobody
.msi Windows x64 only C:\Program Files\Amethyst (per-machine; winget manifest says Scope: machine) No Authenticode winget upgrade, or nobody
.zip Windows x64 + arm64 wherever the user unzips it No nobody (the only Windows arm64 option)
.deb Linux x64 + arm64 /opt/amethyst (root) No repo signature nobody (no apt repo)
.rpm Linux x64 + arm64 /opt/amethyst (root) No repo signature nobody (no dnf repo)
.AppImage x86_64 + aarch64 one file, anywhere No nobody (no update info embedded: createReleaseAppImage calls appimagetool without -u)
.tar.gz Linux x64 + arm64 wherever the user extracts it No nobody
.flatpak bundle Linux x64 + arm64 Flatpak user/system install No nobody (built with --runtime-repo only, no --repo-url)
Orchard Flatpak Linux x64 + arm64 Flatpak NIP-82 listing signed with Amethyst's nsec Orchard

Other facts that matter:

  • User data lives outside the install. Accounts and keys are in ~/.amethyst, caches and Tor state in the OS cache and app-support folders (see the zap list in packaging/homebrew/amethyst-nostr.rb). Replacing the install tree loses nothing.
  • The app knows where its launcher is. jpackage sets the jpackage.app-path system property to the absolute launcher path. OsScheduler.resolveAppLaunchCommand() already uses it. It is null under ./gradlew run, which is how a dev build stays out of the updater.
  • The app knows its version. -Damethyst.version=$appVersion is passed to the JVM. Nip82VersionComparator already compares NIP-82 versions.
  • The MSI upgrade path already works. upgradeUuid is fixed in build.gradle.kts, and winget's UpgradeBehavior: install runs the new MSI over the old one. An in-app MSI update takes the same path.
  • Every package is large. Each one ships a jlink'd JRE, Skiko, FFmpeg and Tor. Only zsync (AppImage) gives cheap delta updates. Everything else downloads the full package.

Rule zero: detect the install channel

Run this at startup, from desktopApp. It returns a sealed InstallChannel.

Signal Channel What the app does
jpackage.app-path is null Dev build No update checks
FLATPAK_ID set, or /.flatpak-info exists Flatpak (bundle or Orchard) Portal update (see Linux) or notify only
APPIMAGE set (set by the AppImage runtime) AppImage Self-replace
macOS, and a link under /opt/homebrew/Caskroom/amethyst-nostr/<version>/ (or /usr/local/Caskroom/…) leads to this bundle Homebrew cask Notify: "run brew upgrade --cask amethyst-nostr"
macOS, bundle path contains /AppTranslocation/ or starts with /Volumes/ Translocated / running from the DMG Tell the user to move the app to /Applications. Updating here can't work: the path is read-only and random.
macOS, anything else DMG install Self-replace
Windows, launcher under %ProgramFiles% and the MSI UpgradeCode is registered MSI (direct or winget: same MSI, same ARP entry) Run the new MSI
Windows, anything else Portable zip Self-replace
Linux, launcher under /opt/amethyst/ and dpkg-query -S / rpm -qf owns it .deb / .rpm Notify, or hand off to PackageKit
Linux, anything else Tarball Self-replace

On Windows, a winget install and a direct MSI install can't be told apart, and it doesn't matter: winget reads the installed version from the same ARP (Add/Remove Programs) entry, so it stays in sync whoever ran the new MSI.

Homebrew is the exception. If the app replaces itself, brew's record goes stale. Many casks do this and declare auto_updates true, but our cask is deliberately bare (upstream maintainers removed extras). So: notify only.

Every channel gets a user setting: "Check for updates" (on/off), plus "Install automatically" wherever self-replace exists. Respect it before any network call.

Where the "new version" signal comes from

Option A: GitHub Releases API

GET https://api.github.com/repos/vitorpamplona/amethyst/releases/latest. That gives the tag and the asset URLs. The asset name is predictable from scripts/asset-name.sh. Unauthenticated requests allow 60 per hour per IP, which is plenty.

  • Pros: zero release-process change.
  • Cons: trust rests entirely on TLS + the GitHub account. A leaked token or a hijacked workflow could swap an asset. Every check also leaks the user's IP to GitHub.

The release process publishes, per desktop asset, a kind 3063 SoftwareAssetEvent with:

  • i (the desktop app id)
  • url (the GitHub download URL)
  • x (SHA-256)
  • size
  • version
  • f platform (darwin-arm64, windows-x86_64, windows-aarch64, linux-x86_64, linux-aarch64)
  • m mime type (to tell the MSI from the zip, and the deb from the AppImage)

A kind 30063 release references the assets, and the kind 32267 application ties them together. Quartz already models all three: SoftwareApplicationEvent, ReleaseArtifactSetEvent (kind 30063; SoftwareReleaseEvent is a deprecated alias for it) and SoftwareAssetEvent. PlatformTag has exactly these platform constants. See the next sections for how many events that is and how existing stores read them.

The app:

  1. Fetches the newest assets for its own platform from the relays it already talks to, filtered on the relay with #i (app id) + #f (platform). It uses fetchFirst/fetchAll from nip01Core/relay/client/accessories. Tor routing then comes for free when the user has Tor enabled.
  2. Picks the asset whose m (or variant) matches how it was installed (MSI, zip, AppImage…).
  3. Checks that the author equals a pubkey pinned in the build.
  4. Verifies the Schnorr signature (Quartz already does this for every event).
  5. Downloads from url and refuses anything whose SHA-256 or size differs.

Pros:

  • End-to-end integrity from the maintainer's key, not GitHub's.
  • No new crypto, and no second signing key (Sparkle would need an extra EdDSA key, for example).
  • Privacy: no GitHub call until the user actually updates.
  • The same events make the desktop builds visible in the in-app Software Apps screen and in Zapstore-style stores.

Cons / open points:

  • Signing in CI vs by hand. The nsec currently signs by hand (zsp publish, orchard-publish; see RELEASE_OPS.md). Options:
    • a maintainer step after the release (amy could gain a publish-release verb that hashes the published assets and signs);
    • a NIP-46 bunker that CI asks to sign;
    • a separate "release signer" key that the app pins in addition to the main key, so the main nsec never touches CI.
  • App-id collisions. Zapstore owns com.vitorpamplona.amethyst and Orchard owns com.vitorpamplona.amethyst.Amethyst, both signed by the same key. The desktop listing needs a third id; see Event budget.
  • Downgrade and freeze protection. The app refuses a version lower than its own, and refuses an event older than the newest one it has accepted. Relays can withhold events (a freeze), but they can't forge one.

Recommended: B is the source of truth for automatic installs. A is the fallback for a "new version available" banner only, never for installing unattended.

Event budget per release

Recommended app id: com.vitorpamplona.amethyst.desktop (the macOS bundleID).

Event How many When
32267 listing 1 Once; re-sign only when the listing text or icon changes
30063 release com.vitorpamplona.amethyst.desktop@X.Y.Z 1 Every release
3063 assets 8 (12 with deb/rpm) Every release

The 8 assets are the files the self-updater can install:

Platform (f) Files
darwin-arm64 DMG
windows-x86_64 MSI, zip
windows-aarch64 zip
linux-x86_64 AppImage, tar.gz
linux-aarch64 AppImage, tar.gz

The .deb and .rpm for both architectures add 4 more. They're only worth publishing for the PackageKit hand-off, which needs a verified hash; if those installs stay notify-only, they can be left out. The GitHub Flatpak bundles stay notify-only too, and Orchard already publishes signed events for Flatpak users.

So a release is 9 events (13 with deb/rpm), against Orchard's 3. All the hashes come from the dist/ folder the release workflow already builds, so one command (e.g. an amy command that reads dist/ and the tag) can build and sign every event at once.

Why not fewer events

Not one release event with the hashes inline, without asset events. The release already works as a single signed manifest: it points to each asset by event id (AssetTag is an e tag holding the 64-character id), and an event id is the hash of the event's content. One signature on the release therefore fixes every asset event, including each file's SHA-256; swapping an asset changes its id and breaks the release signature. Inlining would save one relay round trip and take the release off NIP-82, so Zapstore-style clients and our own Software Apps screen could no longer read it.

Not one app id for the APK, the Flatpak and desktop. NIP-82 allows one release to carry assets for every platform, but:

  • zsp and orchard-publish each sign a 30063 with only their own files. Under a shared id, whichever publishes last replaces the others' file list (Orchard's per-arch address, below, avoids that only for its own two architectures). Sharing means retiring both tools for our own publisher.
  • The Flatpak id was moved off com.vitorpamplona.amethyst in v1.18.0 for exactly this reason (desktopApp/packaging/flatpak/README.md).
  • Zapstore's detail screen would pick up non-Android assets (below).

How the existing stores filter

NIP-82 has no "type of release". Stores filter by the platform tag f, which all three kinds carry: the listing (every platform the app supports), the release (the union of its assets' platforms, which NIP-82 requires and buildSoftwareRelease computes), and each asset (its own platform). Relays index single-letter tags, so #f can be filtered on the relay. Where one platform has several formats, the asset's m tag (MIME type) or variant tells them apart.

Checked against the source on 2026-10-15: Zapstore's default branch (last commit 2026-08-27) with the purplebase/models library it uses, and Orchard's (last commit 2026-10-15).

Zapstore filters on the relay and reads assets directly.

  • The platform is hard-coded: every package manager returns 'android-arm64-v8a' (android_package_manager.dart:89).
  • Catalog and update queries send it to the relay: listings with {'#d': ids, '#f': {platform}}, assets with {'#i': {appId}, '#f': {platform}}, limit: 1 (catalog_fetcher.dart).
  • It works asset-first. For current-format apps it skips the 30063 release and takes the newest 3063 for the app id and platform. The release chain is only used for old apps still on kind 1063.
  • It never reads m or variant.
  • Gap: the app detail screen loads latestAsset with only the author and #i, no platform, limit 1 (models/lib/src/models/app.dart:40). A non-Android asset under the APK's app id could show there as the latest build. This is why desktop must not share the APK's id.

Orchard downloads everything and filters on the device.

  • It subscribes to all 32267, 30063 and 3063 events with no tag filter (orchard-nostr/src/relay.rs:141).
  • It drops listings whose platform tags include no linux-*, an explicit guard against Zapstore's Android listings.
  • It already expects mixed releases. From orchard-nostr/src/lib.rs:185: "A release references every format the publisher ships (apk, exe, AppImage), all sharing the linux platform tags, so the kind, not just the platform, decides installability." An asset is installable only with an oci:/docker:// location, m = application/vnd.flatpak, or a .flatpak filename, and then only on an exact host-platform match (falling back to assets with no f).
  • It accepts stable channels only: no channel tag, stable, or main (Zapstore's default name).
  • orchard-publish addresses releases as <app-id>@<version>@<platform>, one per architecture, so its two runs don't overwrite each other. NIP-82 says <app-id>@<version>; Orchard's reader accepts both.

What our events must carry, as a result:

  • i and f on every asset (what Zapstore filters on), and m (what Orchard keys on and what our updater needs). SoftwareAssetEvent.build already requires the app id and MIME type; the platform tag is the one to remember.
  • A c channel tag of stable on stable releases.
  • One release holding all assets, or one per platform like Orchard: both are valid. One release keeps everything under one signature and is simpler.

Our updater should read the same way Zapstore does (#i + #f on the relay, then m on the device). The release event stays useful for release notes and the single signature over all assets.

Per-OS mechanics

All the self-replace paths share one shape:

  1. Check: at startup + every ~12 h, with a jittered delay. Never on the first launch.
  2. Ask: banner or notification, "Amethyst X.Y.Z is ready", with "Update now" / "Later" / release notes.
  3. Download to staging: a sibling of the install (<install>.new / <install>.download) so the final rename stays on one filesystem and is atomic.
  4. Verify: hash + size from the signed manifest, plus the platform signature where one exists.
  5. Apply on restart: spawn a detached helper that waits for our PID to exit, swaps, relaunches jpackage.app-path, and cleans up. Keep the old copy until the new one starts (rollback).

The helper is a generated shell script on macOS/Linux and a PowerShell script on Windows, written to the cache dir. That avoids shipping a second binary.

macOS: DMG install (arm64)

In-house, recommended:

  1. Download the DMG, check the hash.
  2. Mount it: hdiutil attach -nobrowse -readonly -mountpoint <tmp> <dmg>.
  3. Run codesign --verify --deep --strict <tmp>/Amethyst.app. Compare its TeamIdentifier (from codesign -dv) with the running bundle's. spctl --assess --type execute confirms notarization.
  4. Run ditto <tmp>/Amethyst.app <parent>/Amethyst.app.new, then detach.
  5. Helper: wait for the PID, then mv Amethyst.app Amethyst.app.old && mv Amethyst.app.new Amethyst.app, then open Amethyst.app, then rm -rf Amethyst.app.old.

Traps:

  • Write access. /Applications is writable by admin-group users, not by standard users. If <parent> isn't writable, fall back: download, verify, and open the DMG for the user to drag across. An elevated copy through osascript … with administrator privileges is possible but heavy-handed.
  • Translocation. An app launched from ~/Downloads with the quarantine attribute gets moved to a random read-only path, and the original location can't be found (Eclectic Light). Detect it and ask the user to move the app; don't try to update.
  • Never patch inside the bundle. Replacing a jar in Contents/app/ breaks the code-signature seal and Gatekeeper will refuse the app. That rules out jar-only updates (update4j-style) on macOS. Replace the whole .app.
  • Quarantine. A file written by the JVM's HTTP client carries no com.apple.quarantine attribute. Only LSFileQuarantine-aware apps add it, so the copied bundle isn't translocated on its next launch. To verify in phase 0.
  • Intel Macs. The release matrix builds arm64 only, so there is nothing to offer them.

Sparkle 2 (alternative). MIT license (checked the LICENSE on the 2.x branch). It is the standard macOS updater: EdDSA-signed appcast, DMG/zip installs, an installer helper that handles authorization and translocation, and delta updates. Cost for us:

  • Embed Sparkle.framework (with its XPC services) in Contents/Frameworks. jpackage/Compose don't do that. It needs a step between createDistributable and signing, and every nested binary must be signed for notarization.
  • Drive SPUStandardUpdaterController from the JVM through JNA / the ObjC runtime.
  • Keep a second signing key (EdDSA) and an RSS appcast next to the NIP-82 events.

Worth it only if the in-house path hits permission walls we can't cross.

Windows: MSI install (x64)

  1. Download the MSI, check the hash.
  2. Helper (PowerShell): wait for the PID, then Start-Process msiexec -Verb RunAs -ArgumentList '/i', '<msi>', '/passive', '/norestart' -Wait, then relaunch C:\Program Files\Amethyst\Amethyst.exe. The RunAs verb triggers UAC because the MSI is per-machine.
  3. The fixed upgradeUuid makes it a major upgrade: Windows Installer removes the old version and installs the new one. winget already runs exactly this step.

Traps and recommendations:

  • Unsigned MSI. The UAC prompt says "Unknown publisher". Integrity is still covered by the signed manifest, but users see a scary dialog on every update. Recommend Authenticode signing in the release workflow. Azure Trusted Signing and SignPath's free OSS tier are the usual options. With a signature, the app can also check Get-AuthenticodeSignature before running the MSI.
  • SmartScreen. A file the app writes itself has no Mark-of-the-Web (Zone.Identifier stream), so SmartScreen shouldn't intervene. To verify in phase 0.
  • Per-user install. jpackage's --win-per-user-install would make the MSI install to %LOCALAPPDATA% and update with no UAC. But Windows Installer can't major-upgrade across per-machine and per-user contexts, so existing users would end up with two installs, and the winget manifest's Scope would change. Not worth it alone. Maybe revisit with signing.
  • Toasts. The Start Menu shortcut carries the AUMID that toasts need, and the MSI recreates it on upgrade. Nothing to do.

WinSparkle (alternative). MIT (checked COPYING). It downloads and runs the installer with its own native UI. It would replace step 1–2 with a DLL bound through JNA, but its UI isn't our Compose UI. Little gain.

Windows: portable zip (x64 + arm64)

This is the only Windows arm64 artifact, so it has to work.

  1. Download the zip, check the hash, extract to <parent>\Amethyst.new.
  2. Helper: wait for the PID. Windows locks the running .exe, loaded DLLs and jars the JVM has open, so nothing can be swapped before we exit. Then rename Amethyst → Amethyst.old and Amethyst.new → Amethyst, launch, delete .old. Retry the rename a few times: antivirus scanners briefly hold freshly extracted files.
  3. If the parent dir isn't writable (e.g. unzipped into Program Files by hand), fall back to notify + open the download page.

Linux: AppImage (x86_64 + aarch64)

In-app (recommended):

  1. Download the new AppImage to $APPIMAGE.new (same dir), check the hash, chmod +x.
  2. rename($APPIMAGE.new, $APPIMAGE). This is safe while running: the running instance's squashfs mount holds the old inode, and the rename is atomic.
  3. Relaunch via $APPIMAGE and exit. No helper process needed.

Keeping the old filename means it shows a stale version string, but it keeps the user's desktop shortcuts (and AppImageLauncher's integration) pointing at the right file.

Also add zsync update info, which is nearly free:

  • Pass -u "gh-releases-zsync|vitorpamplona|amethyst|latest|amethyst-desktop-*-x86_64.AppImage.zsync" (and the aarch64 equivalent) to appimagetool in createReleaseAppImage. It writes the .zsync file next to the AppImage; upload that too.
  • External updaters can then do delta updates without the app's help: appimageupdatetool, AppImageLauncher, Gear Lever. AppImageUpdate is MIT (checked LICENSE.txt). AppImage docs.
  • Caveat: latest means GitHub's latest non-prerelease, which is what we want.

Linux: tarball (x64 + arm64)

Same as the Windows zip: extract to a sibling dir, swap, relaunch. Linux lets us rename the directory while running (open jars stay valid through their file descriptors), but restart right after the swap: classes that load later come from the new jars.

Linux: .deb / .rpm (x64 + arm64)

The app must not rewrite root-owned /opt/amethyst itself. Two honest paths:

  1. Host signed apt + dnf repositories (recommended long-term). The .deb's postinst drops /etc/apt/sources.list.d/amethyst.list and a keyring in /usr/share/keyrings/; the .rpm drops a /etc/yum.repos.d/amethyst.repo. Chrome, VS Code and Signal do this. From then on unattended-upgrades, GNOME Software, Discover or dnf upgrade keep the app current, with zero updater code in the app. Costs:
    • a repo host: GitHub Pages works for a small apt/dnf repo; Cloudsmith and packagecloud have OSS tiers;
    • a GPG key in CI;
    • more postinst editing in scripts/fix-deb-*.sh and in rpmSpec() in build.gradle.kts.
  2. Short-term. Notify, then either open the download, or call PackageKit's D-Bus InstallFiles on the verified .deb/.rpm. PackageKit shows the polkit password prompt and works on both apt and dnf systems. If PackageKit is missing, fall back to xdg-open on the file (the software center installs it).

Linux: Flatpak bundle

  • An installed bundle has no update source today. Rebuilding it with flatpak build-bundle --repo-url=<ostree repo> --gpg-keys=<key> makes installing it "automatically configure a remote for this URL" (flatpak command reference). But that needs a hosted, GPG-signed OSTree repo, which is close to running our own Flathub.
  • Once a remote exists (Orchard, Flathub, or our own), the app can update itself through the portal: org.freedesktop.portal.Flatpak.CreateUpdateMonitor emits UpdateAvailable, and Update(parent_window, …) installs it. Available since flatpak 1.5.0. It fails when the new version asks for more permissions than the installed one.
  • Recommendation: steer Flatpak users to Orchard (and Flathub once the source build lands). For the GitHub bundle, notify only.

Frameworks considered

Tool License (checked) Fit Verdict
Conveyor (Hydraulic) Tool is proprietary; free for OSS apps. The public hydraulic-software/conveyor repo is Apache-2.0 docs/samples. Replaces jpackage entirely: MSIX + .appinstaller on Windows, Sparkle zips (no DMG) on macOS, its own apt repo for .deb, tarball with no updates. Has Compose Desktop support. Most complete, but it throws away our signed DMG, MSI/winget/Homebrew pipeline, rpm, AppImage and Flatpak work, and puts releases on a closed tool. No.
Sparkle 2 MIT macOS only; native framework + JNA bindings. Fallback for macOS if the in-house path fails.
WinSparkle MIT Windows only; runs our MSI. Little gain over a PowerShell helper.
Velopack MIT Lists C#, C++, JS, Rust SDKs; no JVM SDK we could confirm; ships its own installer/package formats. No.
update4j Apache-2.0, repo archived 2023 Swaps jars at JVM level. Wrong level: breaks the macOS signature seal and can't update the JRE or natives. No.
AppImageUpdate / zsync MIT AppImage only. Yes, as the -u update info (external tools), alongside the in-app swap.

Proposed shape in the codebase

  • commons (headless). These go under a new commons/…/update/ package.
    • UpdateSource interface, with NostrReleaseSource (NIP-82) and GitHubReleaseSource (notify-only) implementations.
    • AvailableUpdate(version, notesUrl, asset: url/sha256/size/mime/platform, trusted: Boolean).
    • UpdateChecker: schedules checks, compares versions with Nip82VersionComparator, applies downgrade/freeze rules, exposes a StateFlow<UpdateState>.
    • All of it is CLI-safe, so amy could reuse it later (amy update, since amy ships a tarball too).
  • desktopApp (platform shim).
    • InstallChannelDetector.
    • One UpdateInstaller per channel: MacDmgInstaller, WindowsMsiInstaller, PortableDirInstaller (Windows zip + Linux tarball), AppImageInstaller, NotifyOnlyInstaller (brew, deb/rpm, Flatpak), FlatpakPortalInstaller.
    • The helper-script writer.
    • All of it goes behind a port the shared UI calls, so Android (which updates through stores) gets a no-op.
  • commonsUI. An update banner and a Settings › App › Updates section (toggle, "Check now", the current channel shown as text, so users understand why it's "notify only"). Strings go in commonsUI compose resources. Separately, the shared Software Apps screen filters by hashtag and follows but not by platform (commons/…/relayClient/softwareapps/), so on desktop it lists Android-only apps. Worth adding a #f filter once desktop builds are published.
  • Release workflow. -u + .zsync for the AppImage, Authenticode for the MSI/zip exe, publishing of the NIP-82 asset events (hashes come from dist/* in the same job).

Phases

  1. Verify assumptions on real machines:
    • an in-app-downloaded DMG/MSI carries no quarantine attribute / MOTW;
    • msiexec /i over the installed MSI upgrades in place and keeps the shortcut/AUMID;
    • renaming over a running AppImage is safe;
    • the Windows zip swap works with Defender scanning.
  2. Notify only, everywhere: channel detection + GitHubReleaseSource + banner. Small and low-risk; it already fixes "users never learn there's an update".
  3. Signed manifest: publish NIP-82 asset events per release, add NostrReleaseSource, pin the key.
  4. Unprivileged self-replace: AppImage, Linux tarball, Windows zip. Add -u/zsync to the AppImage build.
  5. MSI with the UAC helper. Add Authenticode signing alongside.
  6. DMG in-house installer (Sparkle only if this stalls).
  7. apt/dnf repositories for the .deb/.rpm. This is release infrastructure, not app code.

Open questions for the maintainer

  • Signing the per-release NIP-82 asset events: by hand after each release, via a bunker from CI, or with a dedicated release key that the app pins?
  • Desktop NIP-82 app id: is com.vitorpamplona.amethyst.desktop fine (see Event budget)? Publish the .deb/.rpm assets too (13 events per release instead of 9)?
  • Is Authenticode signing for Windows in budget (Azure Trusted Signing is paid monthly; SignPath has an OSS program)?
  • Should we host apt/dnf repos, or keep .deb/.rpm as one-shot downloads with notify-only updates?
  • Should updates install automatically by default, or only notify by default? Nostr users tend to want control. Suggest: notify by default, auto-install opt-in.

Implementation

Built on 2026-10-15, following the recommendations above. Decisions taken where the study left a question open:

  • App id: com.vitorpamplona.amethyst.desktop.
  • Signing: by hand, with Amethyst's nsec, through amy releases publish (RELEASE_OPS.md § Nostr releases). No key in CI. The same run also publishes the APK's events for Zapstore, tag for tag as zsp did, and the Flatpak's for Orchard, so one command covers all three NIP-82 readers.
  • Orchard: the events name the GitHub Release's .flatpak bundles by URL and SHA-256, which Orchard installs since 0.2.0 (2026-10-15). Orchard 0.1.x needed an OCI image on a registry, which 1.19.0 had (orchard-publish, ghcr); with bundles there is no registry, no image build and no third-party tool in CI.
  • .deb and .rpm assets: published too (12 assets per release), for the PackageKit hand-off.
  • Default: the app checks on its own (at start and twice a day) and asks before it installs anything. Settings › App › Updates turns the checks off.

Where the code is

Piece Where
Release lookup, trust rules, state machine, settings commons/…/appupdate/ (Nip82UpdateSource, Nip82UpdateFinder, AppUpdateManager, AppUpdateSettingsStore)
Release events and asset names (shared with amy) commons/…/appupdate/DesktopReleaseEvents.kt, DesktopRelease.kt
Prompt and Settings › App › Updates commonsUI/…/appupdate/ui/ (AppUpdateHost, AppUpdateSettingsSection), wired through AppRoot.appUpdates
Install channel, downloader, installers, helper scripts, GitHub fallback desktopApp/…/desktop/update/
Publishing cli/…/commands/ReleasesCommands.kt (amy releases publish)
Zapstore and Orchard events, APK and bundle checks commons/…/appupdate/ (AndroidReleaseEvents, FlatpakReleaseEvents, FlatpakRelease, ReleaseBundles, ApkInspector)
AppImage zsync createReleaseAppImage (`-u gh-releases-zsync

How each part behaves:

  • Trust. The app installs only from a kind 30063 release signed by DesktopRelease.PUBLISHER, for the desktop app id, on the main (or stable) channel, with the host's platform. Its file must be one of the release's own e-tagged assets, signed by the same key, with an https URL and a SHA-256. The download is checked against that hash and size before anything moves. The GitHub API only produces a "new version, open the download page" notice, never an install.

  • Prereleases. amy publishes a prerelease version on the beta channel, which the app ignores.

  • Applying.

    • AppImage: renamed over the running file, then relaunched.
    • Windows zip and Linux tarball: unpacked next to the app folder; after the app quits, a helper script swaps the folders and puts the old one back if the move fails.
    • DMG: codesign --verify --deep --strict and the same Team ID as the running app, then the same swap.
    • MSI: msiexec with UAC after the app quits.
    • .deb/.rpm: pkcon install-local after the app quits, or the desktop's software installer when PackageKit is missing.

    Every helper relaunches the app whether or not the update went in.

  • Notify only. Homebrew (with the brew upgrade command), Flatpak (with flatpak update), a translocated macOS app (move it to Applications), and any install the user cannot write to (the download page).

How it was checked

  • Unit tests (46 tests).
    • commons (24): release selection, including forged, foreign, unnamed and http assets, channels, platforms and prereleases; the state machine; asset names.
    • desktopApp (22): channel detection per OS and installer, the downloader against a local HTTP server (hash, size and network failures), zip-slip, and the Unix helper run for real (it waits for a process, swaps the folders and relaunches, and rolls back when the new folder is missing).
  • End to end (UpdateEndToEndTest, 1 more test): signed events on an in-process relay, the updater finding them, downloading, verifying, staging and swapping an AppImage.
  • amy against a local amy serve relay: 13 events published (12 assets and the release); a #i + #f query returns the release and the right asset.
  • The desktop app, headless: with -Damethyst.update.devCheck=true -Damethyst.update.pretendVersion=1.0.0, the app queried the relays (no signed release exists yet), fell back to GitHub, showed the "Amethyst 1.19.0 is available" prompt and the Settings › App › Updates section, and kept the auto-check switch.
  • appimagetool 1.9.0 writes the .zsync only when zsyncmake is installed, and points it at the AppImage's file name. That is why the build now names the AppImage as the release asset, and why CI installs zsync.

Left to do

  • Try it on real installs: the DMG (Gatekeeper, writing to /Applications, quarantine), the MSI (UAC, the upgrade keeping the Start menu shortcut), the Windows zip under Defender, and a .deb through PackageKit. The phase 0 assumptions above are still unverified on those systems.
  • Publish the first release events for the next stable tag (--listing the first time).
  • Authenticode signing for the MSI and the Windows launcher.
  • Signed apt/dnf repositories, which would make the .deb/.rpm updates the system's job.
  • A hosted Flatpak repo (--repo-url) for the GitHub bundle, or pointing those users at Orchard.