Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target-triple>` for just one), writing `src-tauri/binaries/omnideck-<target-triple>[.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/<pid>/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

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
21 changes: 13 additions & 8 deletions scripts/build-appimage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,25 @@
# 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)"
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"
106 changes: 106 additions & 0 deletions scripts/strip-unsafe-appimage-libs.sh
Original file line number Diff line number Diff line change
@@ -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 <bundle-dir>
# <bundle-dir> 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 <bundle-dir>" >&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"
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 15 additions & 0 deletions tests/policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading