Shipping Desktop Updates
Shipping Desktop Updates
How to take a desktop app from "I have a build on my laptop" to "my users' apps update themselves", using LicenseSeat Distribution.
This guide is written to be executed — by a person or by a coding agent — and it is deliberately specific. Every pitfall in it is one that actually happened while shipping a real app, and several of them produce a perfectly valid release that no client can install: correct digest, valid signature, correct feed, dead update. Those are the expensive ones, so they are called out where you would hit them rather than in a footnote.
Companion reading: The Agentic Integration Playbook covers the licensing model and the wire contracts; this guide is the artifact and updater half.
The Mental Model
Three things have to line up, and they are owned by different parties:
YOUR BUILD LICENSESEAT YOUR APP
┌───────────┐ ┌─────────────┐ ┌────────────┐
│ .app.tar │─upload──▶│ verifies │ │ updater │
│ .dmg .exe │ │ digest │ │ reads feed │
│ .AppImage │ │ SIGNS it ───┼──feed───▶│ verifies │
└───────────┘ │ serves feed │ │ signature │
▲ └─────────────┘ │ installs │
│ │ └────────────┘
you package we hold the ▲
the bytes private key │
you embedded the
PUBLIC key at build time
- You package the bytes. LicenseSeat never repackages; what you upload is what your users download, byte for byte. Packaging mistakes are therefore yours to make and yours to prevent — see Packaging, which is where the worst failures live.
- LicenseSeat signs and serves. The private key never leaves the server; signing happens at finalize, not at upload.
- Your app verifies. It carries the public key, compiled in at build time. That is the whole security property: a build your users accept is one your LicenseSeat account signed.
The consequence that catches people: the public key must be embedded before you ship version 1. Change it later and every already-installed copy refuses every future update, permanently. There is no recovery except telling users to download a fresh installer manually.
The Publish Pipeline
Four calls, in order. The engine API is keyed by version (the dashboard's upload form performs the same sequence through its own controllers, layering an extra macOS installability check on top — see below):
1. POST /api/v1/products/:slug/releases → draft release
2. POST …/releases/:version/artifacts → presigned PUT
3. PUT <presigned url> (the bytes) → storage
4. POST …/releases/:version/artifacts/:id/finalize → verify + SIGN
5. POST …/releases/:version/publish → feeds go live
Step 2 — registering the artifact
You declare the bytes before you send them:
{
"filename": "MyApp-2.0.1-aarch64.app.tar.gz",
"byte_size": 18409016,
"content_type": "application/octet-stream",
"md5_base64": "…",
"sha512": "…"
}
md5_base64is mandatory — the presigned PUT is Content-MD5 bound, so storage itself rejects bytes that do not match. You cannot upload something other than what you declared.sha512is optional but strongly recommended. It is what finalize re-verifies server-side after re-streaming the object. Browsers cannot hash files above roughly 512 MB in one pass (crypto.subtle.digestneeds the whole buffer in memory), so a browser client may legitimately omit it; a CLI or agent has no such excuse and should always send it.- Registration is idempotent-ish in practice: a failed upload can reuse the same draft release and registered artifact. Do not create a new release to retry, or you will trip the version-uniqueness validation.
Step 4 — finalize is where signing happens
Finalize re-streams the stored object, recomputes the digest, compares it to what you declared, and only then signs with the product's active key. A mismatch fails the release rather than publishing something unverified.
This means: an artifact finalized while no signing key is active is stored, served, and completely unsigned. No error, no warning in the feed. Every updater that verifies signatures will refuse it, and you will find out from user reports. Generate or import your key before your first finalize.
Packaging
The single largest source of "everything looks right and nothing works".
Filename drives platform detection
Platform, architecture and kind are inferred from the filename. Get it right and you never think about it again:
| Filename | platform | arch | kind |
|---|---|---|---|
MyApp-2.0.1-aarch64.app.tar.gz |
macos | aarch64 | app_tar_gz |
MyApp-2.0.1-x64.dmg |
macos | x86_64 | dmg |
MyApp-Setup-2.0.1.exe |
windows | x86_64 | nsis |
MyApp_2.0.1_amd64.AppImage |
linux | x86_64 | appimage |
Ambiguous containers (.zip especially) may need an explicit platform
override. If a published release shows platform: any while its artifacts
detected fine, that is the release row, not a detection failure — releases
span platforms by design.
macOS: strip extended attributes, or ship a broken update
This will bite you. A freshly built
.appcarriescom.apple.provenanceextended attributes on every file. macOStarstores those as PAX records. Tauri's updater unpacks with a Rust tar implementation that tries to materialise them as AppleDouble files and dies with:failed to unpack `._MyApp.app` into `/var/folders/…/T/tauri_updated_appXXXX/`The archive lists cleanly —
tar -tzfshows no._entries — because the attributes are metadata records, not files. The digest matches. The signature verifies. The feed is perfect. And the update fails on every machine.
Package macOS bundles like this:
xattr -cr MyApp.app # strip provenance/quarantine
COPYFILE_DISABLE=1 tar --no-xattrs -czf \
MyApp-2.0.1-aarch64.app.tar.gz MyApp.app # no AppleDouble, no PAX xattrs
The dashboard publish flow refuses these archives at finalize, before signing or publishing, and the error carries the repackaging command. You will find out in ten seconds rather than from support mail. The engine API's finalize does not run this inspection — if you publish via the API (which agents should), the pre-upload
tar -tvfcheck above is your mandatory gate, not a nicety. Treat the dashboard check as a safety net rather than a substitute for packaging correctly: it inspects the archive's opening entries, which is where the defect always appears in practice, and it deliberately stays silent when it cannot read an archive — a storage hiccup must never block a valid release.
Verify before uploading — the check costs a second and the failure costs a release:
tar -tvf MyApp-2.0.1-aarch64.app.tar.gz | grep -i "\._" && echo "BAD: AppleDouble entries"
xattr -lr MyApp.app | head # should print nothing
The archive layout matters
For macOS, the .app bundle must be at the root of the archive, not
nested in a directory:
MyApp.app/ ✅
MyApp.app/Contents/…
release/MyApp.app/ ❌ the updater will not find it
Wiring Your Updater
Configure one, ignore the rest. LicenseSeat generates every feed regardless, but you only need the one your app reads.
First: put the feed behind a domain you own
The feed URL you are about to configure gets compiled into every copy of your app you ever ship. Whatever domain that URL names is the domain those copies are married to, forever — there is no fixing it later for versions already installed. So before wiring anything, spend five minutes making that URL yours:
- DNS record for
updates.yourapp.comat your DNS provider. On Cloudflare: anArecord to192.0.2.1with the proxy enabled — the address is a documentation placeholder (RFC 5737); the proxy answers, not the address. - Redirect rule (Cloudflare: Rules → Redirect Rules): requests matching
updates.yourapp.com/*→ dynamic targetconcat("https://licenseseat.com/p/<product-slug>", substring(http.request.uri.path, 0)), status 308, preserve query string. Any CDN or reverse proxy that can issue a path-preserving 308 works the same way. - Verify the chain before you ship — a redirect that drops the path or the query string fails silently as "no update available":
curl -sIL "https://updates.yourapp.com/tauri/latest.json?channel=beta" | grep -E "HTTP|location"
# expect: 308 → licenseseat.com/p/<product-slug>/tauri/latest.json?channel=beta → 200/204
Then use https://updates.yourapp.com/… everywhere the sections below say
https://licenseseat.com/p/<product-slug>/….
If the same subdomain serves anything else, scope the rule. A blanket
/* redirect owns the entire hostname — the moment that host also serves a
fallback bucket, a status page, or any static object, the redirect silently
captures those paths too, and the failure hides until the day the fallback
is actually needed. Use a custom filter expression instead of the wildcard
template, excluding the paths the host serves directly:
http.host eq "updates.yourapp.com"
and not starts_with(http.request.uri.path, "/fallback/")
with the same dynamic 308 target and query-string preservation. The exclusion list and whatever writes to those paths must move together — a prefix added on one side and not the other is this exact failure again.
Why we tell you this even though it loosens your coupling to us: if you ever
move providers — including away from LicenseSeat — you re-point one DNS
record and every installed copy follows. Skip it and your update path is
hostage to a hostname you don't control. Updaters follow redirects
(Tauri/reqwest, Sparkle, electron-updater all do), and the pattern runs in
production today: updates.clipbasket.com is exactly this recipe, and it
carried a real publish → verify → install → relaunch cycle through the
redirect, query strings intact, before this section was written.
Tauri
"plugins": {
"updater": {
"endpoints": ["https://licenseseat.com/p/<product-slug>/tauri/latest.json"],
"pubkey": "<the base64 pubkey from your dashboard>"
}
}
- Static vs dynamic.
…/tauri/latest.jsonis a static manifest listing every platform. The dynamic form,…/tauri/{{target}}/{{arch}}/{{current_version}}, lets the server decide: 200 with an update, 204 when the client is already current. Prefer dynamic if you want the "no update" case to cost nothing. - Target keys must match. A macOS arm64
.app.tar.gzpublishes underdarwin-aarch64-app. If your client looks fordarwin-aarch64and the feed only carries-app(or vice versa), it silently finds no update. The log line to look for isSearching for updater target '<key>' in release data. pubkeyis the base64-wrapped form shown in the dashboard, not the raw minisign.pubdocument. They are different encodings of the same key and mixing them up produces a verification failure that reads like corruption.
Sparkle
<key>SUFeedURL</key>
<string>https://licenseseat.com/p/<product-slug>/appcast.xml</string>
<key>SUPublicEDKey</key>
<string><the Ed25519 public key from your dashboard></string>
If no Sparkle key is active when you finalize, the appcast item carries no
sparkle:edSignature — and a Sparkle app configured with SUPublicEDKey
refuses unsigned items. The feed will look fine to you. Check for the attribute
explicitly:
curl -s https://licenseseat.com/p/<slug>/appcast.xml | grep -c edSignature # must be ≥ 1
electron-builder
publish:
provider: generic
url: https://licenseseat.com/p/<product-slug>/electron
electron-updater verifies a SHA-512 from latest.yml rather than a
signature, so there is no key to manage — and no signing key to forget.
Anything else
curl https://licenseseat.com/p/<product-slug>/latest
Returns version, download URL and SHA-512. Enough to roll your own check in any language.
The Download Link on Your Website
Never link a versioned artifact URL from your website. The canonical
/d/<artifact-id>/<filename> URLs that appear in feeds point at one specific
upload — hard-code one into a download button and it is stale the moment you
publish the next version, handing every new visitor an app whose first act is
asking to update itself.
Link the permalink instead:
https://licenseseat.com/p/<product-slug>/download
Every click 302s to the newest installable artifact, so the button you wire today still serves whatever version is current years from now.
- Platform.
…/download/macos,…/download/windows,…/download/linuxpin it. The bare URL decides from the visitor's browser; phones deliberately match nothing (an Android UA says "Linux", an iPhone says "Mac OS X") and fall back to the overall best artifact, as docurland scripts — pin the platform in anything automated. - Channel. A path segment after the platform:
…/download/beta,…/download/macos/beta(order is platform, then channel; a lone segment is unambiguous because the vocabularies do not overlap).?channel=betaworks too. Channels arestable,beta,alpha, and follow the same hierarchy as the feeds: a beta subscriber also sees stable releases, and whichever is newest wins. - Architecture.
?arch=aarch64, with the spellings people actually type (arm64,x64,amd64) accepted. Without it, universal builds win, then the most compatible arch. An arch nothing satisfies is a 404; an arch we do not recognize is ignored rather than breaking the link. - What it picks. The thing a person should download, not the thing an updater should: the DMG over the Sparkle zip, the setup exe over the MSI, the AppImage over deb/rpm — and never deltas or blockmaps. A release that shipped only updater artifacts falls through to the newest release with a real installer.
- Gating. On a
licensedfeed policy this endpoint returns a real 403 to anonymous requests (it serves people, not update clients, so no empty-feed politeness). A request carrying a license key (Authorization: Beareror?license_key=) downloads the newest version that license is entitled to — a lapsed maintenance window serves the last entitled release, same as the feeds.
Downloads through the permalink are counted and attributed in the dashboard's download analytics exactly like feed-driven downloads.
Signing Keys
What to tell your users' future selves: every build is signed with a private key only LicenseSeat holds; the public half lives in your app and lets it prove an update came from you and was not modified in transit.
Operational rules that matter more than the cryptography:
- Secret material is shown exactly once, at generation. There is no re-download endpoint, deliberately: a show-once guarantee with a permanent re-download route one page over is not a guarantee. If you lose it, retire the key, generate a new one, and re-embed the new public key — which only helps versions you ship after that point.
- Migrating an app that already has users? Import, do not generate. Copies already installed only accept updates signed with the key they were built against. Generating a fresh key strands every one of them permanently. The dashboard has an import path for exactly this.
- Retiring a key does not break installed apps — they keep verifying with the key they carry. It breaks future releases until you generate or import a replacement.
- One key per purpose per product. Sparkle uses Ed25519, Tauri uses minisign; they are not interchangeable.
Update Feed Visibility
Two policies, and the choice is about who may download, not about tiers:
- Open — anyone can see and download updates. What most apps do.
- Licensed — feeds are empty unless the request carries a valid license
key; lapsed licenses are windowed to their
updatesentitlement.
Two rules worth internalising:
- Never gate reads on billing state. A seller who downgrades should stop uploading; their already-shipped users must keep updating. Punishing end users for their vendor's plan choice turns into your reputation problem.
- Licensed feeds change your support load. Every update failure becomes "is my license OK?" Choose it when piracy actually costs you, not by default.
Maintenance windows: updates that lapse without punishing anyone
On a licensed feed, a license's access to versions is bounded by its
updates entitlement:
- No
updatesentitlement, or one with no expiry → every release, forever. - An
updatesentitlement with an expiry → only releases published before that instant. Publication time, exclusive — a release published at the exact expiry does not qualify.
A lapsed customer keeps what they paid for: their feed still serves the last entitled version (never an error state), the download permalink serves the newest entitled release, and download-authorization tokens re-check the cutoff at download time, so a token minted before a lapse cannot outlive it. Renewals just move the expiry — nothing else to migrate. This is the "perpetual license + one year of updates" model, and one plan entitlement is the whole implementation:
{ "key": "updates", "duration_type": "fixed", "duration": "1.year" }
Version ceilings: the paid-major-upgrade model
Beside the time window, the updates entitlement can carry a version
ceiling — the "this license receives all 3.x" model as data the platform
enforces, instead of a check every app reimplements:
{ "key": "updates", "duration_type": "perpetual", "below_version": "4.0.0" }
The ceiling is exclusive and compares core versions: below_version: 4.0.0 covers every 3.x release including 3.9.0-beta.1, and does NOT
cover 4.0.0 — or 4.0.0-beta.1, because nobody selling "all 3.x updates"
means betas of the next major. It is enforced everywhere the time window is:
licensed feeds, the download permalink, gated downloads, and download
tokens (revalidated live at redemption). A 3.x-only license is never
offered 4.0 — which its app would otherwise install and then refuse to
run. Both bounds compose: a license can have a year of updates AND a 4.0.0
ceiling. Rules worth knowing: releases without parseable semver are never
served to a version-bounded license (fail closed), the ceiling must be a
plain x.y.z (prerelease ceilings are rejected at write time), and it only
exists on the updates entitlement.
The ceiling also gates activation and validation — whenever the
requesting app declares what version it is. Send software_version on
activate or validate and a license bounded below that version refuses:
activation fails with version_not_entitled (403), validation answers
valid: false, code: "version_not_entitled" in its normal 200 envelope,
and both are audited. Two details worth knowing: the official SDKs already
declare a version implicitly — the telemetry envelope's app_version rides
every licensing call when telemetry is enabled — and declared versions
parse leniently (3.0 counts as 3.0.0; a version string we cannot parse
skips the gate rather than bricking the app, because this is honest-client
gating, not DRM).
The principle that makes upgrade flows work: the key proves who you are; entitlements say what you get. A request that declares no version — your website's key-check, an upgrade-discount flow, an old client — still validates: proving ownership is exactly what those flows need, and the gate only ever fires against a version the app itself declared.
Grandfathering: existing customers into a new major, no reissue
When you ship 4.0 and want some or all 3.x customers to have it free, do
not issue new keys — the key is the customer's identity, and activations,
machine files, and history hang off it. Raise their ceiling instead: each
plan page's "Version coverage" card shows what new licenses will get
and what the existing fleet actually has, and applies a ceiling to the
existing licenses (with an optional "only purchases since" date, so a
launch grace window is one click). By default the apply only ever
raises ceilings — backed by
Grandfathering.raise_updates_ceiling!: unbounded licenses are never
touched, re-running is a no-op, every change is audited, so the default
cannot take anything away from anyone.
The same card also handles the opposite move — a paid discontinuation
("existing keys are for 2.x; 3.x needs a new purchase"): checking allow
reducing imposes the ceiling exactly on every selected license, including
unbounded ones, via Grandfathering.set_updates_ceiling!. Deliberately an
explicit checkbox with its own warning: this is the direction that takes
access away. Expiries are preserved and every change carries its previous
value in the audit trail either way.
Verifying It Actually Works
Do not trust the dashboard's word, or this guide's. The following sequence proves the whole chain with independent tools, and an agent should run it after the first publish of any product.
BASE=https://licenseseat.com/p/<product-slug>
# 1. The feed serves the version you published
curl -s "$BASE/tauri/latest.json" | jq '.version'
# 2. The advertised bytes are the bytes you uploaded
URL=$(curl -s "$BASE/tauri/latest.json" | jq -r '.platforms["darwin-aarch64-app"].url')
curl -sL "$URL" -o served.tar.gz
cmp served.tar.gz MyApp-2.0.1-aarch64.app.tar.gz && echo "byte-identical"
# 3. The signature verifies with a tool that is not ours
curl -s "$BASE/tauri/latest.json" | jq -r '.platforms["darwin-aarch64-app"].signature' \
| base64 -d > update.minisig
minisign -V -p product.pub -x update.minisig -m served.tar.gz
# 4. Version semantics: an old client is offered the update, a current one is not
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/tauri/darwin/aarch64/1.0.0" # 200
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/tauri/darwin/aarch64/2.0.1" # 204
Then do the thing none of that proves: run the previous version and let it update itself. Feed correctness and installability are different properties, and only the second one is what your users experience.
Failure Signatures
What you will actually see, and what it means:
| Symptom | Cause |
|---|---|
failed to unpack '._MyApp.app' |
macOS xattrs stored as PAX records — repackage with xattr -cr + --no-xattrs |
Dashboard finalize returns 422 mentioning xattr -cr |
The same defect, caught at upload instead of on your users' machines (dashboard flow only — API finalize does not inspect archives). Repackage and re-upload; nothing was signed or published |
StartingBinary found current_exe() that contains a symlink |
App installed under a symlinked path (/tmp → /private/tmp). Install to a real path |
| Updater finds nothing, feed looks correct | Target key mismatch (darwin-aarch64 vs darwin-aarch64-app); check the "Searching for updater target" log line |
| Updater finds nothing through a custom domain, direct feed URL works | The redirect drops the path or the query string (a plain CNAME cannot rewrite paths; a static redirect target loses ?channel=). Re-test with curl -sIL and a query string |
| Sparkle refuses the update | No sparkle:edSignature — no Sparkle key was active at finalize |
| Signature verification fails | Wrong key encoding (base64 config form vs .pub document), or the app was built before the key existed |
| Update check never runs on relaunch | Client-side throttle persisted to disk (e.g. update-checks.json); clear it or wait out the interval |
| Release build refuses to compile | Release guards demand production signing config and HTTPS endpoints — by design; use a debug build for local testing |
| Storage rejects the PUT | The bytes changed after you computed the MD5, or you retried with a stale presigned URL |
| Upload rejected with a quota message | Storage or artifact-size limit; the dashboard's JSON error carries an upgrade_url when the refusal is resolvable (the API's 403 distribution_quota_exceeded carries the reason string only) |
Testing Locally
You will want to prove the loop before pointing real users at it.
- Build a debug bundle, not a release one. Release builds are fenced: they require production signing configuration and reject plain-HTTP endpoints. That guard is doing its job — do not weaken it. A debug build runs the same updater and verifies signatures identically.
- Point the build at your local server by overlaying the updater config at build time rather than editing committed files.
- Install to a real path, not
/tmp(see the symlink failure above). - Clear the update-check state between runs, or the client's throttle will make it look like nothing is happening.
- Use a throwaway product for the rehearsal. Test releases in the product your customers actually update from will be visible to them.
For Coding Agents
If you are automating this end to end, the failure modes above are not theoretical — they are what separates "the API returned 200" from "the user's app updated". Concretely:
- Verify installability, not just publishability. Every API call can
succeed while producing an update nothing can install. The packaging check
(
tar -tvf | grep '\._') and a real install are the only proofs. - Check for a signing key before finalize, and refuse to proceed without one unless the framework is digest-based. An unsigned first release is the most expensive silent failure here.
- Never generate a signing key for a product that already has shipped users. Ask; import instead. This is irreversible in the way that matters.
- Treat the public key as immutable after first ship. If a task looks like it needs a key change, stop and surface it — the blast radius is every installed copy.
- Report what you verified and what you did not. "Feed serves 2.0.1 and
the signature verifies with
minisign" is a claim; "updates work" is not, unless you watched an app update itself.
Next Steps
- The Agentic Integration Playbook — the licensing model, wire contracts, and the tier funnel
- Offline Licensing — keeping apps working without a connection
- Download Tokens — license-gated download URLs