DOCS LLMs

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
  1. 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.
  2. LicenseSeat signs and serves. The private key never leaves the server; signing happens at finalize, not at upload.
  3. 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_base64 is 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.
  • sha512 is 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.digest needs 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 .app carries com.apple.provenance extended attributes on every file. macOS tar stores 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 cleanlytar -tzf shows 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 -tvf check 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:

  1. DNS record for updates.yourapp.com at your DNS provider. On Cloudflare: an A record to 192.0.2.1 with the proxy enabled — the address is a documentation placeholder (RFC 5737); the proxy answers, not the address.
  2. Redirect rule (Cloudflare: Rules → Redirect Rules): requests matching updates.yourapp.com/* → dynamic target concat("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.
  3. 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.json is 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.gz publishes under darwin-aarch64-app. If your client looks for darwin-aarch64 and the feed only carries -app (or vice versa), it silently finds no update. The log line to look for is Searching for updater target '<key>' in release data.
  • pubkey is the base64-wrapped form shown in the dashboard, not the raw minisign .pub document. 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.


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/linux pin 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 do curl and 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=beta works too. Channels are stable, 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 licensed feed 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: Bearer or ?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 updates entitlement.

Two rules worth internalising:

  1. 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.
  2. 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 updates entitlement, or one with no expiry → every release, forever.
  • An updates entitlement 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:

  1. 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.
  2. 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.
  3. Never generate a signing key for a product that already has shipped users. Ask; import instead. This is irreversible in the way that matters.
  4. 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.
  5. 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