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

238 lines
15 KiB
Markdown

# 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):
```text
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.