mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-01 11:30:33 +00:00
## 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.
238 lines
15 KiB
Markdown
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.
|