CodeNomad/dev-docs/TAURI_UPDATER.md
Pascal André 87ae7050c6
fix(tauri): ship a single desktop entry in the Linux AppImage (#1179)
## Problem

Every Tauri Linux build since 2026-10-28 fails in "Package and verify
Tauri AppImage" with `Expected exactly one desktop entry in the Tauri
AppImage, found 2`. This blocks dev pre-releases and releases.

## Cause

Tauri's AppImage bundler copies `bundle.linux.appimage.files` into
`usr/share/applications` next to its generated `CodeNomad.desktop`,
symlinks `CodeNomad.desktop` at the AppDir root, then runs linuxdeploy
without a desktop-file argument (tauri-bundler `linuxdeploy.rs`).
linuxdeploy logs `No desktop file specified, using first desktop file
found` and deploys whichever entry the directory listing returns first.
When that is the hidden alias `ai.neuralnomads.codenomad.client.desktop`
(`NoDisplay=true`, added in #334), it lands at the root beside Tauri's
entry. The 2026-10-27 build picked `CodeNomad.desktop`; today's builds
pick the alias. No repository change between them touches packaging.

## Fix

- Remove the alias from `bundle.linux.appimage.files` only. Inside an
AppImage, files under `usr/share/applications` are not registered with
the desktop; integration tools install the root entry. deb/rpm keep the
alias for GTK app id association.
- Document the constraint in `dev-docs/TAURI_UPDATER.md`.

## Validation

- This PR's Tauri Linux packaging job extracts the AppImage and requires
exactly one root desktop entry.
2026-10-28 17:41:19 +01:00

15 KiB

Tauri updates and Linux AppImage distribution

Installation contract

The native Get Updates menu, the required-upgrade notification and the preview release notification share stores/desktop-updates.ts. A check offers an explicit Install and restart action. The renderer supplies only the saved update feed and later the version it accepted; Rust selects the release, retains the checked native Update and owns every URL and the verification key. Each new check or install replaces the previous update notification so stale install offers do not remain actionable after a failure or changed check result.

Update feeds follow the server notification rules (server.updateFeed, else derived from the installed build label), implemented natively in update_releases.rs from the GitHub releases list:

Feed Offered release
stable GitHub's latest final release (/releases/latest, never displaced by prereleases) when it has a latest.json and a higher version
preview Newest publication of any kind (dev prerelease or release) with a latest.json, newer by publication than the installed release

Preview labels reuse their base version, so SemVer alone can rank a newer preview below an older one; publication order decides, and builds unknown to GitHub fall back to version order as on the server. Only the selected release's manifest is fetched, and it must announce exactly the selected version. Releases without a manifest (still building, partially failed or older than the updater) are never offered, so a release in progress cannot break checks.

The native menu emits menu:action to the focused local webview only, and the renderer listens on its own webview: untargeted Tauri listeners receive every window's events. When Preferences or a remote window is focused, or no local window remains, Get Updates opens the release page. Renderer fallbacks use the saved feed (stable: the latest release; preview, or a feed that could not be read: the full releases list, which contains every offered release). The native fallback and About link cannot read the saved feed and derive it from the installed build label.

When a notification's check finds nothing installable, it opens that notification's release page rather than reporting the application as current.

Supported installed formats:

Target Updater payload
Windows x64 NSIS Signed .exe
macOS x64 Signed .app.tar.gz
macOS arm64 Signed .app.tar.gz
Linux x64 AppImage Signed .AppImage

Debian/RPM packages, loose Windows executables and builds without an embedded public key open the release page. Linux requires both the native AppImage bundle marker and a nonempty APPIMAGE path; APPRUN alone is insufficient.

desktop_updater.rs authorizes native callers against local application origins and serializes checks/downloads across windows. A check while another window checks or installs returns busy, shown as information rather than a failure (a notification still opens its release page). Only the three guarded application commands (check, install, take failure) are granted to local webviews; never grant updater/process plugin permissions to previews, remote windows or renderers generally.

The plugin verifies the complete download before shutdown admission. A prepared installer and restart intent enter the shutdown coordinator under one lock. Every renderer must flush and the owned CodeNomad backend must stop before installation. A timeout or cleanup failure discards the pending installer; a late acknowledgement or later ordinary quit cannot replay it. Windows logout discards a waiting update. Installation never stops the shared OpenCode daemon.

On macOS/Linux, successful installation follows the normal native restart. On Windows, the NSIS installer performs relaunch and the plugin exits. The vendored 2.12.0 plugin runs its exit callback only after a successful ShellExecuteW, so a refused launch keeps client-state persistence ownership. On an installation error, the application restores its owned backend without replaying installation. That recovery reloads every window, so failures are recorded natively with the webview whose install was accepted. That webview's document takes and reports it, including after a reload; once that webview is closed, any local document takes it. Download failures are recorded and emitted too, since the caller may reload or close during a long download; the live caller reports each failure once, whether its rejected request or the event arrives first. Failures without a reload (flush timeout, cleanup failure, refused backend recovery) are also emitted to live documents; another document merely checking cannot take them. Notification history retains install actions, so an offer from an earlier check starts a fresh check instead of installing.

The loading, sign-in (/login, /auth/*) and pairing documents have no menu listener: Get Updates opens the release page while the focused local window shows one of them. Every other server path serves the application, whose listener registers when it mounts. See packages/tauri-app/vendor/README.md for provenance.

Build and publication

configure-updater.mjs writes a Tauri V2 plugins.updater entry and bundle.createUpdaterArtifacts: true when TAURI_UPDATER_PUBKEY contains a minisign public key, with requireSignedVersion: the manifest itself is unsigned, so the signed trusted comment must carry the announced version and an older signed artifact cannot be relabelled. It configures no endpoint; the application selects the manifest per feed. Absent keys remove updater configuration and explicitly disable artifact signing. The native host registers the plugin only when that configuration exists; the plugin rejects a missing configuration during initialization, before any check.

Release and dev prerelease uploads use these repository secrets in all four platform jobs:

  • TAURI_UPDATER_PUBKEY (job environment; public)
  • TAURI_SIGNING_PRIVATE_KEY and TAURI_SIGNING_PRIVATE_KEY_PASSWORD (only the tauri build step, never dependency installation or upload steps). Prebuild runs earlier without them; src-tauri/tauri.prebuilt.conf.json replaces the beforeBuildCommand hook so JavaScript builds do not rerun with the key. Cargo build scripts inside tauri build still run in that environment.

PR builds receive none of these secrets. Never embed a test public key in a production release.

updater-artifacts.mjs stage finds exactly one native updater payload, verifies its minisign data and trusted-comment signatures, requires the trusted comment to record the release version, copies it to the final release asset name, and writes a per-platform receipt. The receipt contains the verified signature, the public key fingerprint and the payload digest, never private key material. The signature is published only inside latest.json, which is where the updater reads it; staging removes any separate .sig asset from a rebuilt tag.

The final publication job waits for all four platform jobs, merges their receipts and checks the uploaded GitHub asset digests. Missing/duplicate targets, mixed versions or keys, partial signing and missing or changed payloads fail publication. Only then does it generate and upload that release's latest.json; stable releases and dev prereleases each carry their own manifest. Receipt artifacts and the publication job run only for tagged upload builds. PR builds are unsigned: staging only records an unsigned receipt, and signature/manifest logic is covered there by the script tests. Before a platform replaces its signed payload on a rebuilt tag, staging deletes any existing latest.json, so no published manifest pairs new bytes with an old signature; until publication succeeds, the release is simply not installable.

Downloads have connect (30 s) and read-stall (60 s) limits but no total limit, so slow connections can still fetch large installers. Fully unsigned builds produce no update manifest. The release matrix has no Linux arm64 target.

The Linux build requests only appimage, with verbose linuxdeploy diagnostics; the Linux deb is the Electron build. The AppImage carries only Tauri's generated CodeNomad.desktop: Tauri runs linuxdeploy without a desktop-file argument, so linuxdeploy deploys whichever entry it finds first in usr/share/applications. Keep the hidden reverse-DNS alias entry (NoDisplay=true, deb/rpm only) out of bundle.linux.appimage.files; packaging rejects an image with more than one root desktop entry. The Windows NSIS installer is published directly as the download and updater payload, without a ZIP wrapper. The CLI minimum is 2.11.5, the first release that records the app version in updater signatures' trusted comments; requireSignedVersion and publication's signed-version gate depend on it.

Linux desktop resources omit msgpackr's musl-only native modules. The official bundled Node runtime is glibc-based; linuxdeploy otherwise runs ldd over the unusable musl ELF and aborts. For local rebuilds after a failed packaging run, remove the generated target/release/bundle/appimage and target/release/bundle/appimage_deb staging directories: the latter can retain removed resources between bundle attempts.

The display and GPU stack comes from the host. The Linux job's APPIMAGE_HOST_LIBRARIES (libwayland-*, libxkbcommon, libxcb*, libX11*, libXau, libXdmcp, EGL/GL/gbm/drm) reaches linuxdeploy as LINUXDEPLOY_EXCLUDED_LIBRARIES, on top of the excludelist of Tauri CLI 2.12.1 or later. A build-runner copy on AppRun's library path makes Mesa's EGL fail on newer distributions (an Ubuntu 24.04 libwayland-client with Mesa 26), and WebKit's window then stays black.

The AppRun hook leaves GDK_BACKEND to the session. Unless it is set, appimage_environment.rs makes CodeNomad's own GTK prefer X11 (XWayland) and fall back to Wayland without an X server (gdk_set_allowed_backends, on every Linux build), because the F11 fullscreen shortcut is an X11 key grab; child processes keep the session's choice. The hook also points GTK, GIO and GSettings at modules inside the mount (GIO_MODULE_DIR, GTK_PATH, a prefixed XDG_DATA_DIRS, ...). The backend, and through it the shared OpenCode service, the editors CodeNomad opens and link handlers outlive the mount, so they start without those mount paths and outside the mount. Links open through external_open.rs, including the renderer's open_external_url command; the windows hold no opener plugin permission that would bypass it, and the plugin's injected click handler is off: lib/native/external-link-clicks.ts opens anchors that ask for a new window (target="_blank", Ctrl or Shift) through openExternalUrl instead, in the application and Preferences windows and in each document a trusted same-origin SideCar frame loads; opaque preview and panel-extension frames get no opener.

Verification extracts via an absolute artifact path, checks AppRun and usr/bin/codenomad-tauri, rejects an image that bundles those host libraries, smoke-tests bundled Node/server/UI resources, resolves ELF dependencies against bundled library directories and reports the binary's glibc floor.

The AppImage runtime unmounts the bundle as soon as the main process exits, while WebKit's helper processes started from it can outlive that process briefly; a helper that then touches the vanished mount dies with SIGBUS and the desktop reports a crash. On RunEvent::Exit, including updater restarts, the AppImage host therefore stops its child processes that still run from the mount (appimage_exit.rs): each is pinned with a pidfd and checked again as our child running from the mount, then receives SIGTERM, and SIGKILL if it has not exited after 1.5 s; waiting for exits stops after two seconds in total. Children running from elsewhere, such as the shared OpenCode service, keep running.

Validation

  • Node tests exercise configuration, the four payload formats, manifest completeness, key/asset mismatch, payload/trusted-comment tampering and the signed release version.
  • update_releases.rs tests feed resolution, stable/preview selection, same-day previews, publication order, drafts and repository-bound URLs.
  • desktop_updater_tests.rs downloads a CLI-signed public-only fixture through the real updater plugin and rejects modified bytes without invoking installers. Its insecure HTTP exception exists only in the loopback mock application's config.
  • Shutdown regressions verify all-window flushing, cancellation, atomic restart admission, conflict with an existing quit and Windows logout.
  • The vendored Windows launch regression checks success-only ownership release.
  • On Linux, appimage_exit.rs tests stop a child running from a temporary mount while sparing one running elsewhere; appimage_environment.rs tests drop mount entries from path lists and keep host values. A real AppImage must quit without leaving WebKit helpers or a crash report in /var/crash.
  • Browser tests exercise the real update store and toast actions, explicit install, duplicate gestures, failure recovery (including after a reload), feed forwarding and unsigned/Electron/remote fallback.

Commands (from the checkout, unless noted):

node --test packages/tauri-app/scripts/configure-updater.test.mjs packages/tauri-app/scripts/updater-artifacts.test.mjs
npm run typecheck --workspace @codenomad/ui
cargo test --manifest-path packages/tauri-app/src-tauri/Cargo.toml --locked
# From packages/tauri-app on Windows:
cargo test --locked -p tauri-plugin-updater windows_launch_tests
# From packages/ui:
node --import tsx --test tests/browser/desktop-updates.test.ts

Signed install/relaunch trials on real artifacts are also required: track Windows, Linux and macOS trials in the PR; macOS guest access and Apple Silicon hardware are required for their respective real-device evidence.

Agent conventions

  • The update feed (server.updateFeed: stable/preview, chosen in Settings → Info → Updates) only selects offered releases. It is read on every refresh in releases/dev-release-monitor.ts, never from CODENOMAD_UPDATE_CHANNEL, and must not touch desktop data scopes, singletons, backends or OpenCode state. Preview offers use publication order (dev builds carry the previous release number) and, like the native updater, consider only releases whose complete latest.json exists: release creation precedes the builds, and the manifest is uploaded after every platform build, so a notification is never shown before the release is installable. When the installed release is older than the fetched page, the server looks it up by tag before falling back to version order, and Settings → Info never substitutes a SemVer-ranked stable release on the preview feed. The native Tauri updater follows the same feed (update_releases.rs); Electron, remote, unsigned and unsupported installations open the release page.