diff --git a/AGENT.md b/AGENT.md index 269c228..0e9993f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -108,13 +108,14 @@ Scaffolded and verified working (sequencing step 2 + a first read-only slice of - **CLI sidecar**: bundled via Tauri's `externalBin` mechanism (`bundle.externalBin: ["binaries/omnideck"]` in `tauri.conf.json`; `cli_bridge.rs` spawns it with `app.shell().sidecar("omnideck")`, never PATH). **Pinned by version + checksum as of `omnideck-dev/cli` `v0.11.0-alpha.2`** (bumped from `v0.10.0` — this tag's JSON contract `3` adds `runtime ensure` NDJSON `substage`/`status` fields and a `"permission"` state value, plus 4 new error codes, all now consumed in `bootstrap.rs`; confirmed directly by downloading the real release, cross-checking its own published `SHA256SUMS`, and running the extracted binary's `--version --json`, not assumed) — `src-tauri/binaries/vendor-manifest.json` records the tag, commit, download URLs, and archive/binary/SBOM SHA-256s for all six target triples. Run `npm run fetch:sidecars` to download+verify all six (or `node scripts/fetch-sidecars.mjs ` for just one), writing `src-tauri/binaries/omnideck-[.exe]` (gitignored — only `vendor-manifest.json` is committed). `npm run verify:sidecars` re-checksums already-fetched binaries without re-downloading. `OMNIDECK_CLI_ARCHIVE_DIR` may point at a directory of pre-downloaded release archives for an offline/sandboxed fetch; the pinned hashes are still enforced either way. `cli_bridge.rs::version()` checks both `EXPECTED_JSON_CONTRACT` (`3` as of `v0.11.0-alpha.2`, exact match) and `MINIMUM_CLI_VERSION` (`v0.11.0-alpha.2`, floor — not exact, since the sidecar is always exactly what the manifest pinned at build time; see `reference/desktop-hardening-migration-PLAN.md`'s "Decisions from review" for why floor-not-exact). To bump the pinned version: re-run the manifest-generation steps in that plan doc's Phase 1 against the new tag's real GitHub release assets (don't hand-edit checksums — download the real archives, verify against the tag's own `SHA256SUMS` release asset, extract and hash the binary yourself), then update `MINIMUM_CLI_VERSION`/`EXPECTED_JSON_CONTRACT` in `cli_bridge.rs` in the same change. - **Frontend**: `npm install`, `npm run dev` (Vite only) or `npm run dev:app` (full app — see above), `npm run build` (typecheck + production bundle — works on the bare host). No test runner wired in yet — Vitest is still the natural fit; decide and record here when it lands. - **Backend**: from `src-tauri/` inside the toolbox — `cargo build`, `cargo test` (fixture tests against canned JSON, no real CLI/podman needed), `cargo clippy -- -D warnings`, `cargo fmt`. -- **Full packaged app (Linux AppImage; other bundle targets untested here)**: `npm run build:appimage`, then `npm run run:appimage` to launch it (`scripts/build-appimage.sh` / `scripts/run-appimage.sh`). Since this is a release build, sidecar resolution works the same way as dev (no debug/PATH fallback involved) — the bundled `omnideck` binary ships inside the AppImage. Five real issues had to be worked through to get a *correctly functioning* AppImage out of this toolbox, all now handled automatically by the scripts/code below — worth knowing about if a rebuild ever breaks again: +- **Full packaged app (Linux AppImage; other bundle targets untested here)**: `npm run build:appimage`, then `npm run run:appimage` to launch it (`scripts/build-appimage.sh` / `scripts/run-appimage.sh`). Since this is a release build, sidecar resolution works the same way as dev (no debug/PATH fallback involved) — the bundled `omnideck` binary ships inside the AppImage. Six real issues had to be worked through to get a *correctly functioning* AppImage out of this toolbox, all now handled automatically by the scripts/code below — worth knowing about if a rebuild ever breaks again: - **`xdg-open` / FUSE2 missing in the toolbox**: `dnf install -y xdg-utils fuse fuse-libs` (needed once per toolbox — not scripted, since it's a one-time environment setup step, not a per-build one). - **`strip` can't parse `.relr.dyn`**: this toolbox's binutils (2.44) can't strip its own newer system libraries that get bundled in. `build-appimage.sh` sets `NO_STRIP=1` (linuxdeploy's own escape hatch — skips stripping, AppImage is a bit larger). - **WebKitGTK helper-process crash (`SIGBUS`)**: without `WEBKIT_EXEC_PATH` set, `WebKitWebProcess`/`WebKitNetworkProcess` resolve via their compiled-in absolute path instead of the bundled copies — and if that finds a *different* webkit2gtk build already on the host, the mismatched shared-memory IPC crashes almost immediately. Confirmed via `coredumpctl`, not a guess. Fixed permanently in `src-tauri/src/main.rs` (`fix_appimage_webkit_exec_path`) — in Rust, not a build-time AppRun hook patch, because `linuxdeploy` regenerates its own hook on every build regardless of what's injected via `bundle.linux.appimage.files`, silently clobbering any hook-level fix. - **`LD_LIBRARY_PATH` leaking into podman itself, breaking container inspection silently (`status: "unknown"` for every instance, even running ones — no crash, no error, just wrong answers)**: the AppImage runtime sets `LD_LIBRARY_PATH` (among other vars) so *our own* GTK/WebKit process finds its bundled libraries — but every child process inherits it by default, including the `omnideck` sidecar and, in turn, *its* child, podman. Podman dynamically linking against the AppImage's bundled versions of libraries it also happens to depend on (instead of the host's) is enough to break container inspection without erroring outright. Root-caused by comparing the real running app's full `/proc//environ` against a manual reproduction that initially didn't reproduce the bug (because it used a stripped-down `env -i` environment that accidentally avoided the problem) — not a guess, verified end-to-end with the fix applied. Fixed in `cli_bridge.rs`'s `sidecar_command()` helper, which clears `LD_LIBRARY_PATH` specifically (the only var of the AppImage-injected set that actually affects `ld.so`'s dynamic linking) before every sidecar spawn. - **Separately** (not yet root-caused as environment-specific vs. universal): the packaged AppImage's default FUSE-mount execution exits silently within a few seconds in this setup — the loose `AppDir/AppRun` and `--appimage-extract-and-run` both stay running reliably in every test, the plain double-executed `.AppImage` never did. `run-appimage.sh` always passes `--appimage-extract-and-run`. If you ever build for real distribution, re-test the plain (no-flag) launch on a target machine before assuming this flag is required everywhere — it may be specific to FUSE behavior inside this toolbox. - **The AppImage's *build machine's OS* matters, not just its build flags**: `linuxdeploy` bundles whatever GTK/WebKit/libepoxy shared libraries exist on the machine that runs the build. A `.github/workflows/release.yml` Linux build running directly on `ubuntu-24.04` produced an AppImage that reliably crashed at launch on a real Intel Iris Xe / Mesa 26.1.4 machine (`Could not create default EGL display: EGL_BAD_PARAMETER`), while the exact same source built in this Fedora 42 toolbox launched fine on that same machine — confirmed by diffing the two builds' bundled `webkit2gtk-4.1`/`gtk3`/`libepoxy` binaries directly (genuinely different sizes, i.e. different upstream versions — not a stripping artifact; `NO_STRIP=1` alone was tried first and did not fix it). Fixed by having CI's Linux leg build inside a `container: fedora:42` (matching this toolbox's own Fedora version) instead of on the bare `ubuntu-24.04` runner — see `release.yml`'s comments on the `linux-x64` matrix entry for the full account, including the exact `dnf` package list confirmed against a real `podman run fedora:42` before being wired into CI. + - **`linuxdeploy` bundles `libgcrypt.so.20` but not its version-locked pair `libgpg-error.so.0`** (the latter is on `linuxdeploy`'s own ~50-entry built-in exclude list — always left to the host — the former isn't, even though upstream GnuPG always releases and version-locks the two together). Bundling one without the other means the bundled `libgcrypt` (built against the build machine's `libgpg-error`) loads against whatever `libgpg-error` the *run* machine provides instead — confirmed the hard way: a Fedora-built AppImage crashed at launch on Ubuntu with `symbol lookup error: ...libgcrypt.so.20: undefined symbol: gpgrt_add_post_log_func, version GPG_ERROR_1.0`. This is the exact inverse of the EGL bug above (that one: Ubuntu-built breaks on some hardware; this one: Fedora-built breaks on Ubuntu) — confirms the general lesson generalizes, not "just switch which distro builds it" as a fix. Swept every other bundled library's own dependencies against `linuxdeploy`'s real exclude list looking for the same half-bundled-pair pattern; found one more (lower severity, doesn't crash at launch): `libprintbackend-cups.so` (a GTK print plugin) depends on `avahi`/`colord`/`cups`, none of which are bundled or excluded either. Fixed by `scripts/strip-unsafe-appimage-libs.sh` — a post-build step (wired into both `build-appimage.sh` and `npm run build:linux`) that extracts the built AppImage, deletes both, and repackages via the same `linuxdeploy-plugin-appimage` tauri itself already downloads — **not** by shipping separate Ubuntu/Fedora AppImages, which would just shift the "which one do I download" problem onto users and still not guarantee correctness on a third distro. Verified directly: removed the libraries, repackaged, relaunched — works. ### The `EGL_BAD_PARAMETER` AppImage crash: what actually fixed it diff --git a/package.json b/package.json index 13c732a..64b9a56 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omnideck-desktop", "private": true, - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "type": "module", "scripts": { "dev": "vite", @@ -20,7 +20,7 @@ "lint:rust": "cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets -- -D warnings", "format:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml -- --check", "verify": "npm run fetch:sidecars && npm run verify:sidecars && npm run test:policy && npm run typecheck && npm run format:rust && npm run test:rust && npm run lint:rust", - "build:linux": "node scripts/fetch-sidecars.mjs x86_64-unknown-linux-gnu && NO_STRIP=1 tauri build --bundles appimage,deb,rpm --target x86_64-unknown-linux-gnu", + "build:linux": "node scripts/fetch-sidecars.mjs x86_64-unknown-linux-gnu && NO_STRIP=1 tauri build --bundles appimage,deb,rpm --target x86_64-unknown-linux-gnu && bash scripts/strip-unsafe-appimage-libs.sh src-tauri/target/x86_64-unknown-linux-gnu/release/bundle", "build:windows": "node scripts/fetch-sidecars.mjs x86_64-pc-windows-msvc && tauri build --bundles nsis --target x86_64-pc-windows-msvc", "build:macos": "node scripts/fetch-sidecars.mjs aarch64-apple-darwin && tauri build --bundles dmg --target aarch64-apple-darwin" }, diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index 36dcfaf..cadd654 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -7,6 +7,12 @@ # toolbox's `strip` (binutils 2.44) can't parse the `.relr.dyn` relocation # section present in its own newer system libraries that get bundled in, and # fails the whole build without it. Confirmed by hand — see AGENT.md. +# +# No longer `exec`s the build command directly (it used to) — needs control +# back afterward to run strip-unsafe-appimage-libs.sh, which doesn't need +# the toolbox at all (it only extracts/repackages an already-built +# AppImage), so it always runs on the bare host regardless of which branch +# built it. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -14,13 +20,12 @@ toolbox_name="${OMNIDECK_TOOLBOX:-omnideck-dev}" cd "$repo_root" if [[ -f /run/.toolboxenv ]]; then - exec env NO_STRIP=1 npm run tauri build -- --bundles appimage -fi - -if [[ "$(uname -s)" == "Linux" ]] && command -v toolbox >/dev/null 2>&1; then - if ! command -v cargo >/dev/null 2>&1 || ! pkg-config --exists webkit2gtk-4.1 2>/dev/null; then - exec toolbox run -c "$toolbox_name" bash -c "cd '$repo_root' && NO_STRIP=1 npm run tauri build -- --bundles appimage" - fi + env NO_STRIP=1 npm run tauri build -- --bundles appimage +elif [[ "$(uname -s)" == "Linux" ]] && command -v toolbox >/dev/null 2>&1 \ + && { ! command -v cargo >/dev/null 2>&1 || ! pkg-config --exists webkit2gtk-4.1 2>/dev/null; }; then + toolbox run -c "$toolbox_name" bash -c "cd '$repo_root' && NO_STRIP=1 npm run tauri build -- --bundles appimage" +else + env NO_STRIP=1 npm run tauri build -- --bundles appimage fi -exec env NO_STRIP=1 npm run tauri build -- --bundles appimage +bash "$repo_root/scripts/strip-unsafe-appimage-libs.sh" "$repo_root/src-tauri/target/release/bundle" diff --git a/scripts/strip-unsafe-appimage-libs.sh b/scripts/strip-unsafe-appimage-libs.sh new file mode 100755 index 0000000..b5a7547 --- /dev/null +++ b/scripts/strip-unsafe-appimage-libs.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Post-processes a built Linux AppImage, removing shared libraries +# `linuxdeploy` bundled but shouldn't have — libraries whose ABI is tightly +# version-locked to another library `linuxdeploy`'s own exclude list +# *does* correctly leave to the host. +# +# The real bug this fixes: `linuxdeploy` has a ~50-entry built-in exclude +# list of universal system libraries it always leaves to the host — +# libgpg-error.so.0 is on it. libgcrypt.so.20 (pulled in transitively by +# webkit2gtk) is not, even though upstream GnuPG always releases and +# version-locks the two as a matched pair. Bundling one without the other +# means the bundled libgcrypt (built against the build machine's +# libgpg-error) gets loaded at runtime alongside whatever libgpg-error the +# *host* provides instead — on a sufficiently different distro, a symbol +# version the bundled libgcrypt expects isn't there. Confirmed directly: a +# Fedora-built AppImage crashed on Ubuntu with "undefined symbol: +# gpgrt_add_post_log_func, version GPG_ERROR_1.0"; removing the bundled +# libgcrypt.so.20 (falling back to the host's own matched pair, exactly +# like libgpg-error already does) fixed it — verified by relaunching the +# repackaged AppImage. +# +# Usage: strip-unsafe-appimage-libs.sh +# is a tauri bundle output dir containing an appimage/ +# subdirectory with exactly one *.AppImage (matches scripts/checksums.mjs's +# own directory-argument convention). +set -euo pipefail + +[[ $# -eq 1 ]] || { echo "Usage: $0 " >&2; exit 1; } +bundle_dir="$1" +appimage_dir="$bundle_dir/appimage" +[[ -d "$appimage_dir" ]] || { echo "No appimage/ directory under $bundle_dir" >&2; exit 1; } + +appimage="" +for f in "$appimage_dir"/*.AppImage; do + [[ -e "$f" ]] || continue + if [[ -n "$appimage" ]]; then + echo "Expected exactly one .AppImage in $appimage_dir, found more than one" >&2 + exit 1 + fi + appimage="$f" +done +[[ -n "$appimage" ]] || { echo "No .AppImage found in $appimage_dir" >&2; exit 1; } +appimage="$(cd "$(dirname "$appimage")" && pwd)/$(basename "$appimage")" + +# Libraries linuxdeploy bundles but shouldn't — each is tightly +# version-locked to a library linuxdeploy's own exclude list already +# leaves to the host, so bundling only this one breaks that pairing. +# Glob patterns, matched against usr/lib/. +unsafe_libs=( + "libgcrypt.so*" # paired with libgpg-error.so.0, which linuxdeploy excludes +) +# GTK plugin, not a hard startup dependency — only breaks printing, not +# launch — but has the identical half-bundled-dependency-chain problem +# (needs avahi/colord/cups, none of which are bundled or excluded either). +unsafe_plugins=( + "libprintbackend-cups.so" +) + +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT +cd "$work_dir" + +echo "==> Extracting $(basename "$appimage")" +"$appimage" --appimage-extract >/dev/null + +removed=0 +for pattern in "${unsafe_libs[@]}" "${unsafe_plugins[@]}"; do + for f in squashfs-root/usr/lib/$pattern; do + [[ -e "$f" ]] || continue + echo " removing $(basename "$f")" + rm -f "$f" + removed=$((removed + 1)) + done +done +if (( removed == 0 )); then + echo "Nothing matched the unsafe-library list — is it stale, or did linuxdeploy stop bundling these?" >&2 + exit 1 +fi + +# Same tool tauri's own AppImage bundling step already downloads+caches — +# reuse that cache if present (fast path for repeat local builds), else +# fetch fresh (CI containers start empty every run). +plugin="$HOME/.cache/tauri/linuxdeploy-plugin-appimage.AppImage" +if [[ ! -x "$plugin" ]]; then + echo "==> Fetching linuxdeploy-plugin-appimage" + mkdir -p "$(dirname "$plugin")" + curl -fsSL -o "$plugin" \ + https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage + chmod +x "$plugin" +fi + +echo "==> Repackaging" +# NO_STRIP=1: same binutils/.relr.dyn issue documented in AGENT.md / +# build-appimage.sh — repackaging re-invokes strip otherwise. +NO_STRIP=1 "$plugin" --appimage-extract-and-run --appdir squashfs-root >/dev/null + +repacked="" +for f in *.AppImage; do + [[ -e "$f" ]] || continue + repacked="$f" + break +done +[[ -n "$repacked" ]] || { echo "Repackaging produced no AppImage" >&2; exit 1; } + +mv -f -- "$repacked" "$appimage" +echo "==> Done: $appimage" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c946a49..cb7c585 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2269,7 +2269,7 @@ dependencies = [ [[package]] name = "omnideck-desktop" -version = "0.5.0-alpha.8" +version = "0.5.0-alpha.9" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8087633..6270c64 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "omnideck-desktop" -version = "0.5.0-alpha.8" +version = "0.5.0-alpha.9" description = "Omnideck desktop app — a thin GUI shell over the omnideck CLI" authors = ["Omnideck"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4cc099a..211194a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Omnideck", - "version": "0.5.0-alpha.8", + "version": "0.5.0-alpha.9", "identifier": "dev.omnideck.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/tests/policy.test.mjs b/tests/policy.test.mjs index be3263c..1bc2cc7 100644 --- a/tests/policy.test.mjs +++ b/tests/policy.test.mjs @@ -228,3 +228,18 @@ test("sidecar process output is bounded and every operation has a timeout", () = assert.match(cliBridgeRust, /fn append_bounded/); assert.match(cliBridgeRust, /struct LineBuffer/); }); + +test("both Linux AppImage build paths strip the known half-bundled libraries", async () => { + // Regression guard for a real cross-distro crash: linuxdeploy bundles + // libgcrypt.so.20 but not its version-locked pair libgpg-error.so.0 (the + // latter is on linuxdeploy's own exclude list, the former isn't), so a + // Fedora-built AppImage's bundled libgcrypt loaded against Ubuntu's older + // libgpg-error at runtime and crashed with a missing symbol version. See + // scripts/strip-unsafe-appimage-libs.sh and AGENT.md's AppImage issues + // list for the full account. + assert.match(packageJson.scripts["build:linux"], /strip-unsafe-appimage-libs\.sh/); + const buildAppimageScript = await read("../scripts/build-appimage.sh"); + assert.match(buildAppimageScript, /strip-unsafe-appimage-libs\.sh/); + const stripScript = await read("../scripts/strip-unsafe-appimage-libs.sh"); + assert.match(stripScript, /libgcrypt\.so/); +});