## 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.
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_KEYandTAURI_SIGNING_PRIVATE_KEY_PASSWORD(only thetauri buildstep, never dependency installation or upload steps). Prebuild runs earlier without them;src-tauri/tauri.prebuilt.conf.jsonreplaces thebeforeBuildCommandhook so JavaScript builds do not rerun with the key. Cargo build scripts insidetauri buildstill 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.rstests feed resolution, stable/preview selection, same-day previews, publication order, drafts and repository-bound URLs.desktop_updater_tests.rsdownloads 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.rstests stop a child running from a temporary mount while sparing one running elsewhere;appimage_environment.rstests 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 inreleases/dev-release-monitor.ts, never fromCODENOMAD_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 completelatest.jsonexists: 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.