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>
34 KiB
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
- 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
commonsfinds the new version. A small per-format installer indesktopAppapplies it. - 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.
- 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 (ftag). 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. - 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).
- 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 thezaplist inpackaging/homebrew/amethyst-nostr.rb). Replacing the install tree loses nothing. - The app knows where its launcher is. jpackage sets the
jpackage.app-pathsystem 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=$appVersionis passed to the JVM.Nip82VersionComparatoralready compares NIP-82 versions. - The MSI upgrade path already works.
upgradeUuidis fixed inbuild.gradle.kts, and winget'sUpgradeBehavior: installruns 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.
Option B: NIP-82 events signed by Amethyst's key (recommended)
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)sizeversionfplatform (darwin-arm64,windows-x86_64,windows-aarch64,linux-x86_64,linux-aarch64)mmime 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:
- 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 usesfetchFirst/fetchAllfromnip01Core/relay/client/accessories. Tor routing then comes for free when the user has Tor enabled. - Picks the asset whose
m(orvariant) matches how it was installed (MSI, zip, AppImage…). - Checks that the author equals a pubkey pinned in the build.
- Verifies the Schnorr signature (Quartz already does this for every event).
- Downloads from
urland 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; seeRELEASE_OPS.md). Options:- a maintainer step after the release (
amycould gain apublish-releaseverb 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.
- a maintainer step after the release (
- App-id collisions. Zapstore owns
com.vitorpamplona.amethystand Orchard ownscom.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:
zspandorchard-publisheach 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.amethystin 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
morvariant. - Gap: the app detail screen loads
latestAssetwith 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 anoci:/docker://location,m = application/vnd.flatpak, or a.flatpakfilename, and then only on an exact host-platform match (falling back to assets with nof). - It accepts stable channels only: no channel tag,
stable, ormain(Zapstore's default name). orchard-publishaddresses 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:
iandfon every asset (what Zapstore filters on), andm(what Orchard keys on and what our updater needs).SoftwareAssetEvent.buildalready requires the app id and MIME type; the platform tag is the one to remember.- A
cchannel tag ofstableon 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:
- Check: at startup + every ~12 h, with a jittered delay. Never on the first launch.
- Ask: banner or notification, "Amethyst X.Y.Z is ready", with "Update now" / "Later" / release notes.
- Download to staging: a sibling of the install
(
<install>.new/<install>.download) so the final rename stays on one filesystem and is atomic. - Verify: hash + size from the signed manifest, plus the platform signature where one exists.
- 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:
- Download the DMG, check the hash.
- Mount it:
hdiutil attach -nobrowse -readonly -mountpoint <tmp> <dmg>. - Run
codesign --verify --deep --strict <tmp>/Amethyst.app. Compare itsTeamIdentifier(fromcodesign -dv) with the running bundle's.spctl --assess --type executeconfirms notarization. - Run
ditto <tmp>/Amethyst.app <parent>/Amethyst.app.new, then detach. - Helper: wait for the PID, then
mv Amethyst.app Amethyst.app.old && mv Amethyst.app.new Amethyst.app, thenopen Amethyst.app, thenrm -rf Amethyst.app.old.
Traps:
- Write access.
/Applicationsis writable by admin-group users, not by standard users. If<parent>isn't writable, fall back: download, verify, andopenthe DMG for the user to drag across. An elevated copy throughosascript … with administrator privilegesis possible but heavy-handed. - Translocation. An app launched from
~/Downloadswith 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.quarantineattribute. 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) inContents/Frameworks. jpackage/Compose don't do that. It needs a step betweencreateDistributableand signing, and every nested binary must be signed for notarization. - Drive
SPUStandardUpdaterControllerfrom 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)
- Download the MSI, check the hash.
- Helper (PowerShell): wait for the PID, then
Start-Process msiexec -Verb RunAs -ArgumentList '/i', '<msi>', '/passive', '/norestart' -Wait, then relaunchC:\Program Files\Amethyst\Amethyst.exe. TheRunAsverb triggers UAC because the MSI is per-machine. - The fixed
upgradeUuidmakes 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-AuthenticodeSignaturebefore running the MSI. - SmartScreen. A file the app writes itself has no Mark-of-the-Web
(
Zone.Identifierstream), so SmartScreen shouldn't intervene. To verify in phase 0. - Per-user install. jpackage's
--win-per-user-installwould 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'sScopewould 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.
- Download the zip, check the hash, extract to
<parent>\Amethyst.new. - 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 renameAmethyst→Amethyst.oldandAmethyst.new→Amethyst, launch, delete.old. Retry the rename a few times: antivirus scanners briefly hold freshly extracted files. - If the parent dir isn't writable (e.g. unzipped into
Program Filesby hand), fall back to notify + open the download page.
Linux: AppImage (x86_64 + aarch64)
In-app (recommended):
- Download the new AppImage to
$APPIMAGE.new(same dir), check the hash,chmod +x. rename($APPIMAGE.new, $APPIMAGE). This is safe while running: the running instance's squashfs mount holds the old inode, and the rename is atomic.- Relaunch via
$APPIMAGEand 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) toappimagetoolincreateReleaseAppImage. It writes the.zsyncfile 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 (checkedLICENSE.txt). AppImage docs. - Caveat:
latestmeans 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:
- Host signed apt + dnf repositories (recommended long-term). The .deb's
postinst drops
/etc/apt/sources.list.d/amethyst.listand 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 ordnf upgradekeep 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-*.shand inrpmSpec()inbuild.gradle.kts.
- Short-term. Notify, then either open the download, or call PackageKit's
D-Bus
InstallFileson the verified.deb/.rpm. PackageKit shows the polkit password prompt and works on both apt and dnf systems. If PackageKit is missing, fall back toxdg-openon 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.CreateUpdateMonitoremitsUpdateAvailable, andUpdate(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 newcommons/…/update/package.UpdateSourceinterface, withNostrReleaseSource(NIP-82) andGitHubReleaseSource(notify-only) implementations.AvailableUpdate(version, notesUrl, asset: url/sha256/size/mime/platform, trusted: Boolean).UpdateChecker: schedules checks, compares versions withNip82VersionComparator, applies downgrade/freeze rules, exposes aStateFlow<UpdateState>.- All of it is CLI-safe, so
amycould reuse it later (amy update, sinceamyships a tarball too).
desktopApp(platform shim).InstallChannelDetector.- One
UpdateInstallerper 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 incommonsUIcompose 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#ffilter once desktop builds are published.- Release workflow.
-u+.zsyncfor the AppImage, Authenticode for the MSI/zip exe, publishing of the NIP-82 asset events (hashes come fromdist/*in the same job).
Phases
- Verify assumptions on real machines:
- an in-app-downloaded DMG/MSI carries no quarantine attribute / MOTW;
msiexec /iover 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.
- Notify only, everywhere: channel detection +
GitHubReleaseSource+ banner. Small and low-risk; it already fixes "users never learn there's an update". - Signed manifest: publish NIP-82 asset events per release, add
NostrReleaseSource, pin the key. - Unprivileged self-replace: AppImage, Linux tarball, Windows zip. Add
-u/zsync to the AppImage build. - MSI with the UAC helper. Add Authenticode signing alongside.
- DMG in-house installer (Sparkle only if this stalls).
- 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.desktopfine (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 aszspdid, and the Flatpak's for Orchard, so one command covers all three NIP-82 readers. - Orchard: the events name the GitHub Release's
.flatpakbundles 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 themain(orstable) channel, with the host's platform. Its file must be one of the release's owne-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.
amypublishes a prerelease version on thebetachannel, 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 --strictand the same Team ID as the running app, then the same swap. - MSI:
msiexecwith UAC after the app quits. - .deb/.rpm:
pkcon install-localafter 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 upgradecommand), Flatpak (withflatpak 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 serverelay: 13 events published (12 assets and the release); a#i+#fquery 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
.zsynconly whenzsyncmakeis 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 installszsync.
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 (
--listingthe 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.