diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml index 16590b389..e0e313e05 100644 --- a/.github/workflows/app-release.yml +++ b/.github/workflows/app-release.yml @@ -150,6 +150,95 @@ jobs: app/src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: warn + build-linux: + name: build (linux) + needs: verify-version + # Pin the release build to Ubuntu 22.04 so the shipped binaries retain + # glibc compatibility with supported Ubuntu installations. Revisit this + # when the supported Ubuntu baseline is raised or GitHub retires the label. + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: app + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: app/pnpm-lock.yaml + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: app/src-tauri + + - name: Install Tauri Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf libssl-dev libsoup-3.0-dev libxdo-dev \ + build-essential curl wget file + + - name: Install minisign verifier + run: | + set -euo pipefail + version=0.12 + archive="$RUNNER_TEMP/minisign-linux.tar.gz" + install_dir="$RUNNER_TEMP/minisign/bin" + expected_sha256=9a599b48ba6eb7b1e80f12f36b94ceca7c00b7a5173c95c3efc88d9822957e73 + curl --fail --location --silent --show-error \ + "https://github.com/jedisct1/minisign/releases/download/$version/minisign-$version-linux.tar.gz" \ + --output "$archive" + echo "$expected_sha256 $archive" | sha256sum --check --strict + mkdir -p "$install_dir" + tar --extract --gzip --file "$archive" --directory "$RUNNER_TEMP" + install -m 0755 "$RUNNER_TEMP/minisign-linux/x86_64/minisign" "$install_dir/minisign" + echo "$install_dir" >> "$GITHUB_PATH" + "$install_dir/minisign" -v + + - name: Verify Tauri AppImage signature encoding + run: scripts/test-verify-minisign-appimage.sh + + - name: Install frontend deps + run: pnpm install --frozen-lockfile + + - name: Bundle pinned agmsg-core + run: app/scripts/bundle-core.sh + working-directory: . + shell: bash + + - name: Verify agmsg-core resource modes + run: scripts/test-verify-core-tree-modes.sh + + - name: Build deb and AppImage + env: + # Linux updater artifacts use the minisign key pair. Apple and + # Microsoft signing credentials are platform-specific and must not + # be introduced into this job. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: pnpm tauri build --bundles deb,appimage + + - name: Verify Linux bundles + run: scripts/verify-linux-bundles.sh + + - uses: actions/upload-artifact@v4 + with: + name: agmsg-app-linux + path: | + app/src-tauri/target/release/bundle/deb/*.deb + app/src-tauri/target/release/bundle/appimage/*.AppImage + app/src-tauri/target/release/bundle/appimage/*.AppImage.sig + if-no-files-found: error + build-windows: name: build (windows) needs: verify-version diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dba53c762..3189449eb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -473,6 +473,80 @@ jobs: if: needs.changes.outputs.app_changed != 'false' run: cargo test --manifest-path src-tauri/Cargo.toml + # Keep the Linux test leg on the same Ubuntu generation as local validation. + # The explicit 24.04 label is intentional: it catches Linux cfg regressions + # against the Ubuntu 24 environment we support, while ubuntu-latest may move + # to 26.04. Revisit this pin when the real Ubuntu target is upgraded or when + # GitHub retires the label. + app-check-linux: + name: app typecheck (linux) + needs: changes + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04 + timeout-minutes: 25 + defaults: + run: + working-directory: app + steps: + - uses: actions/checkout@v4 + + - name: No app changes — skipping Linux typecheck + if: needs.changes.outputs.app_changed == 'false' + run: echo "Diff does not touch app/ — reporting green without running the Linux typecheck." + + - name: Install Tauri Linux dependencies + if: needs.changes.outputs.app_changed != 'false' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf libssl-dev libsoup-3.0-dev libxdo-dev \ + build-essential curl wget file + + - uses: pnpm/action-setup@v4 + if: needs.changes.outputs.app_changed != 'false' + with: + version: 10 + + - uses: actions/setup-node@v4 + if: needs.changes.outputs.app_changed != 'false' + with: + node-version: 20 + cache: pnpm + cache-dependency-path: app/pnpm-lock.yaml + + - uses: dtolnay/rust-toolchain@stable + if: needs.changes.outputs.app_changed != 'false' + + - uses: Swatinem/rust-cache@v2 + if: needs.changes.outputs.app_changed != 'false' + with: + workspaces: app/src-tauri + + - name: Install frontend deps + if: needs.changes.outputs.app_changed != 'false' + run: pnpm install --frozen-lockfile + + - name: TypeScript typecheck + if: needs.changes.outputs.app_changed != 'false' + run: pnpm exec tsc --noEmit + + - name: Frontend tests + if: needs.changes.outputs.app_changed != 'false' + run: pnpm test + + - name: Bundle pinned agmsg-core + if: needs.changes.outputs.app_changed != 'false' + run: scripts/bundle-core.sh + + - name: Rust check + if: needs.changes.outputs.app_changed != 'false' + run: cargo check --manifest-path src-tauri/Cargo.toml + + - name: Rust tests + if: needs.changes.outputs.app_changed != 'false' + run: cargo test --manifest-path src-tauri/Cargo.toml + # Windows leg for the app: the command layer's bash resolution and path # conversion are all behind cfg(windows) and had never run in CI — the very # 0.1.1→0.1.3 regressions (WSL bash.exe, backslash argv). This runs cargo test diff --git a/RELEASING.md b/RELEASING.md index 8daf3b549..ced8de35a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -42,6 +42,100 @@ which: 4. Generates the release notes for the tag with git-cliff and creates a GitHub Release from them. +## Desktop app: Linux artifacts and updater metadata + +The desktop app has a separate `app-vX.Y.Z` release flow in +`.github/workflows/app-release.yml`. Its Linux job runs on the pinned +`ubuntu-22.04` image (glibc compatibility for distributed binaries) and asks +Tauri for exactly `deb,appimage` bundles. Before uploading artifacts, run: + +```bash +cd app +scripts/verify-linux-bundles.sh src-tauri/target/release/bundle +``` + +The check must find one `.deb`, one executable `.AppImage`, and the matching +`.AppImage.sig`, and no RPM. It also checks the Debian runtime dependencies and +the executable bits of the bundled `agmsg-core` scripts in both formats. + +### Linux release validation (manual) + +The bundle script covers deterministic archive checks. Before publishing a +desktop release, complete these checks on the built `.deb` and `.AppImage` +executables as well: + +1. **Updater acceptance (plan 5(e)).** Point a test build at the candidate + `latest.json` and confirm that an AppImage accepts the `linux-x86_64` entry + containing the matching AppImage URL and `.AppImage.sig` contents. Confirm + that a `.deb` does not run an updater check and instead follows the release + page installation path. If an endpoint-backed check is not practical for a + release, record the first-release manual verification in the release notes. +2. **Terminal copy/paste (plan 5(f)).** In each shipped format, verify all three + Linux terminal paths: right-click Copy, PRIMARY-selection middle-click + paste, and `Ctrl+Shift+C`/`Ctrl+Shift+V`. These checks preserve the Option B + decision to omit the inert native Edit submenu. +3. **Runtime WebKit (plan 5(g)).** While each format is running, use `ldd` + (or `/proc//maps`) to record which `libwebkit2gtk` is loaded. An + AppImage's bundled-versus-system WebKit behavior is an environment record + for diagnosing WebKit-dependent behavior such as a blank window (including + DMA-BUF renderer issues), and for documenting whether the AppImage bundles + WebKit or relies on the system library. + +Linux updater behavior is a product policy: only an AppImage self-updates. A +Debian package could technically be replaced through `pkexec`, a GUI sudo +prompt, or `dpkg -i`, but this project does not adopt a self-replacement flow +that escalates privileges. Tell `.deb` users to download and install the newer +package from the release page instead. macOS and Windows updater behavior is +unchanged. + +The `platforms` map in the hand-authored `latest.json` is cumulative. Start from +the current file and preserve the existing `darwin-aarch64` and +`windows-x86_64` objects; merge only the new `linux-x86_64` object. Do not +replace the map with a Linux-only object, or the existing macOS/Windows updater +paths will stop working. The Linux entry must pair the AppImage URL with the +exact contents of its v2 signature file (not a URL to the signature file). +Local bundle verification requires `jq` and `minisign` on `PATH`. +Run `app/scripts/bundle-core.sh` first; the verifier fails closed when its +source `agmsg-core` tree is absent because packaged resource modes are compared +against that tree. + +```bash +set -euo pipefail + +APPIMAGE_URL="https://github.com/fujibee/agmsg/releases/download/app-latest/agmsg_0.4.0_amd64.AppImage" +APPIMAGE_SIG="$(cat agmsg_0.4.0_amd64.AppImage.sig)" +candidate="latest.json.next" +jq --arg url "$APPIMAGE_URL" --arg signature "$APPIMAGE_SIG" \ + '.platforms["linux-x86_64"] = {url: $url, signature: $signature}' \ + latest.json > "$candidate" + +# Inspect the merge: the existing macOS/Windows entries must still be present, +# and Linux must point to an AppImage with a non-empty matching signature. +jq -e ' + .platforms["darwin-aarch64"] != null and + .platforms["windows-x86_64"] != null and + (.platforms["linux-x86_64"] != null) and + (.platforms["linux-x86_64"].url | + if type == "string" then endswith(".AppImage") else false end) and + (.platforms["linux-x86_64"].signature | + if type == "string" then length > 0 else false end) +' "$candidate" +mv -- "$candidate" latest.json +``` + +When updating the fixed `app-latest` release, upload the Linux `.deb`, the +AppImage, its `.AppImage.sig`, and this `latest.json` together. Keep the +`linux-x86_64` URL and signature from the same build; a signature copied from a +different AppImage makes the updater reject the artifact. + +The explicit `sqlite3` entry in `app/src-tauri/tauri.conf.json` is intentional. +The app's Rust `rusqlite` dependency uses the bundled SQLite library, while the +`agmsg-core` resource invokes the system `sqlite3` command from its install +script (`app/src-tauri/resources/agmsg-core/install.sh`, the missing-command +check around lines 209–212). Do not remove that Debian dependency unless the +resource's runtime requirement and the package's `Depends` field are both +revalidated. + ### Manual steps (if you'd rather not use the script) ```bash diff --git a/app/README.md b/app/README.md index 1aa18dc3b..204a84647 100644 --- a/app/README.md +++ b/app/README.md @@ -1,11 +1,12 @@ # agmsg desktop app -The official agmsg desktop app (macOS/Windows, desktop-first): a terminal-embedded +The official agmsg desktop app (macOS/Windows/Linux, desktop-first): a terminal-embedded GUI that spawns agents in real PTYs and delivers agmsg messages to ANY interactive CLI agent by injecting them into the agent's stdin at its idle prompt — no per-agent bridge, hook, or monitor tool. -> Status: past Phase 0 — daily-driven, macOS-signed and notarized, auto-updating. +> Status: past Phase 0 — daily-driven, macOS-signed and notarized. macOS and +> Windows update in place; Linux AppImage builds use the same updater policy. ## Install @@ -23,8 +24,14 @@ normally with no Gatekeeper right-click workaround needed. **Windows** Download the `.msi` or `.exe` installer from the same [Releases page](https://github.com/fujibee/agmsg/releases). -Both platforms auto-update in place after install (**agmsg → Check for -Updates…**, or silently on launch). +**Linux (Ubuntu)** +Download the `.deb` or `.AppImage` from the same [Releases page](https://github.com/fujibee/agmsg/releases). +The AppImage can update in place; a `.deb` is updated by downloading the newer +package from the release page (it does not self-update). + +macOS, Windows, and Linux AppImage builds can check for updates after install +(**agmsg → Check for Updates…**, or silently on launch). The Linux `.deb` path +is deliberately excluded from the updater; see the product policy below. Prerequisite either way: agmsg itself installed at `~/.agents/skills/agmsg` (the app reads its DB and team config from there) — see the @@ -84,14 +91,17 @@ DB and team config from there). `claude` must be on `PATH` to spawn Claude Code ## Releasing -`.github/workflows/app-release.yml` builds, signs, and (macOS) notarizes both -platforms on a push of an `app-vX.Y.Z` tag (or by hand via `workflow_dispatch`). +`.github/workflows/app-release.yml` builds, signs, and (macOS) notarizes all +supported desktop platforms on a push of an `app-vX.Y.Z` tag (or by hand via +`workflow_dispatch`). macOS goes through codesign → notarize → staple end to end in CI. Windows builds and, given Azure credentials, runs Trusted Signing (OIDC via `azure/login`, no client secret) — that federated identity trusts `main` only, so it can't be exercised from a feature branch. The workflow deliberately doesn't publish a GitHub Release itself; cutting one is still a human-gated step (upload artifacts -+ hand-author `latest.json`, below). ++ hand-author `latest.json`, below). The Linux job requests exactly a `.deb`, an +`.AppImage`, and its `.AppImage.sig`; run `app/scripts/verify-linux-bundles.sh` +against the downloaded bundle directory before uploading it. For a local macOS build without CI: ```sh @@ -103,7 +113,11 @@ pnpm build:notarize # sources APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID from ``` Auto-update is wired up via `tauri-plugin-updater`, checked silently on launch and -on-demand via **agmsg → Check for Updates…**. The private signing key lives in +on-demand via **agmsg → Check for Updates…**. On Linux this is a product policy, +not a technical limitation: only AppImage bundles self-update. A `.deb` could be +replaced with an elevated package-manager operation, but this project does not +adopt a self-replacement flow that prompts for privilege escalation; install a +new `.deb` from the release page instead. The private signing key lives in the worktree-root `.secrets/` locally (never committed) and as the `TAURI_SIGNING_PRIVATE_KEY`/`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secrets in CI. `TAURI_SIGNING_PRIVATE_KEY` must be the **decoded** minisign key text itself @@ -132,12 +146,14 @@ gh release create app-latest --repo fujibee/agmsg --title "agmsg (latest)" \ # Cut a normal versioned release for history/changelog, from CI's artifacts... gh release create app-vX.Y.Z --repo fujibee/agmsg --title "agmsg vX.Y.Z" \ - + # ...then overwrite app-latest's assets with the same build + latest.json # (hand-author latest.json: version, notes, pub_date, per-platform url+signature). +# Linux must use the AppImage itself and the matching v2 .AppImage.sig: +# "linux-x86_64": { "url": ".../.AppImage", "signature": "<.AppImage.sig contents>" } gh release upload app-latest --repo fujibee/agmsg --clobber \ - latest.json + latest.json ``` Once artifacts are up, update the Homebrew cask (`fujibee/homebrew-agmsg`): ```sh @@ -148,6 +164,15 @@ scripts/release/update-cask.sh X.Y.Z # finds the .dmg on the release, Run it only after the release assets are uploaded — the cask's `url` must resolve as soon as the tap commit lands. +## Known limitations + +- X11 was tested. Wayland and HiDPI were not tested. +- Printable-character typeahead is not implemented in the custom select controls; + use the arrow keys, Home/End, or the option list instead. +- If terminal output has no colour, check the shell that launched agmsg for + `NO_COLOR`. Remove that variable when colour is wanted; the app respects the + user's `NO_COLOR` setting and does not override it. + ## Known gaps - Windows Trusted Signing is wired into CI but unverified end to end — its diff --git a/app/scripts/bundle-core.sh b/app/scripts/bundle-core.sh index 951626254..4d2d94884 100755 --- a/app/scripts/bundle-core.sh +++ b/app/scripts/bundle-core.sh @@ -9,8 +9,11 @@ # what ships is fixed and auditable via git history. Bump AGMSG_CORE_REF by # hand to pick up newer agmsg-core fixes. # -# Called from three places that must stay in sync: app-release.yml's macOS -# and Windows jobs, and build-notarize.sh for local builds. +# Called from every build path that must bundle the pinned core: +# - .github/workflows/app-release.yml: build-macos, build-windows, build-linux +# - .github/workflows/tests.yml: app-check, app-check-linux, app-test-windows +# - app/scripts/build-notarize.sh: local macOS notarized builds +# Keep this list exhaustive when adding another build or test path. set -euo pipefail APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" diff --git a/app/scripts/fixtures/minisign-appimage/foreign.pub b/app/scripts/fixtures/minisign-appimage/foreign.pub new file mode 100644 index 000000000..cd91a604b --- /dev/null +++ b/app/scripts/fixtures/minisign-appimage/foreign.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key D709B5D1C5173148 +RWRIMRfF0bUJ11vl/TRtk8Pdf5bi2AsyZ2wX4qx/gbVBE3Wx6yE3MsPP diff --git a/app/scripts/fixtures/minisign-appimage/invalid-base64.AppImage.sig b/app/scripts/fixtures/minisign-appimage/invalid-base64.AppImage.sig new file mode 100644 index 000000000..da4a6f972 --- /dev/null +++ b/app/scripts/fixtures/minisign-appimage/invalid-base64.AppImage.sig @@ -0,0 +1 @@ +not a Tauri signature diff --git a/app/scripts/fixtures/minisign-appimage/trusted.pub b/app/scripts/fixtures/minisign-appimage/trusted.pub new file mode 100644 index 000000000..9b30f5e61 --- /dev/null +++ b/app/scripts/fixtures/minisign-appimage/trusted.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key E7620F1842B4E81F +RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3 diff --git a/app/scripts/fixtures/minisign-appimage/valid.AppImage.sig b/app/scripts/fixtures/minisign-appimage/valid.AppImage.sig new file mode 100644 index 000000000..de0ad26b6 --- /dev/null +++ b/app/scripts/fixtures/minisign-appimage/valid.AppImage.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUldRZjZMUkNHQTlpNTlTTE9GeHo2Tnh2QVNYREplUnR1Wnlrd1FlcGJERUd0ODdpZzFCTnBXYVZXdU5ybTczWWlJaUpicTcxV2krZFA5ZUtMOE9DMzUxdndJYXNTU2JYeHdBPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTU1Nzc5OTY2CWZpbGU6dGVzdApRdEtNWFd5WWN3ZHBaQWxQRjd0RTJFTkprUmQxdWp2S2psajFtOVJ0SFRCblpQYTVXS1U1dVdSczVHb1A1TS9WcUU4MVFGdU1LSTVrL1NmTlFVYU9BQT09Cg== diff --git a/app/scripts/test-verify-core-tree-modes.sh b/app/scripts/test-verify-core-tree-modes.sh new file mode 100755 index 000000000..ee3c34f64 --- /dev/null +++ b/app/scripts/test-verify-core-tree-modes.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Exercise agmsg-core resource path and execution-bit preservation checks. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +source_core="$APP_DIR/src-tauri/resources/agmsg-core" +source "$SCRIPT_DIR/verify-core-tree-modes.sh" + +[[ -d "$source_core" ]] || { + echo "test-verify-core-tree-modes: run scripts/bundle-core.sh first" >&2 + exit 1 +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +nonexec_file="$(find "$source_core" -type f -name '_*.sh' ! -perm /0111 -print -quit)" +[[ -n "$nonexec_file" ]] || { + echo "test-verify-core-tree-modes: fixture needs a non-executable sourced helper" >&2 + exit 1 +} +[[ ! -x "$nonexec_file" ]] || { + echo "test-verify-core-tree-modes: selected helper is unexpectedly executable" >&2 + exit 1 +} +nonexec_relative="${nonexec_file#"$source_core"/}" + +exec_file="$(find "$source_core" -type f -perm /0111 -not -name install.sh -not -name uninstall.sh -print -quit)" +[[ -n "$exec_file" ]] || { + echo "test-verify-core-tree-modes: fixture needs an executable regular file" >&2 + exit 1 +} +exec_relative="${exec_file#"$source_core"/}" + +make_fixture() { + local label="$1" + local root="$tmp_dir/$label/root" + mkdir -p "$root" + cp -a "$source_core" "$root/agmsg-core" + printf '%s\n' "$root" +} + +verify_success() { + local label="$1" + local root="$2" + if ! verify_core_tree_modes "$source_core" "$root" "$label" "$tmp_dir/manifests"; then + echo "$label: expected success" >&2 + exit 1 + fi + printf '%s: success\n' "$label" +} + +verify_failure() { + local label="$1" + local root="$2" + if verify_core_tree_modes "$source_core" "$root" "$label" "$tmp_dir/manifests"; then + echo "$label: expected failure" >&2 + exit 1 + fi + printf '%s: rejected\n' "$label" +} + +exact_root="$(make_fixture exact)" +verify_success exact "$exact_root" + +extra_root="$(make_fixture extra)" +printf '%s\n' 'Tauri-added fixture entry' >"$extra_root/agmsg-core/tauri-added-file" +verify_success extra-entry "$extra_root" + +nonexec_to_exec_root="$(make_fixture nonexec-to-exec)" +chmod +x "$nonexec_to_exec_root/agmsg-core/$nonexec_relative" +verify_failure nonexec-to-exec "$nonexec_to_exec_root" + +exec_to_nonexec_root="$(make_fixture exec-to-nonexec)" +chmod a-x "$exec_to_nonexec_root/agmsg-core/$exec_relative" +verify_failure exec-to-nonexec "$exec_to_nonexec_root" + +write_mode_root="$(make_fixture write-mode-change)" +chmod 666 "$write_mode_root/agmsg-core/$nonexec_relative" +verify_failure write-mode-change "$write_mode_root" + +missing_root="$(make_fixture missing)" +mv "$missing_root/agmsg-core/$nonexec_relative" \ + "$missing_root/agmsg-core/$nonexec_relative.missing" +verify_failure missing-file "$missing_root" + +printf 'test-verify-core-tree-modes: OK (%s source regular files)\n' \ + "$(find "$source_core" -type f | wc -l)" diff --git a/app/scripts/test-verify-minisign-appimage.sh b/app/scripts/test-verify-minisign-appimage.sh new file mode 100755 index 000000000..b88a8ee3c --- /dev/null +++ b/app/scripts/test-verify-minisign-appimage.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Exercise the Tauri updater signature encoding used by verify-linux-bundles.sh. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIXTURE_DIR="$SCRIPT_DIR/fixtures/minisign-appimage" +source "$SCRIPT_DIR/verify-minisign-appimage.sh" + +command -v minisign >/dev/null 2>&1 || { + echo "test-verify-minisign-appimage: minisign is required" >&2 + exit 1 +} + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +appimage="$tmp_dir/fixture.AppImage" +tampered_appimage="$tmp_dir/fixture-tampered.AppImage" +printf '%s' 'test' >"$appimage" +printf '%s' 'tampered' >"$tampered_appimage" + +run_case() { + local label="$1" + local candidate_appimage="$2" + local signature="$3" + local public_key="$4" + local expected_status="$5" + local decoded_signature="$tmp_dir/$label.minisig" + local key_body status + + key_body="$(sed -n '2p' "$public_key")" + if verify_minisign_appimage_signature \ + "$candidate_appimage" "$signature" "$key_body" "$decoded_signature"; then + status=0 + else + status=$? + fi + + if (( expected_status == 0 && status != 0 )); then + echo "$label: expected success, got exit $status" >&2 + exit 1 + fi + if (( expected_status != 0 && status == 0 )); then + echo "$label: expected failure, got success" >&2 + exit 1 + fi + if (( status == 0 )); then + printf '%s: success\n' "$label" + else + printf '%s: rejected\n' "$label" + fi +} + +run_case valid \ + "$appimage" "$FIXTURE_DIR/valid.AppImage.sig" \ + "$FIXTURE_DIR/trusted.pub" 0 +run_case foreign-key \ + "$appimage" "$FIXTURE_DIR/valid.AppImage.sig" \ + "$FIXTURE_DIR/foreign.pub" 1 +run_case tampered \ + "$tampered_appimage" "$FIXTURE_DIR/valid.AppImage.sig" \ + "$FIXTURE_DIR/trusted.pub" 1 +run_case invalid-base64 \ + "$appimage" "$FIXTURE_DIR/invalid-base64.AppImage.sig" \ + "$FIXTURE_DIR/trusted.pub" 1 + +echo 'test-verify-minisign-appimage: OK' diff --git a/app/scripts/verify-core-tree-modes.sh b/app/scripts/verify-core-tree-modes.sh new file mode 100755 index 000000000..9c60c500b --- /dev/null +++ b/app/scripts/verify-core-tree-modes.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Compare the paths, types, and complete modes of an agmsg-core resource tree. + +verify_core_tree_modes() { + local source_core="$1" + local root="$2" + local label="$3" + local manifest_dir="$4" + local core + local source_manifest="$manifest_dir/${label}-source-files.manifest" + local bundle_manifest="$manifest_dir/${label}-bundle-files.manifest" + local relative_path file_type mode expected actual + declare -A bundle_entries=() + + if [[ ! -d "$source_core" ]]; then + echo "verify-core-tree-modes: source agmsg-core is missing; run scripts/bundle-core.sh first: $source_core" >&2 + return 1 + fi + core="$(find "$root" -type d -name agmsg-core -print -quit)" + if [[ -z "$core" ]]; then + echo "verify-core-tree-modes: $label agmsg-core resource directory is missing" >&2 + return 1 + fi + for required in install.sh uninstall.sh; do + if [[ ! -f "$source_core/$required" ]]; then + echo "verify-core-tree-modes: source agmsg-core/$required is missing" >&2 + return 1 + fi + if [[ ! -f "$core/$required" ]]; then + echo "verify-core-tree-modes: $label agmsg-core/$required is missing" >&2 + return 1 + fi + done + + # Git records the complete mode for every entry. This comparison checks + # whether packaging preserved it; it does not make every .sh executable, + # because helpers beginning with `_` are intentionally sourced. Compare + # directories and all regular files, including executable non-.sh files. + mkdir -p "$manifest_dir" + find "$source_core" -mindepth 1 -printf '%P\t%y\t%m\n' \ + | LC_ALL=C sort >"$source_manifest" + find "$core" -mindepth 1 -printf '%P\t%y\t%m\n' \ + | LC_ALL=C sort >"$bundle_manifest" + + while IFS=$'\t' read -r relative_path file_type mode; do + [[ -n "$relative_path" ]] || continue + bundle_entries["$relative_path"]="$file_type:$mode" + done <"$bundle_manifest" + + # The source set is authoritative: every source entry must exist in the + # package with the same type and complete mode. Extra entries that Tauri adds + # in the packaged resource tree are intentionally allowed. + while IFS=$'\t' read -r relative_path file_type mode; do + [[ -n "$relative_path" ]] || continue + if [[ -z ${bundle_entries["$relative_path"]+present} ]]; then + echo "verify-core-tree-modes: $label agmsg-core path missing after packaging: $relative_path" >&2 + return 1 + fi + expected="$file_type:$mode" + actual="${bundle_entries["$relative_path"]}" + if [[ "$actual" != "$expected" ]]; then + echo "verify-core-tree-modes: $label type/mode changed at $relative_path (source $expected, bundle $actual)" >&2 + return 1 + fi + done <"$source_manifest" +} diff --git a/app/scripts/verify-linux-bundles.sh b/app/scripts/verify-linux-bundles.sh new file mode 100755 index 000000000..3e7da9593 --- /dev/null +++ b/app/scripts/verify-linux-bundles.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Verify the Linux artifacts produced by `tauri build --bundles deb,appimage`. +# +# This covers the checks that are deterministic in CI: exactly the requested +# bundle formats, an AppImage updater signature, Debian dependencies, and the +# executable bits/resources preserved through both archive formats. GUI smoke +# tests, updater endpoint acceptance, copy/paste paths, and runtime WebKit +# inspection remain release-validation steps because they need a desktop +# session and a running binary. +set -euo pipefail + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUNDLE_DIR="${1:-$APP_DIR/src-tauri/target/release/bundle}" + +die() { + echo "verify-linux-bundles: $*" >&2 + exit 1 +} + +[[ -d "$BUNDLE_DIR" ]] || die "bundle directory not found: $BUNDLE_DIR" +BUNDLE_DIR="$(cd -- "$BUNDLE_DIR" && pwd -P)" +command -v dpkg-deb >/dev/null 2>&1 || die "dpkg-deb is required" +command -v jq >/dev/null 2>&1 || die "jq is required to read the updater public key" +command -v minisign >/dev/null 2>&1 || die "minisign is required to verify the AppImage signature" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +source "$APP_DIR/scripts/verify-minisign-appimage.sh" +source "$APP_DIR/scripts/verify-core-tree-modes.sh" +source_core="$APP_DIR/src-tauri/resources/agmsg-core" +[[ -d "$source_core" ]] \ + || die "source agmsg-core is missing; run scripts/bundle-core.sh before verify-linux-bundles.sh: $source_core" + +shopt -s nullglob +deb_files=("$BUNDLE_DIR"/deb/*.deb) +appimage_files=("$BUNDLE_DIR"/appimage/*.AppImage) +signature_files=("$BUNDLE_DIR"/appimage/*.AppImage.sig) +rpm_files=("$BUNDLE_DIR"/rpm/*) + +(( ${#deb_files[@]} == 1 )) || die "expected exactly one .deb, found ${#deb_files[@]}" +(( ${#appimage_files[@]} == 1 )) || die "expected exactly one AppImage, found ${#appimage_files[@]}" +(( ${#signature_files[@]} == 1 )) || die "expected exactly one .AppImage.sig, found ${#signature_files[@]}" +(( ${#rpm_files[@]} == 0 )) || die "unexpected RPM artifacts found under $BUNDLE_DIR" + +appimage="${appimage_files[0]}" +signature="${signature_files[0]}" +[[ "$signature" == "$appimage.sig" ]] || die "signature does not match AppImage: $signature" +[[ -x "$appimage" ]] || die "AppImage is not executable: $appimage" +[[ -s "$signature" ]] || die "AppImage signature is empty: $signature" + +# No unrequested bundle format should be silently emitted by a future CLI. +# Tauri 2.11.3 may emit a `.deb.sig`; accept it for validation, while the +# workflow upload list deliberately publishes only the `.deb` and AppImage +# updater artifacts. +while IFS= read -r artifact; do + case "$artifact" in + "$BUNDLE_DIR"/deb/*.deb|"$BUNDLE_DIR"/deb/*.deb.sig|"$BUNDLE_DIR"/appimage/*.AppImage|"$BUNDLE_DIR"/appimage/*.AppImage.sig) ;; + *) die "unexpected bundle artifact: $artifact" ;; + esac +done < <(find "$BUNDLE_DIR" -mindepth 2 -maxdepth 2 -type f -print) + +verify_appimage_signature() { + local config="$APP_DIR/src-tauri/tauri.conf.json" + local encoded_key key_text key_comment key_body extra_line + local key_hex key_id_raw decoded_signature + + encoded_key="$(jq -er '.plugins.updater.pubkey // empty' "$config")" \ + || die "updater public key is missing from $config" + [[ -n "$encoded_key" ]] || die "updater public key is empty in $config" + key_text="$(printf '%s' "$encoded_key" | base64 --decode 2>/dev/null)" \ + || die "updater public key is not valid base64" + + key_comment="$(sed -n '1p' <<<"$key_text")" + key_body="$(sed -n '2p' <<<"$key_text")" + extra_line="$(sed -n '3p' <<<"$key_text")" + [[ "$key_body" =~ ^[A-Za-z0-9+/]+={0,2}$ ]] \ + || die "updater public key has an invalid minisign body" + + # The comment is explicitly untrusted metadata. Keep it for diagnostics, + # but never use it as an acceptance gate. The decoded body is the Minisign + # public-key structure: Ed marker + 8-byte little-endian key id + 32-byte + # public key. + key_hex="$(printf '%s' "$key_body" | base64 --decode 2>/dev/null | od -An -tx1 -v | tr -d '[:space:]')" \ + || die "updater public key body is not valid base64" + [[ "$key_hex" =~ ^[[:xdigit:]]{84}$ ]] \ + || die "updater public key body must decode to 42 bytes" + [[ "${key_hex:0:4}" == "4564" ]] \ + || die "updater public key body has an invalid Ed25519 marker" + key_id_raw="${key_hex:4:16}" + [[ "$key_id_raw" =~ ^[[:xdigit:]]{16}$ ]] \ + || die "updater public key body is missing its 8-byte key id" + + echo "Minisign public-key comment: ${key_comment:-}" + if [[ "$key_comment" == "untrusted comment: "* ]]; then + echo "Minisign public-key comment prefix recognized" + else + echo "warning: non-standard Minisign public-key comment; ignoring untrusted comment" + fi + [[ -z "$extra_line" ]] || echo "warning: extra public-key comment lines are ignored" + + decoded_signature="$tmp_dir/appimage.minisig" + echo "Verifying AppImage minisign signature with configured public key body id bytes $key_id_raw" + if ! verify_minisign_appimage_signature "$appimage" "$signature" "$key_body" "$decoded_signature"; then + die "AppImage minisign verification failed (invalid Base64, empty, foreign, or invalid signature)" + fi +} + +verify_appimage_signature + +depends="$(dpkg-deb -f "${deb_files[0]}" Depends)" +echo "Debian Depends: $depends" +grep -Eq '(^|,|[[:space:]])sqlite3([[:space:](<>=]|,|$)' <<<"$depends" \ + || die "Debian Depends does not include sqlite3" +# Tauri normally adds the runtime GTK/WebKit dependencies from its Linux +# bundler. Keep this assertion so a toolchain change cannot ship a package +# that only fails on a clean machine; add explicit deb.depends entries if it +# ever trips. +grep -Eiq 'libwebkit2gtk' <<<"$depends" \ + || die "Debian Depends is missing a libwebkit2gtk runtime dependency" +grep -Eiq 'libgtk-3' <<<"$depends" \ + || die "Debian Depends is missing a libgtk-3 runtime dependency" + +deb_root="$tmp_dir/deb" +mkdir -p "$deb_root" +dpkg-deb -x "${deb_files[0]}" "$deb_root" +if ! verify_core_tree_modes "$source_core" "$deb_root" "deb" "$tmp_dir/core-modes"; then + die "deb: agmsg-core files or execution bits were not preserved" +fi + +appimage_root="$tmp_dir/appimage" +mkdir -p "$appimage_root" +if ! (cd "$appimage_root" && "$appimage" --appimage-extract >/dev/null); then + command -v unsquashfs >/dev/null 2>&1 \ + || die "AppImage extraction failed and unsquashfs is unavailable" + unsquashfs -d "$appimage_root/squashfs-root" "$appimage" >/dev/null +fi +[[ -d "$appimage_root/squashfs-root" ]] \ + || die "AppImage squashfs extraction did not produce squashfs-root" +if ! verify_core_tree_modes "$source_core" "$appimage_root/squashfs-root" "AppImage" "$tmp_dir/core-modes"; then + die "AppImage: agmsg-core files or execution bits were not preserved" +fi + +echo "verify-linux-bundles: OK" +echo " deb: ${deb_files[0]}" +echo " AppImage: $appimage" +echo " signature: $signature" diff --git a/app/scripts/verify-minisign-appimage.sh b/app/scripts/verify-minisign-appimage.sh new file mode 100755 index 000000000..59f0046d7 --- /dev/null +++ b/app/scripts/verify-minisign-appimage.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Verify a Tauri updater signature stored as Base64 in an AppImage .sig file. +# +# Tauri writes the four-line minisign signature as one Base64 string. The +# minisign CLI accepts the decoded four-line representation, so callers pass +# a path inside their existing temporary directory for the decoded file. + +verify_minisign_appimage_signature() { + local appimage="$1" + local signature="$2" + local key_body="$3" + local decoded_signature="$4" + local signature_base64 reencoded_signature + + if ! signature_base64="$(<"$signature")"; then + return 1 + fi + [[ "$signature_base64" =~ ^[A-Za-z0-9+/]+={0,2}$ ]] || return 1 + (( ${#signature_base64} % 4 == 0 )) || return 1 + + if ! printf '%s' "$signature_base64" | base64 --decode >"$decoded_signature" 2>/dev/null; then + return 1 + fi + [[ -s "$decoded_signature" ]] || return 1 + + if ! reencoded_signature="$(base64 --wrap=0 "$decoded_signature")"; then + return 1 + fi + [[ "$reencoded_signature" == "$signature_base64" ]] || return 1 + + minisign -Vm "$appimage" -x "$decoded_signature" -P "$key_body" >/dev/null 2>&1 +} diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 9ff011ec9..7377e14c2 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -9,7 +9,6 @@ "dialog:default", "core:webview:allow-set-webview-zoom", "core:window:allow-start-dragging", - "updater:default", "process:default" ] } diff --git a/app/src-tauri/src/agmsg.rs b/app/src-tauri/src/agmsg.rs index bf9f3ff76..88e6105d0 100644 --- a/app/src-tauri/src/agmsg.rs +++ b/app/src-tauri/src/agmsg.rs @@ -188,10 +188,12 @@ fn bash_command() -> Result { const CREATE_NO_WINDOW: u32 = 0x08000000; cmd.creation_flags(CREATE_NO_WINDOW); } - // Explicitly attach the PATH import_login_shell_path() resolved at - // startup (lib.rs), same reasoning as pty::pty_spawn: don't rely on this - // child implicitly inheriting the process's own (mutated) environment. - // No-op on Windows / if the import never ran or failed. + // Linux child commands receive the AppImage sanitizer and the explicit + // login-shell PATH override. Other platforms retain their existing PATH + // propagation without carrying Linux-only sanitizer code. + #[cfg(target_os = "linux")] + crate::apply_appimage_env_to_command(&mut cmd, crate::imported_path()); + #[cfg(not(target_os = "linux"))] if let Some(path) = crate::imported_path() { cmd.env("PATH", path); } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 23e8b2620..dc9fb7340 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -8,11 +8,12 @@ use serde::Serialize; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use tauri::menu::{AboutMetadataBuilder, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::utils::config::BundleType; use tauri::{AppHandle, Emitter, Manager, Wry}; -/// The PATH import_login_shell_path() resolved, kept around so every spawn -/// site (pty::pty_spawn, agmsg::bash_command) can attach it to the child -/// process explicitly via .env("PATH", ...) rather than relying on the +/// The PATH import_login_shell_path() resolved, kept around so long-lived +/// child launch sites (pty::pty_spawn, agmsg::bash_command) can attach it via +/// .env("PATH", ...) rather than relying on the /// spawned process implicitly inheriting this process's own (mutated) /// environment — a real Finder-launch hardware failure persisted even with /// the process-level std::env::set_var in place, so this makes the @@ -25,6 +26,230 @@ pub(crate) fn imported_path() -> Option<&'static str> { IMPORTED_PATH.get().map(|s| s.as_str()) } +/// The child-process action for an AppImage-injected environment variable. +/// Keeping the action explicit distinguishes an unchanged inherited variable +/// from an intentional `env_remove`. +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum AppImageEnvAction { + Unchanged, + Set(String), + Remove, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AppImageEnvKind { + PathList, + SinglePath, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AppImageEnvSpec { + pub(crate) name: &'static str, + pub(crate) kind: AppImageEnvKind, +} + +pub(crate) const APPIMAGE_PATH_ENV: AppImageEnvSpec = AppImageEnvSpec { + name: "PATH", + kind: AppImageEnvKind::PathList, +}; + +pub(crate) const APPIMAGE_CHILD_ENV_SPECS: [AppImageEnvSpec; 4] = [ + AppImageEnvSpec { + name: "LD_LIBRARY_PATH", + kind: AppImageEnvKind::PathList, + }, + AppImageEnvSpec { + name: "PYTHONPATH", + kind: AppImageEnvKind::PathList, + }, + AppImageEnvSpec { + name: "PYTHONHOME", + kind: AppImageEnvKind::SinglePath, + }, + AppImageEnvSpec { + name: "PERLLIB", + kind: AppImageEnvKind::PathList, + }, +]; + +fn valid_appdir(appdir: Option<&str>) -> Option<&str> { + let appdir = appdir?; + let trimmed = appdir.trim_end_matches('/'); + // APPDIR is an AppImage/Unix path; do not use the host OS path grammar. + if trimmed.is_empty() + || trimmed == "/" + || !trimmed.starts_with('/') + { + return None; + } + Some(appdir) +} + +fn is_appdir_path(path: &str, appdir: &str) -> bool { + let root = appdir.trim_end_matches('/'); + if root.is_empty() { + return false; + } + path == root || path.strip_prefix(root).is_some_and(|suffix| suffix.starts_with('/')) +} + +/// Decide how a child should receive one of the environment variables injected +/// by an AppImage launcher. The fixed allowlist came from inspecting +/// `squashfs-root/AppRun` and `apprun-hooks` (LD_LIBRARY_PATH, PYTHONPATH, +/// PYTHONHOME, PERLLIB, and PATH); re-derive it if the Tauri CLI or linuxdeploy +/// plugins are upgraded. +/// +/// LD_LIBRARY_PATH, PYTHONPATH, PERLLIB, and PATH are colon-separated lists, +/// so only APPDIR-rooted entries are removed. PYTHONHOME is a single prefix +/// value, not a list: if it points into APPDIR, remove the whole variable. +/// APPDIR-rooted entries, empty elements, and empty values are removed; an empty +/// list element may otherwise be interpreted as a search of the current directory. +/// The caller applies the returned action to the child `Command`/`CommandBuilder`; +/// this function never mutates the app process environment. +pub(crate) fn sanitize_appimage_env( + spec: AppImageEnvSpec, + value: Option<&str>, + appdir: Option<&str>, +) -> Option { + let Some(value) = value else { + return None; + }; + let Some(appdir) = valid_appdir(appdir) else { + return Some(value.to_owned()); + }; + if value.is_empty() { + return None; + } + if spec.kind == AppImageEnvKind::SinglePath { + if is_appdir_path(value, appdir) { + return None; + } + return Some(value.to_owned()); + } + + let mut removed = false; + let filtered = value + .split(':') + .filter(|path| { + let keep = !path.is_empty() && !is_appdir_path(path, appdir); + removed |= !keep; + keep + }) + .collect::>() + .join(":"); + if removed && filtered.is_empty() { + None + } else if removed { + Some(filtered) + } else { + Some(value.to_owned()) + } +} + +/// Read one AppImage launcher variable and turn its sanitized value into a +/// Read one AppImage launcher variable and turn its sanitized value into a +/// child-only action. Linux callers enter this path only when APPDIR is a valid +/// absolute, non-root runtime directory; other platforms do not call it. +pub(crate) fn appimage_env_action_for_context( + enabled: bool, + spec: AppImageEnvSpec, + value: Option<&str>, + appdir: Option<&str>, +) -> AppImageEnvAction { + if !enabled { + return AppImageEnvAction::Unchanged; + } + let Some(appdir) = valid_appdir(appdir) else { + return AppImageEnvAction::Unchanged; + }; + let Some(value) = value else { + return AppImageEnvAction::Unchanged; + }; + match sanitize_appimage_env(spec, Some(value), Some(appdir)) { + Some(sanitized) if sanitized == value => AppImageEnvAction::Unchanged, + Some(sanitized) => AppImageEnvAction::Set(sanitized), + None => AppImageEnvAction::Remove, + } +} + +#[cfg(target_os = "linux")] +pub(crate) fn for_each_appimage_env_action( + imported_path: Option<&str>, + apply: impl FnMut(AppImageEnvSpec, AppImageEnvAction), +) { + let appdir = std::env::var("APPDIR").ok(); + let inherited_path = std::env::var("PATH").ok(); + for_each_appimage_env_action_for_context( + valid_appdir(appdir.as_deref()).is_some(), + appdir.as_deref(), + inherited_path.as_deref(), + imported_path, + apply, + ); +} + +#[cfg(target_os = "linux")] +/// Build child environment actions from an explicit Linux/AppImage context. +/// A supplied `imported_path` remains an explicit PATH override even when +/// sanitization leaves its value unchanged. When it is absent, the separate +/// `inherited_path` value is sanitized and keeps `Unchanged` semantics only +/// when no filtering is needed (or PATH itself is absent). +pub(crate) fn for_each_appimage_env_action_for_context( + enabled: bool, + appdir: Option<&str>, + inherited_path: Option<&str>, + imported_path: Option<&str>, + mut apply: impl FnMut(AppImageEnvSpec, AppImageEnvAction), +) { + let path_action = if let Some(path) = imported_path { + match appimage_env_action_for_context(enabled, APPIMAGE_PATH_ENV, Some(path), appdir) { + AppImageEnvAction::Unchanged if path.is_empty() => AppImageEnvAction::Remove, + AppImageEnvAction::Unchanged => AppImageEnvAction::Set(path.to_owned()), + action => action, + } + } else { + appimage_env_action_for_context(enabled, APPIMAGE_PATH_ENV, inherited_path, appdir) + }; + apply(APPIMAGE_PATH_ENV, path_action); + for spec in APPIMAGE_CHILD_ENV_SPECS { + let value = std::env::var(spec.name).ok(); + apply( + spec, + appimage_env_action_for_context(enabled, spec, value.as_deref(), appdir), + ); + } +} + +#[cfg(target_os = "linux")] +fn apply_appimage_env_action( + command: &mut std::process::Command, + spec: AppImageEnvSpec, + action: AppImageEnvAction, +) { + match action { + AppImageEnvAction::Unchanged => {} + AppImageEnvAction::Set(value) => { + command.env(spec.name, value); + } + AppImageEnvAction::Remove => { + command.env_remove(spec.name); + } + } +} + +/// Apply the Linux AppImage child-environment policy to a native command. +/// `imported_path` is the explicit login-shell PATH override used by the +/// long-lived child launchers; `None` means sanitize the inherited PATH. +#[cfg(target_os = "linux")] +pub(crate) fn apply_appimage_env_to_command( + command: &mut std::process::Command, + imported_path: Option<&str>, +) { + for_each_appimage_env_action(imported_path, |spec, action| { + apply_appimage_env_action(command, spec, action); + }); +} + /// The native menu's current language (BCP-47 code, e.g. "ja", "zh-CN") — /// the frontend pushes its i18next language here via `set_menu_language` on /// startup and on every change, since React's i18n can't reach this @@ -104,18 +329,21 @@ fn save_zoom(app: &AppHandle, zoom: f64) { /// (MOTD, prompts) — wrapping the $PATH readout in unique markers and /// extracting just what's between them keeps that noise from corrupting it. #[cfg(unix)] -fn import_login_shell_path() { +fn import_login_shell_path(app: &AppHandle) { const START: &str = "__AGMSG_PATH_START__"; const END: &str = "__AGMSG_PATH_END__"; let shell = resolve_login_shell(); - log_path_import(&format!("resolved login shell: {shell}")); + log_path_import(app, &format!("resolved login shell: {shell}")); let script = format!("printf '{START}%s{END}' \"$PATH\""); - let output = match std::process::Command::new(&shell).args(["-ilc", &script]).output() { + let mut command = std::process::Command::new(&shell); + #[cfg(target_os = "linux")] + apply_appimage_env_to_command(&mut command, None); + let output = match command.args(["-ilc", &script]).output() { Ok(o) => o, Err(e) => { let msg = format!("couldn't run login shell ({shell}) to import PATH: {e}"); eprintln!("warning: {msg}"); - log_path_import(&msg); + log_path_import(app, &msg); return; } }; @@ -129,7 +357,7 @@ fn import_login_shell_path() { }); match parsed { Some(path) if !path.is_empty() => { - log_path_import(&format!("imported PATH: {path}")); + log_path_import(app, &format!("imported PATH: {path}")); std::env::set_var("PATH", path); let _ = IMPORTED_PATH.set(path.to_string()); } @@ -139,42 +367,141 @@ fn import_login_shell_path() { stdout.trim() ); eprintln!("warning: {msg}"); - log_path_import(&msg); + log_path_import(app, &msg); } } } -/// Resolves the user's login shell for import_login_shell_path() above. -/// $SHELL isn't reliably set for a Finder/LaunchServices-launched GUI -/// process — confirmed on real hardware: present in the same user's -/// Terminal session, absent (or stale) when the app itself is launched via -/// Finder. `dscl` asks Directory Services directly for the account's -/// configured shell, independent of whatever this process's own -/// environment happens to have inherited. /bin/zsh (macOS's default shell -/// since Catalina) is the last-resort fallback if even that comes up empty. -#[cfg(unix)] -fn resolve_login_shell() -> String { - if let Ok(s) = std::env::var("SHELL") { - if !s.is_empty() { - return s; +/// Selects the first non-empty shell source in the platform-specific +/// priority order. The callers gather the sources (environment, account +/// database, and fallback files) outside this function so the precedence can +/// be tested without invoking external commands or reading the host system. +/// +/// The production Linux/macOS priority is owned by +/// `resolve_login_shell_with_probes`; this helper is only the final pure +/// fold over already-probed values (D-9). +#[cfg_attr(not(unix), allow(dead_code))] +fn select_login_shell( + shell: Option<&str>, + account_shell: Option<&str>, + passwd_shell: Option<&str>, + fallback: &str, +) -> String { + [shell, account_shell, passwd_shell] + .into_iter() + .flatten() + .find(|candidate| !candidate.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +/// Extracts the login shell (the seventh colon-separated field) for `user` +/// from a passwd/getent response. Keeping this parser pure also lets the +/// Linux lookup order be tested with representative records. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn passwd_shell_for_user(contents: &str, user: &str) -> Option { + contents.lines().find_map(|line| { + let mut fields = line.split(':'); + let name = fields.next()?; + if name != user { + return None; } + fields.nth(5).filter(|shell| !shell.is_empty()).map(str::to_owned) + }) +} + +/// Resolves a login shell while keeping account-file probes lazy. A process +/// launched from a terminal normally has a valid `$SHELL`; in that case no +/// `getent`, `dscl`, or passwd-file lookup should run at all. When `$SHELL` is +/// empty, the account probe wins and the fallback-file probe runs only when +/// the account probe has no usable value. The closures make those guarantees +/// observable in unit tests without shelling out from the test process. +#[cfg_attr(not(unix), allow(dead_code))] +fn resolve_login_shell_with_probes( + shell: Option<&str>, + account_probe: AccountProbe, + passwd_probe: PasswdProbe, + fallback: &str, +) -> String +where + AccountProbe: FnOnce() -> Option, + PasswdProbe: FnOnce() -> Option, +{ + if let Some(shell) = shell.filter(|value| !value.is_empty()) { + return shell.to_string(); + } + let account_shell = account_probe().filter(|value| !value.is_empty()); + if let Some(shell) = account_shell { + return shell; } + let passwd_shell = passwd_probe().filter(|value| !value.is_empty()); + select_login_shell(None, None, passwd_shell.as_deref(), fallback) +} + +/// Resolves the user's login shell for import_login_shell_path() on Linux. +/// Ubuntu does not provide macOS's `dscl`; use the account's configured shell +/// from `getent`, then `/etc/passwd`, before the stable `/bin/bash` fallback. +#[cfg(target_os = "linux")] +fn resolve_login_shell() -> String { + let shell = std::env::var("SHELL").ok().filter(|value| !value.is_empty()); let user = std::env::var("USER").unwrap_or_default(); - if !user.is_empty() { - if let Ok(output) = - std::process::Command::new("dscl").args([".", "-read", &format!("/Users/{user}"), "UserShell"]).output() - { - if output.status.success() { - let text = String::from_utf8_lossy(&output.stdout); - if let Some(shell) = text.trim().strip_prefix("UserShell: ") { - if !shell.is_empty() { - return shell.to_string(); - } - } + resolve_login_shell_with_probes( + shell.as_deref(), + || { + if user.is_empty() { + return None; } - } - } - "/bin/zsh".into() + let mut command = std::process::Command::new("getent"); + apply_appimage_env_to_command(&mut command, None); + command + .args(["passwd", &user]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| passwd_shell_for_user(&String::from_utf8_lossy(&output.stdout), &user)) + }, + || { + if user.is_empty() { + return None; + } + std::fs::read_to_string("/etc/passwd") + .ok() + .and_then(|contents| passwd_shell_for_user(&contents, &user)) + }, + "/bin/bash", + ) +} + +/// Resolves the user's login shell for import_login_shell_path() on macOS. +/// `$SHELL` remains the fastest path; `dscl` reads the configured account +/// shell when a Finder-launched process did not inherit that variable, and +/// `/bin/zsh` preserves the existing macOS fallback. +#[cfg(all(unix, not(target_os = "linux")))] +fn resolve_login_shell() -> String { + let shell = std::env::var("SHELL").ok().filter(|value| !value.is_empty()); + let user = std::env::var("USER").unwrap_or_default(); + resolve_login_shell_with_probes( + shell.as_deref(), + || { + if user.is_empty() { + return None; + } + std::process::Command::new("dscl") + .args([".", "-read", &format!("/Users/{user}"), "UserShell"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| { + let text = String::from_utf8_lossy(&output.stdout); + text.trim() + .strip_prefix("UserShell: ") + .filter(|shell| !shell.is_empty()) + .map(str::to_owned) + }) + }, + || None, + "/bin/zsh", + ) } /// What to spawn for the free-shell tab (App.tsx's "+" tab and a tab's "Open @@ -213,22 +540,54 @@ fn login_shell() -> LoginShellInfo { } } -/// Appends a timestamped line to ~/Library/Logs/agmsg/path-import.log. The -/// only real diagnostic available for import_login_shell_path(): it runs -/// before the webview (and thus DevTools) exists, and its failure mode was -/// otherwise silent — a prior Finder-launch gate failure took a slow -/// back-and-forth to root-cause because all it did on failure was warn to -/// stderr, which nothing launched from Finder is around to see. +/// Resolves the path-import log file without touching Tauri state. macOS keeps +/// the existing `~/Library/Logs/agmsg` path; Linux uses the directory Tauri +/// derives from the app identifier (`app_log_dir`). +#[cfg_attr(not(unix), allow(dead_code))] +fn path_import_log_path( + is_linux: bool, + home: &std::path::Path, + app_log_dir: Option<&std::path::Path>, +) -> Option { + if is_linux { + app_log_dir.map(|dir| dir.join("path-import.log")) + } else { + Some(home.join("Library/Logs/agmsg/path-import.log")) + } +} + +/// Appends a timestamped line to the platform-specific path-import log. This +/// runs before the webview (and thus DevTools) exists, so the file is the only +/// durable diagnostic for a failed login-shell PATH import. #[cfg(unix)] -fn log_path_import(message: &str) { - let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); - let dir = std::path::PathBuf::from(home).join("Library/Logs/agmsg"); - if std::fs::create_dir_all(&dir).is_err() { +fn log_path_import(app: &AppHandle, message: &str) { + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + #[cfg(target_os = "linux")] + let app_log_dir = match app.path().app_log_dir() { + Ok(dir) => Some(dir), + Err(error) => { + eprintln!("warning: couldn't resolve Linux app log directory: {error}"); + None + } + }; + #[cfg(not(target_os = "linux"))] + // Keep the cfg-only fallback's type explicit: macOS compiles this branch + // without the Linux `app_log_dir()` expression that would otherwise + // provide inference (E0282 under the macOS CI target). + let app_log_dir: Option = None; + let Some(path) = path_import_log_path(cfg!(target_os = "linux"), &home, app_log_dir.as_deref()) else { return; + }; + if let Some(dir) = path.parent() { + if std::fs::create_dir_all(dir).is_err() { + return; + } } use std::io::Write; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("path-import.log")) { + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) { let _ = writeln!(f, "[{now}] {message}"); } } @@ -279,8 +638,40 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu .build(), ), )?; - let check_updates = - MenuItem::with_id(app, CHECK_UPDATES_ID, m("checkForUpdates"), true, None::<&str>)?; + let check_updates = MenuItem::with_id( + app, + CHECK_UPDATES_ID, + m("checkForUpdates"), + updater_enabled_for_current_platform(), + None::<&str>, + )?; + #[cfg(target_os = "linux")] + let app_menu = Submenu::with_items( + app, + name, + true, + &[ + &about, + &PredefinedMenuItem::separator(app)?, + &check_updates, + &PredefinedMenuItem::separator(app)?, + // GTK does not implement muda's predefined quit item reliably. + // Keep the operation visible as a regular item and handle it in + // on_menu_event below so startup and shutdown stay deterministic. + // Ctrl+Q is a terminal control character (and was observed to + // terminate the app while an xterm.js pane had focus), so the + // native accelerator deliberately uses the non-conflicting + // CmdOrCtrl+Shift+Q chord (D-10). + &MenuItem::with_id( + app, + QUIT_MENU_ID, + m_name("quit"), + true, + Some("CmdOrCtrl+Shift+Q"), + )?, + ], + )?; + #[cfg(not(target_os = "linux"))] let app_menu = Submenu::with_items( app, name, @@ -299,6 +690,7 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu &PredefinedMenuItem::quit(app, Some(&m_name("quit")))?, ], )?; + #[cfg(not(target_os = "linux"))] let edit_menu = Submenu::with_items( app, m("editMenu"), @@ -362,6 +754,7 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu &MenuItem::with_id(app, ZOOM_RESET_ID, m("actualSize"), true, Some("CmdOrCtrl+0"))?, ], )?; + #[cfg(not(target_os = "linux"))] let window_menu = Submenu::with_items( app, m("windowMenu"), @@ -372,6 +765,18 @@ fn make_menu(app: &AppHandle, lang: &str) -> tauri::Result<(Menu, CheckMenu &PredefinedMenuItem::close_window(app, Some(&m("closeWindow")))?, ], )?; + #[cfg(target_os = "linux")] + // Linux intentionally has no native Edit submenu. muda's GTK + // PredefinedMenuItem edit actions are inert without its optional libxdo + // feature, while enabling that feature would register accelerators that + // can steal terminal control bytes. xterm.js supplies the tested + // right-click Copy/Paste, primary-selection middle-click, and + // Ctrl+Shift+C/V paths instead (Option B). + // The Window submenu is likewise omitted on Linux (D-3); its only prior + // entries were minimize/close_window, which GTK exposes through the + // window manager and the title bar without an app-menu replacement. + let menu = Menu::with_items(app, &[&app_menu, &view_menu])?; + #[cfg(not(target_os = "linux"))] let menu = Menu::with_items(app, &[&app_menu, &edit_menu, &view_menu, &window_menu])?; Ok((menu, team_room_item, user_chat_item)) } @@ -382,15 +787,43 @@ const ZOOM_IN_ID: &str = "zoom_in"; const ZOOM_OUT_ID: &str = "zoom_out"; const ZOOM_RESET_ID: &str = "zoom_reset"; const CHECK_UPDATES_ID: &str = "check_updates"; +const QUIT_MENU_ID: &str = "quit_app"; const PANE_LAYOUT_VERTICAL_ID: &str = "pane_layout_vertical"; const PANE_LAYOUT_HORIZONTAL_ID: &str = "pane_layout_horizontal"; const PANE_LAYOUT_TILE_ID: &str = "pane_layout_tile"; +/// Linux updates are a product policy: only AppImage bundles may update +/// themselves. Other platforms retain their existing unconditional updater +/// behavior, so the OS check is an explicit input rather than inferred from +/// the bundle variant (which also contains macOS and Windows variants). +fn updater_allowed(is_linux: bool, bundle: Option) -> bool { + !is_linux || matches!(bundle, Some(BundleType::AppImage)) +} + +#[cfg(target_os = "linux")] +fn updater_enabled_for_current_platform() -> bool { + updater_allowed(true, tauri::utils::platform::bundle_type()) +} + +#[cfg(not(target_os = "linux"))] +fn updater_enabled_for_current_platform() -> bool { + // Keep macOS and Windows updater behavior unchanged. Their bundle type + // variants must not be passed through Linux's AppImage-only policy. + updater_allowed(false, None) +} + /// Check the updater endpoint and, if a newer build is available, confirm /// with the user before downloading, installing, and restarting. When /// `user_initiated` is true (menu click) we also report "up to date" / /// errors; a silent startup check stays quiet unless there's an update. async fn check_for_updates(app: &AppHandle, user_initiated: bool) { + // A non-AppImage Linux build has no updater path. Keep the menu disabled + // as the normal guard, and retain this check for startup or programmatic + // callers so raw updater metadata errors never reach the user. + if !updater_enabled_for_current_platform() { + return; + } + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_updater::UpdaterExt; @@ -588,6 +1021,8 @@ pub fn run() { _ => "tile", }; let _ = app.emit("set-pane-layout", layout); + } else if id == QUIT_MENU_ID { + app.exit(0); } else if id == CHECK_UPDATES_ID { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { @@ -606,7 +1041,7 @@ pub fn run() { // Windows doesn't have this problem (PATH comes from the // registry regardless of launch method), hence unix-only. #[cfg(unix)] - import_login_shell_path(); + import_login_shell_path(app.handle()); // Restore the zoom level saved on the last quit/change — .manage() // above only had 1.0 to work with (no AppHandle yet to read the @@ -621,10 +1056,14 @@ pub fn run() { app.state::().start_detection_tick(app.handle().clone()); // Quiet startup check — only surfaces a dialog when an update is // actually available (see check_for_updates's user_initiated flag). - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - check_for_updates(&app_handle, false).await; - }); + // Linux deb/dev builds are intentionally excluded by the product + // policy; AppImage and existing non-Linux paths remain enabled. + if updater_enabled_for_current_platform() { + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + check_for_updates(&app_handle, false).await; + }); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -658,3 +1097,407 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::{ + appimage_env_action_for_context, passwd_shell_for_user, path_import_log_path, + resolve_login_shell_with_probes, sanitize_appimage_env, select_login_shell, updater_allowed, + AppImageEnvAction, APPIMAGE_CHILD_ENV_SPECS, APPIMAGE_PATH_ENV, + }; + use tauri::utils::config::BundleType; + use std::cell::Cell; + use std::path::Path; + + #[cfg(target_os = "linux")] + use super::for_each_appimage_env_action_for_context; + + #[test] + fn login_shell_precedence_is_environment_then_account_then_passwd_then_fallback() { + assert_eq!( + select_login_shell(Some("/bin/zsh"), Some("/bin/fish"), Some("/bin/bash"), "/bin/sh"), + "/bin/zsh" + ); + assert_eq!( + select_login_shell(Some(""), Some("/bin/fish"), Some("/bin/bash"), "/bin/sh"), + "/bin/fish" + ); + assert_eq!( + select_login_shell(None, Some(""), Some("/bin/bash"), "/bin/sh"), + "/bin/bash" + ); + assert_eq!(select_login_shell(None, None, None, "/bin/sh"), "/bin/sh"); + } + + #[test] + fn login_shell_probes_are_lazy_and_short_circuit() { + let getent_calls = Cell::new(0); + let passwd_calls = Cell::new(0); + let shell = resolve_login_shell_with_probes( + Some("/bin/zsh"), + || { + getent_calls.set(getent_calls.get() + 1); + Some("/bin/fish".into()) + }, + || { + passwd_calls.set(passwd_calls.get() + 1); + Some("/bin/bash".into()) + }, + "/bin/sh", + ); + assert_eq!(shell, "/bin/zsh"); + assert_eq!(getent_calls.get(), 0, "SHELL must avoid the account probe"); + assert_eq!(passwd_calls.get(), 0, "SHELL must avoid the passwd probe"); + + let getent_calls = Cell::new(0); + let passwd_calls = Cell::new(0); + let shell = resolve_login_shell_with_probes( + None, + || { + getent_calls.set(getent_calls.get() + 1); + Some("/bin/fish".into()) + }, + || { + passwd_calls.set(passwd_calls.get() + 1); + Some("/bin/bash".into()) + }, + "/bin/sh", + ); + assert_eq!(shell, "/bin/fish"); + assert_eq!(getent_calls.get(), 1); + assert_eq!(passwd_calls.get(), 0, "a valid getent result must avoid passwd"); + + let getent_calls = Cell::new(0); + let passwd_calls = Cell::new(0); + let shell = resolve_login_shell_with_probes( + None, + || { + getent_calls.set(getent_calls.get() + 1); + Some(String::new()) + }, + || { + passwd_calls.set(passwd_calls.get() + 1); + Some("/bin/bash".into()) + }, + "/bin/sh", + ); + assert_eq!(shell, "/bin/bash"); + assert_eq!(getent_calls.get(), 1); + assert_eq!(passwd_calls.get(), 1, "an empty getent result must fall back to passwd"); + } + + #[test] + fn passwd_shell_parser_reads_the_seventh_field_for_the_requested_user() { + let records = "root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/fish\n"; + assert_eq!(passwd_shell_for_user(records, "alice"), Some("/bin/fish".into())); + assert_eq!(passwd_shell_for_user(records, "nobody"), None); + } + + #[test] + fn macos_log_path_keeps_the_existing_location() { + let home = Path::new("/home/alice"); + let app_log_dir = Path::new("/home/alice/.local/share/cc.agmsg.app/logs"); + assert_eq!( + path_import_log_path(false, home, Some(app_log_dir)), + Some(Path::new("/home/alice/Library/Logs/agmsg/path-import.log").into()) + ); + } + + #[test] + fn linux_log_path_uses_tauri_app_log_dir() { + let home = Path::new("/home/alice"); + let app_log_dir = Path::new("/home/alice/.local/share/cc.agmsg.app/logs"); + assert_eq!( + path_import_log_path(true, home, Some(app_log_dir)), + Some(Path::new("/home/alice/.local/share/cc.agmsg.app/logs/path-import.log").into()) + ); + assert_eq!(path_import_log_path(true, home, None), None); + } + + #[test] + fn updater_is_appimage_only_on_linux_and_unrestricted_elsewhere() { + let cases = [ + ("Deb", Some(BundleType::Deb), false), + ("Rpm", Some(BundleType::Rpm), false), + ("AppImage", Some(BundleType::AppImage), true), + ("Msi", Some(BundleType::Msi), false), + ("Nsis", Some(BundleType::Nsis), false), + ("App", Some(BundleType::App), false), + ("Dmg", Some(BundleType::Dmg), false), + ("None", None, false), + ]; + + for (label, bundle, linux_expected) in cases { + assert_eq!( + updater_allowed(true, bundle.clone()), + linux_expected, + "Linux updater policy mismatch for {label}" + ); + assert!( + updater_allowed(false, bundle), + "non-Linux updater policy unexpectedly disabled for {label}" + ); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn imported_path_is_explicitly_set_without_appdir() { + let mut path_action = None; + for_each_appimage_env_action_for_context( + false, + None, + None, + Some("/usr/local/bin:/usr/bin"), + |spec, action| { + if spec == APPIMAGE_PATH_ENV { + path_action = Some(action); + } + }, + ); + assert_eq!( + path_action, + Some(AppImageEnvAction::Set("/usr/local/bin:/usr/bin".into())) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn imported_host_path_is_explicitly_set_with_valid_appdir() { + let mut path_action = None; + for_each_appimage_env_action_for_context( + true, + Some("/tmp/.mount_agmsg"), + None, + Some("/usr/local/bin:/usr/bin"), + |spec, action| { + if spec == APPIMAGE_PATH_ENV { + path_action = Some(action); + } + }, + ); + assert_eq!( + path_action, + Some(AppImageEnvAction::Set("/usr/local/bin:/usr/bin".into())) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn imported_path_is_filtered_and_explicitly_set_with_valid_appdir() { + let mut path_action = None; + for_each_appimage_env_action_for_context( + true, + Some("/tmp/.mount_agmsg"), + None, + Some("/tmp/.mount_agmsg/usr/bin:/usr/bin"), + |spec, action| { + if spec == APPIMAGE_PATH_ENV { + path_action = Some(action); + } + }, + ); + assert_eq!( + path_action, + Some(AppImageEnvAction::Set("/usr/bin".into())) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn inherited_path_is_sanitized_when_imported_path_is_absent() { + let mut path_action = None; + for_each_appimage_env_action_for_context( + true, + Some("/tmp/.mount_agmsg"), + Some("/tmp/.mount_agmsg/usr/bin:/usr/bin"), + None, + |spec, action| { + if spec == APPIMAGE_PATH_ENV { + path_action = Some(action); + } + }, + ); + assert_eq!(path_action, Some(AppImageEnvAction::Set("/usr/bin".into()))); + } + + #[cfg(target_os = "linux")] + #[test] + fn inherited_host_path_stays_unchanged_when_imported_path_is_absent() { + let mut path_action = None; + for_each_appimage_env_action_for_context( + true, + Some("/tmp/.mount_agmsg"), + Some("/usr/local/bin:/usr/bin"), + None, + |spec, action| { + if spec == APPIMAGE_PATH_ENV { + path_action = Some(action); + } + }, + ); + assert_eq!(path_action, Some(AppImageEnvAction::Unchanged)); + } + + #[cfg(target_os = "linux")] + #[test] + fn absent_inherited_path_stays_unchanged_when_imported_path_is_absent() { + let mut path_action = None; + for_each_appimage_env_action_for_context( + true, + Some("/tmp/.mount_agmsg"), + None, + None, + |spec, action| { + if spec == APPIMAGE_PATH_ENV { + path_action = Some(action); + } + }, + ); + assert_eq!(path_action, Some(AppImageEnvAction::Unchanged)); + } + + #[test] + fn appimage_env_without_appdir_is_unchanged() { + assert_eq!( + sanitize_appimage_env(APPIMAGE_CHILD_ENV_SPECS[0], Some("/usr/lib"), None), + Some("/usr/lib".into()) + ); + assert_eq!( + sanitize_appimage_env(APPIMAGE_CHILD_ENV_SPECS[0], Some(""), None), + Some("".into()) + ); + for appdir in [Some(""), Some("relative/app"), Some("/")] { + assert_eq!( + sanitize_appimage_env( + APPIMAGE_CHILD_ENV_SPECS[0], + Some("/tmp/.mount_agmsg/usr/lib:/usr/lib"), + appdir, + ), + Some("/tmp/.mount_agmsg/usr/lib:/usr/lib".into()), + "invalid APPDIR must leave the value unchanged: {appdir:?}" + ); + } + } + + #[test] + fn appimage_env_removes_all_appdir_entries_from_colon_lists() { + let specs = [ + APPIMAGE_CHILD_ENV_SPECS[0], + APPIMAGE_CHILD_ENV_SPECS[1], + APPIMAGE_CHILD_ENV_SPECS[3], + APPIMAGE_PATH_ENV, + ]; + for spec in specs { + assert_eq!( + sanitize_appimage_env( + spec, + Some("/tmp/.mount_agmsg/usr/lib:/tmp/.mount_agmsg/lib"), + Some("/tmp/.mount_agmsg"), + ), + None, + "all AppImage entries must be removed for {}", + spec.name + ); + } + } + + #[test] + fn appimage_env_keeps_non_appdir_entries_in_colon_lists() { + let specs = [ + APPIMAGE_CHILD_ENV_SPECS[0], + APPIMAGE_CHILD_ENV_SPECS[1], + APPIMAGE_CHILD_ENV_SPECS[3], + APPIMAGE_PATH_ENV, + ]; + for spec in specs { + assert_eq!( + sanitize_appimage_env( + spec, + Some("/usr/lib:/tmp/.mount_agmsg/usr/lib:/opt/app"), + Some("/tmp/.mount_agmsg"), + ), + Some("/usr/lib:/opt/app".into()), + "mixed path filtering failed for {}", + spec.name + ); + } + assert_eq!( + sanitize_appimage_env( + APPIMAGE_CHILD_ENV_SPECS[0], + Some("/tmp/.mount_agmsg2/usr/lib"), + Some("/tmp/.mount_agmsg"), + ), + Some("/tmp/.mount_agmsg2/usr/lib".into()) + ); + assert_eq!( + appimage_env_action_for_context( + true, + APPIMAGE_CHILD_ENV_SPECS[0], + Some("/tmp/.mount_agmsg/usr/lib:/usr/lib"), + Some("/tmp/.mount_agmsg"), + ), + AppImageEnvAction::Set("/usr/lib".into()), + "valid APPDIR alone must enable child sanitization" + ); + } + + #[test] + fn appimage_env_removes_empty_values_and_pythonhome_as_a_whole() { + assert_eq!( + sanitize_appimage_env( + APPIMAGE_CHILD_ENV_SPECS[0], + Some(""), + Some("/tmp/.mount_agmsg"), + ), + None + ); + assert_eq!( + sanitize_appimage_env( + APPIMAGE_CHILD_ENV_SPECS[2], + Some("/tmp/.mount_agmsg/usr"), + Some("/tmp/.mount_agmsg"), + ), + None + ); + assert_eq!( + sanitize_appimage_env( + APPIMAGE_CHILD_ENV_SPECS[2], + Some("/usr"), + Some("/tmp/.mount_agmsg"), + ), + Some("/usr".into()) + ); + assert_eq!( + sanitize_appimage_env( + APPIMAGE_CHILD_ENV_SPECS[0], + Some("/usr/lib::/tmp/.mount_agmsg/usr/lib:"), + Some("/tmp/.mount_agmsg"), + ), + Some("/usr/lib".into()), + "empty list entries must not preserve current-directory lookup" + ); + } + + #[test] + fn appimage_env_does_not_generate_absent_variables() { + assert_eq!( + sanitize_appimage_env(APPIMAGE_CHILD_ENV_SPECS[0], None, Some("/tmp/.mount_agmsg")), + None + ); + assert_eq!( + sanitize_appimage_env(APPIMAGE_CHILD_ENV_SPECS[2], None, Some("/tmp/.mount_agmsg")), + None + ); + assert_eq!( + appimage_env_action_for_context( + false, + APPIMAGE_CHILD_ENV_SPECS[0], + Some("/tmp/.mount_agmsg/usr/lib"), + Some("/tmp/.mount_agmsg"), + ), + AppImageEnvAction::Unchanged, + "a disabled sanitizer context must leave APPDIR unchanged" + ); + } +} diff --git a/app/src-tauri/src/pty.rs b/app/src-tauri/src/pty.rs index 3f98d3576..25d1a2530 100644 --- a/app/src-tauri/src/pty.rs +++ b/app/src-tauri/src/pty.rs @@ -137,6 +137,19 @@ fn windows_shell_argv(cmd: &str, args: &[String]) -> Vec { argv } +#[cfg(target_os = "linux")] +fn apply_appimage_env_to_builder(builder: &mut CommandBuilder, imported_path: Option<&str>) { + crate::for_each_appimage_env_action(imported_path, |spec, action| match action { + crate::AppImageEnvAction::Unchanged => {} + crate::AppImageEnvAction::Set(value) => { + builder.env(spec.name, value); + } + crate::AppImageEnvAction::Remove => { + builder.env_remove(spec.name); + } + }); +} + /// Spawn `cmd args` in a fresh PTY and stream its output to the webview as /// `pty-output` events. Stores the session under `id`. #[tauri::command] @@ -185,15 +198,12 @@ pub fn pty_spawn( builder.cwd(dir); } builder.env("TERM", "xterm-256color"); - // Explicitly set PATH from what import_login_shell_path() resolved at - // startup (lib.rs) rather than relying on this child implicitly - // inheriting the process's own (mutated) environment — a real - // Finder-launch hardware gate still failed to find `claude`/`codex` even - // after that process-level import, so this removes any dependence on - // environment-inheritance behavior we can't fully control. No-op (falls - // back to whatever this process's own PATH already is) if the import - // never ran or failed, e.g. on Windows or if the login shell couldn't be - // queried. + // Linux child commands receive the AppImage sanitizer and the explicit + // login-shell PATH override. Other platforms retain their existing PATH + // propagation without carrying Linux-only sanitizer code. + #[cfg(target_os = "linux")] + apply_appimage_env_to_builder(&mut builder, crate::imported_path()); + #[cfg(not(target_os = "linux"))] if let Some(path) = crate::imported_path() { builder.env("PATH", path); } @@ -281,16 +291,23 @@ pub fn pty_resize( /// SIGHUP first (like closing a terminal, so a well-behaved CLI runs its /// shutdown hooks), then SIGKILL after a grace period if it's still alive. The /// reader thread reaps the child and emits pty-exit when it goes. +fn send_kill_signal(pid: &str, signal: &str) { + let mut command = std::process::Command::new("kill"); + #[cfg(target_os = "linux")] + crate::apply_appimage_env_to_command(&mut command, None); + let _ = command.arg(signal).arg(pid).status(); +} + #[tauri::command] pub fn pty_kill(manager: State<'_, PtyManager>, id: String) -> Result<(), String> { let pid = manager.sessions.lock().unwrap().remove(&id).and_then(|s| s.pid); if let Some(pid) = pid { let pid_s = pid.to_string(); - let _ = std::process::Command::new("kill").arg("-HUP").arg(&pid_s).status(); + send_kill_signal(&pid_s, "-HUP"); // Fallback: force-kill if it hasn't exited after a grace period. thread::spawn(move || { thread::sleep(Duration::from_secs(4)); - let _ = std::process::Command::new("kill").arg("-KILL").arg(&pid_s).status(); + send_kill_signal(&pid_s, "-KILL"); }); } Ok(()) diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index d9fd42fd3..70f487477 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -33,6 +33,7 @@ "resources": { "resources/agmsg-core": "agmsg-core" }, + "category": "Utility", "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -42,6 +43,11 @@ ], "macOS": { "signingIdentity": "Developer ID Application: Koichi Fujikawa (A6GJF6MMFV)" + }, + "linux": { + "deb": { + "depends": ["sqlite3"] + } } }, "plugins": { diff --git a/app/src/App.css b/app/src/App.css index daa7f6144..e684b3b05 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -34,6 +34,13 @@ body, font: 13px/1.5 -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; } +/* Linux gets an explicit Ubuntu-compatible UI stack. Keep the base stack + above unchanged so macOS and Windows retain their existing font choice. */ +.app.platform-linux, +.platform-select-popup.platform-linux { + font-family: system-ui, Ubuntu, Cantarell, -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif; +} + /* These sit at the very top of the window, same row as the overlaid macOS traffic lights (titleBarStyle: Overlay) — left padding clears them horizontally, same idea as .sidebar-head's top padding does vertically. */ @@ -42,7 +49,7 @@ body, align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: #4a1f1f; color: #ffd7d7; font-size: 12px; @@ -61,7 +68,7 @@ body, .startup-installing-banner { display: flex; align-items: center; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: var(--panel-2); color: var(--muted); font-size: 12px; @@ -73,7 +80,7 @@ body, align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: #4a3a1f; color: #ffe7b3; font-size: 12px; @@ -114,7 +121,7 @@ body, align-items: center; justify-content: space-between; gap: 12px; - padding: 8px 14px 8px 80px; + padding: 8px 14px; background: #1f4a2c; color: #c8f7d4; font-size: 12px; @@ -130,6 +137,16 @@ body, flex-shrink: 0; } +/* The overlay title bar exists only on macOS (see App.tsx's + platformClassForUserAgent). Linux/Windows use a normal title bar and must + not inherit the 80px traffic-light clearance. */ +.platform-macos .startup-error-banner, +.platform-macos .startup-installing-banner, +.platform-macos .startup-outdated-banner, +.platform-macos .startup-success-banner { + padding-left: 80px; +} + /* Top bar */ .topbar { display: flex; @@ -180,6 +197,100 @@ select:focus { border-color: var(--accent); } +/* Linux's replacement for native option popups. The list itself is rendered + into document.body with position:fixed (see PlatformSelect.tsx), so modal + and composer overflow/stacking contexts cannot clip it. */ +.platform-select-custom { + min-width: 0; +} +.platform-select-trigger { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 0; + appearance: none; + -webkit-appearance: none; + background: var(--panel-2); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 28px 6px 10px; + font: inherit; + text-align: left; + cursor: pointer; +} +.platform-select-trigger:hover, +.platform-select-trigger:focus { + outline: none; + border-color: var(--accent); +} +.platform-select-trigger:disabled { + opacity: 0.4; + cursor: default; +} +.platform-select-trigger-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.platform-select-chevron { + position: absolute; + right: 10px; + width: 8px; + height: 8px; + border-right: 1.5px solid var(--muted); + border-bottom: 1.5px solid var(--muted); + transform: translateY(-2px) rotate(45deg); + pointer-events: none; +} +.platform-select-trigger[aria-expanded="true"] .platform-select-chevron { + transform: translateY(2px) rotate(225deg); +} +.platform-select-popup { + position: fixed; + z-index: 1000; + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 0; + background: var(--panel-2); + color: var(--fg); + font-size: 13px; + line-height: 1.5; + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} +.platform-select-option { + display: block; + width: 100%; + padding: 6px 10px; + color: var(--fg); + cursor: pointer; + white-space: nowrap; +} +.platform-select-option:hover, +.platform-select-option.active { + background: var(--bg); +} +.platform-select-option[aria-selected="true"] { + color: var(--accent); + font-weight: 600; +} +.platform-select-option.active[aria-selected="true"] { + background: var(--accent); + color: #0b0e14; +} +.platform-select-option[aria-disabled="true"] { + opacity: 0.4; + cursor: default; +} +.modal .platform-select-custom { + width: 100%; +} + /* + New menu (collapsed-rail only — the expanded sidebar has its own direct + buttons per section instead, see .section-add-btn) */ .new-wrap { @@ -500,10 +611,10 @@ body.resizing-row { .sidebar.collapsed { width: 44px; } -/* Collapse/expand toggle — its own slim strip, level with the overlaid - macOS traffic lights (koit: not a tall empty row below them, and not - crowded into brand-row either — its own row, just short and right by the - lights rather than adding real vertical space). */ +/* Collapse/expand toggle — its own slim strip in every platform layout. + The control needs a real hit area even when there are no macOS overlay + traffic lights; only the collapsed rail's extra top clearance below is + platform-specific. */ .sidebar-toggle-row { display: flex; justify-content: flex-end; @@ -537,7 +648,10 @@ body.resizing-row { /* No .sidebar-toggle-row above this in the collapsed state (it would overlap the traffic lights at only 44px wide) — this provides its own traffic-light clearance directly. */ - padding: 34px 0 12px; + padding: 0 0 12px; +} +.platform-macos .sidebar-collapsed-rail { + padding-top: 34px; } .rail-logo-mark-btn { background: none; @@ -999,6 +1113,10 @@ body.resizing-row { font-family: Menlo, Monaco, "Courier New", monospace; font-size: 12px; } +.app.platform-linux .room { + /* Keep this list in sync with LINUX_TERMINAL_FONT_FAMILY in platform.ts. */ + font-family: "Ubuntu Mono", "DejaVu Sans Mono", Menlo, Monaco, "Courier New", monospace; +} /* Shared name coloring for both layouts. */ .mf { color: var(--accent); @@ -1512,3 +1630,21 @@ body.resizing-row { opacity: 0.4; cursor: default; } +/* .composer button styles apply to every button in the card; restore the + dropdown trigger's select-like appearance after that broad rule. */ +.composer .platform-select-trigger { + width: auto; + max-width: 220px; + background: transparent; + color: var(--fg); + border-color: transparent; + border-radius: 6px; + padding: 6px 28px 6px 10px; + font-weight: 400; +} +.composer .platform-select-trigger:hover, +.composer .platform-select-trigger:focus, +.composer .platform-select-trigger[aria-expanded="true"] { + background: var(--panel); + border-color: var(--border); +} diff --git a/app/src/App.test.ts b/app/src/App.test.ts index 05ea8ffac..494cd0862 100644 --- a/app/src/App.test.ts +++ b/app/src/App.test.ts @@ -10,6 +10,72 @@ import { shouldSuppressClickAfterDrag, type LoginShellInfo, } from "./App"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + LINUX_TERMINAL_FONT_FAMILY, + isLinuxTerminalCopyShortcut, + platformClassForUserAgent, +} from "./platform"; + +describe("platformClassForUserAgent", () => { + it("marks macOS webviews for the overlay title-bar layout", () => { + expect(platformClassForUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15")).toBe( + "platform-macos", + ); + }); + + it("marks Linux webviews for the Linux-only platform styling", () => { + expect(platformClassForUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")).toBe("platform-linux"); + }); + + it("leaves Windows webviews without a platform-specific class", () => { + expect(platformClassForUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")).toBe(""); + }); +}); + +describe("Linux terminal font fallback", () => { + it("keeps the non-Linux fallback from the pre-Linux implementation", () => { + expect(DEFAULT_TERMINAL_FONT_FAMILY).toBe("Menlo, Monaco, 'Courier New', monospace"); + }); + + it("keeps Ubuntu Mono and DejaVu Sans Mono ahead of the legacy stack", () => { + expect(LINUX_TERMINAL_FONT_FAMILY).toBe( + "'Ubuntu Mono', 'DejaVu Sans Mono', Menlo, Monaco, 'Courier New', monospace", + ); + }); +}); + +describe("Linux terminal copy shortcut", () => { + const linux = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"; + const windows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + + const event = (overrides: Partial = {}) => ({ + type: "keydown", + code: "KeyC", + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + ...overrides, + }); + + it("accepts Linux Ctrl+Shift+KeyC when text is selected", () => { + expect(isLinuxTerminalCopyShortcut(linux, event(), true)).toBe(true); + }); + + it("rejects the chord on non-Linux platforms", () => { + expect(isLinuxTerminalCopyShortcut(windows, event(), true)).toBe(false); + }); + + it("rejects Ctrl+C and Ctrl+Shift+V", () => { + expect(isLinuxTerminalCopyShortcut(linux, event({ shiftKey: false }), true)).toBe(false); + expect(isLinuxTerminalCopyShortcut(linux, event({ code: "KeyV" }), true)).toBe(false); + }); + + it("does not capture the chord when there is no selection", () => { + expect(isLinuxTerminalCopyShortcut(linux, event(), false)).toBe(false); + }); +}); describe("shouldShowOutdatedBanner", () => { it("shows when outdated, not updating, and not dismissed", () => { diff --git a/app/src/App.tsx b/app/src/App.tsx index 68fc2dbee..e5d22dbdc 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -15,6 +15,8 @@ import { Users, } from "lucide-react"; import { TerminalPane } from "./TerminalPane"; +import { PlatformSelect } from "./PlatformSelect"; +import { platformClassForUserAgent } from "./platform"; import { aggregateTeamStatus, applyStateChange, type PaneStatusMap, type RawState } from "./agentStatus"; import { AUTO_TIMEZONE, formatMessageTime, isValidTimeZone, resolveTimeZone } from "./time"; import { @@ -305,6 +307,7 @@ export function shouldShowOutdatedBanner( export default function App() { const { t } = useTranslation(); + const platformClass = platformClassForUserAgent(navigator.userAgent); // Set when a startup call that the whole app depends on (loading teams) // fails outright — most commonly agmsg isn't installed at // ~/.agents/skills/agmsg. Without this the app would just render an empty @@ -1789,7 +1792,7 @@ export default function App() { }, []); return ( -
+
{dragPointer && swapSource && ( // Follows the cursor during a pane-header pointer-drag — the visible // replacement for the old HTML5 setDragImage ghost (which relied on @@ -2509,14 +2512,17 @@ export default function App() { ); })()} - + ({ value: m.name, label: m.name })), + ]} + /> t.name)} + linux={platformClass === "platform-linux"} /> )} {modal?.kind === "rename" && ( @@ -2588,6 +2595,7 @@ export default function App() { onTerminalFontSizeChange={setTerminalFontSize} timezone={timezone} onTimezoneChange={setTimezone} + linux={platformClass === "platform-linux"} /> )} {modal?.kind === "closeWindow" && diff --git a/app/src/PlatformSelect.test.ts b/app/src/PlatformSelect.test.ts new file mode 100644 index 000000000..cd2eae970 --- /dev/null +++ b/app/src/PlatformSelect.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + calculatePlatformSelectPopupPosition, + calculatePlatformSelectScrollTop, + advancePlatformSelectKeyboardScrollRequest, + nextPlatformSelectIndex, + type PlatformSelectOption, +} from "./PlatformSelect"; + +const options: PlatformSelectOption[] = [ + { value: "placeholder", label: "Choose one" }, + { value: "hidden", label: "Unavailable", disabled: true }, + { value: "first", label: "First" }, + { value: "last", label: "Last" }, +]; + +describe("nextPlatformSelectIndex", () => { + it("moves in either direction while skipping disabled options", () => { + expect(nextPlatformSelectIndex(options, 0, 1)).toBe(2); + expect(nextPlatformSelectIndex(options, 2, -1)).toBe(0); + expect(nextPlatformSelectIndex(options, 2, 1)).toBe(3); + }); + + it("does not wrap beyond either end", () => { + expect(nextPlatformSelectIndex(options, 0, -1)).toBe(0); + expect(nextPlatformSelectIndex(options, 3, 1)).toBe(3); + }); + + it("returns -1 when there are no options", () => { + expect(nextPlatformSelectIndex([], 0, 1)).toBe(-1); + }); +}); + +describe("calculatePlatformSelectScrollTop", () => { + it("scrolls down when the active option is below the visible area", () => { + expect( + calculatePlatformSelectScrollTop({ + scrollTop: 0, + clientHeight: 100, + optionTop: 120, + optionHeight: 20, + }), + ).toBe(40); + }); + + it("scrolls up when the active option is above the visible area", () => { + expect( + calculatePlatformSelectScrollTop({ + scrollTop: 80, + clientHeight: 100, + optionTop: 40, + optionHeight: 20, + }), + ).toBe(40); + }); + + it("keeps scrollTop unchanged when the active option is already visible", () => { + expect( + calculatePlatformSelectScrollTop({ + scrollTop: 40, + clientHeight: 100, + optionTop: 80, + optionHeight: 20, + }), + ).toBe(40); + }); +}); + +describe("advancePlatformSelectKeyboardScrollRequest", () => { + it("returns a new token for every keyboard/open request", () => { + const first = advancePlatformSelectKeyboardScrollRequest(0); + const second = advancePlatformSelectKeyboardScrollRequest(first); + + expect(first).toBe(1); + expect(second).toBe(2); + }); +}); + +describe("calculatePlatformSelectPopupPosition", () => { + const viewport = { width: 1200, height: 1000 }; + + it("anchors an above popup to the trigger's top edge", () => { + const position = calculatePlatformSelectPopupPosition( + { top: 900, bottom: 940, left: 100, width: 180 }, + viewport, + 3, + ); + + expect(position.placement).toBe("above"); + if (position.placement !== "above") throw new Error("expected above placement"); + expect(position.bottom).toBe(100); + expect("top" in position).toBe(false); + expect(position.maxHeight).toBeLessThanOrEqual(900 - 8); + }); + + it("keeps the existing trigger-bottom anchor for below placement", () => { + const position = calculatePlatformSelectPopupPosition( + { top: 100, bottom: 140, left: 100, width: 180 }, + viewport, + 3, + ); + + expect(position.placement).toBe("below"); + if (position.placement !== "below") throw new Error("expected below placement"); + expect(position.top).toBe(140); + expect("bottom" in position).toBe(false); + expect(position.maxHeight).toBeLessThanOrEqual(1000 - 140 - 8); + }); + + it("chooses the wider side when neither side fits the estimate", () => { + const position = calculatePlatformSelectPopupPosition( + { top: 200, bottom: 940, left: 100, width: 180 }, + viewport, + 12, + ); + + expect(position.placement).toBe("above"); + expect(position.maxHeight).toBeLessThanOrEqual(200 - 8); + }); + + it("keeps an extreme zero-space popup inside the viewport", () => { + const extremeViewport = { width: 320, height: 100 }; + const position = calculatePlatformSelectPopupPosition( + { top: 0, bottom: 100, left: 10, width: 120 }, + extremeViewport, + 20, + ); + + expect(position.placement).toBe("below"); + if (position.placement !== "below") throw new Error("expected below placement"); + expect(position.top).toBe(100); + expect(position.maxHeight).toBe(0); + expect(position.top + position.maxHeight).toBeLessThanOrEqual(extremeViewport.height); + }); +}); diff --git a/app/src/PlatformSelect.tsx b/app/src/PlatformSelect.tsx new file mode 100644 index 000000000..5d8da9f27 --- /dev/null +++ b/app/src/PlatformSelect.tsx @@ -0,0 +1,426 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type FocusEvent, + type KeyboardEvent, +} from "react"; +import { createPortal } from "react-dom"; + +export type PlatformSelectOption = { + value: string; + label: string; + disabled?: boolean; +}; + +export type PlatformSelectProps = { + /** Render the custom control only for Linux; other platforms keep native select. */ + linux: boolean; + value: string; + onChange: (value: string) => void; + options: readonly PlatformSelectOption[]; + className?: string; + ariaLabel?: string; + disabled?: boolean; +}; + +function firstSelectableIndex(options: readonly PlatformSelectOption[], value: string): number { + const selected = options.findIndex((option) => option.value === value && !option.disabled); + if (selected >= 0) return selected; + return options.findIndex((option) => !option.disabled); +} + +/** Move to the next selectable option without wrapping at either end. */ +export function nextPlatformSelectIndex( + options: readonly PlatformSelectOption[], + current: number, + direction: -1 | 1, +): number { + if (!options.length) return -1; + let index = current; + while (true) { + index += direction; + if (index < 0 || index >= options.length) return current; + if (!options[index]?.disabled) return index; + } +} + +type PlatformSelectScrollMetrics = { + scrollTop: number; + clientHeight: number; + optionTop: number; + optionHeight: number; +}; + +/** Return the nearest scrollTop that makes the active option fully visible. */ +export function calculatePlatformSelectScrollTop({ + scrollTop, + clientHeight, + optionTop, + optionHeight, +}: PlatformSelectScrollMetrics): number { + const visibleTop = scrollTop; + const visibleBottom = scrollTop + clientHeight; + const optionBottom = optionTop + optionHeight; + if (optionTop < visibleTop) return Math.max(0, optionTop); + if (optionBottom > visibleBottom) return Math.max(0, optionBottom - clientHeight); + return scrollTop; +} + +/** Give each keyboard/open scroll request a distinct token for the layout effect. */ +export function advancePlatformSelectKeyboardScrollRequest(request: number): number { + return request + 1; +} + +type PlatformSelectPopupPositionBase = { + left: number; + width: number; + maxHeight: number; +}; + +export type PlatformSelectPopupPosition = + | (PlatformSelectPopupPositionBase & { placement: "above"; bottom: number }) + | (PlatformSelectPopupPositionBase & { placement: "below"; top: number }); + +type PopupAnchorRect = Pick; + +type PopupViewport = { + width: number; + height: number; +}; + +/** + * Calculate the fixed-position popup geometry from viewport coordinates. + * + * Above placement deliberately uses a bottom anchor instead of estimating the + * popup's rendered height. That keeps the popup edge attached to the trigger + * even when its actual content is shorter than the max-height estimate. + */ +export function calculatePlatformSelectPopupPosition( + rect: PopupAnchorRect, + viewport: PopupViewport, + optionCount: number, +): PlatformSelectPopupPosition { + const viewportPadding = 8; + // .platform-select-option is 13px × 1.5 line-height plus 12px vertical + // padding (about 32px); keep this estimate coupled to its CSS height. + const estimatedHeight = Math.min(280, Math.max(48, optionCount * 32 + 8)); + const belowSpace = Math.max(0, viewport.height - rect.bottom - viewportPadding); + const aboveSpace = Math.max(0, rect.top - viewportPadding); + const placement: PlatformSelectPopupPosition["placement"] = + belowSpace >= estimatedHeight || belowSpace >= aboveSpace ? "below" : "above"; + const availableSpace = placement === "below" ? belowSpace : aboveSpace; + // Keep the rendered box within the available side of the viewport, even + // when the trigger leaves less than the normal 48px minimum. + const maxHeight = Math.max(0, Math.min(280, availableSpace)); + const width = Math.min(rect.width, Math.max(0, viewport.width - viewportPadding * 2)); + const left = Math.max( + viewportPadding, + Math.min(rect.left, viewport.width - viewportPadding - width), + ); + + if (placement === "above") { + return { + bottom: viewport.height - rect.top, + left, + width, + maxHeight, + placement, + }; + } + + return { top: rect.bottom, left, width, maxHeight, placement }; +} + +function NativeSelect(props: PlatformSelectProps) { + return ( + + ); +} + +function LinuxSelect(props: PlatformSelectProps) { + const rootRef = useRef(null); + const triggerRef = useRef(null); + const popupRef = useRef(null); + const listboxId = useId(); + const selectedIndex = useMemo( + () => firstSelectableIndex(props.options, props.value), + [props.options, props.value], + ); + const [activeIndex, setActiveIndex] = useState(selectedIndex); + const [open, setOpen] = useState(false); + const [popupPosition, setPopupPosition] = useState(null); + // A request counter keeps keyboard scrolling observable even when a + // boundary key targets the same active index (React otherwise bails out of + // the state update). Hover changes deliberately do not advance this count, + // so a stationary pointer cannot restart scroll-follow. + const [keyboardScrollRequest, setKeyboardScrollRequest] = useState(0); + + const setKeyboardActiveIndex = useCallback((index: number) => { + setKeyboardScrollRequest(advancePlatformSelectKeyboardScrollRequest); + setActiveIndex(index); + }, []); + + const setHoverActiveIndex = useCallback((index: number) => { + // Moving the pointer over an option must not scroll under a stationary + // pointer; only keyboard navigation owns active-option scroll tracking. + setActiveIndex(index); + }, []); + + useEffect(() => { + if (!open) { + setActiveIndex(selectedIndex); + } + }, [open, selectedIndex]); + + useLayoutEffect(() => { + if (!open || activeIndex < 0) return; + const popup = popupRef.current; + const option = document.getElementById(`${listboxId}-option-${activeIndex}`); + if (!popup || !(option instanceof HTMLElement)) return; + const nextScrollTop = calculatePlatformSelectScrollTop({ + scrollTop: popup.scrollTop, + clientHeight: popup.clientHeight, + optionTop: option.offsetTop, + optionHeight: option.offsetHeight, + }); + if (nextScrollTop !== popup.scrollTop) popup.scrollTop = nextScrollTop; + }, [keyboardScrollRequest, open]); + + const positionPopup = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger || typeof window === "undefined") return; + setPopupPosition( + calculatePlatformSelectPopupPosition( + trigger.getBoundingClientRect(), + { width: window.innerWidth, height: window.innerHeight }, + props.options.length, + ), + ); + }, [props.options.length]); + + const closeMenu = useCallback( + (commit: boolean) => { + if (commit && activeIndex >= 0) { + const option = props.options[activeIndex]; + if (option && !option.disabled) props.onChange(option.value); + } else { + setActiveIndex(selectedIndex); + } + setOpen(false); + setPopupPosition(null); + }, + [activeIndex, props.onChange, props.options, selectedIndex], + ); + + const openMenu = useCallback( + () => { + if (props.disabled || selectedIndex < 0) return; + setKeyboardScrollRequest(advancePlatformSelectKeyboardScrollRequest); + setActiveIndex(selectedIndex); + setOpen(true); + positionPopup(); + }, + [positionPopup, props.disabled, selectedIndex], + ); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (rootRef.current?.contains(target) || popupRef.current?.contains(target)) return; + closeMenu(false); + }; + const closeOnResize = () => closeMenu(false); + const closeOnScroll = (event: Event) => { + const target = event.target; + // Scrolling the popup's own list is a normal way to reach lower + // options; only viewport/ancestor scrolling invalidates fixed coords. + if (target instanceof Node && popupRef.current?.contains(target)) return; + closeMenu(false); + }; + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("resize", closeOnResize); + window.addEventListener("scroll", closeOnScroll, true); + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("resize", closeOnResize); + window.removeEventListener("scroll", closeOnScroll, true); + }; + }, [closeMenu, open]); + + const selectIndex = useCallback( + (index: number) => { + const option = props.options[index]; + if (!option || option.disabled) return; + setActiveIndex(index); + props.onChange(option.value); + setOpen(false); + setPopupPosition(null); + }, + [props.onChange, props.options], + ); + + const move = useCallback( + (direction: -1 | 1) => { + // Opening is not a selection. This keeps Escape a true cancellation + // path; Enter/Space, click, or Tab are the only commit paths. + if (!open) { + openMenu(); + return; + } + const base = activeIndex >= 0 ? activeIndex : selectedIndex; + const next = nextPlatformSelectIndex(props.options, base, direction); + if (next < 0) return; + setKeyboardActiveIndex(next); + }, + [activeIndex, open, openMenu, props.options, selectedIndex, setKeyboardActiveIndex], + ); + + const moveToBoundary = useCallback( + (toEnd: boolean) => { + if (!open) { + openMenu(); + return; + } + const index = toEnd + ? [...props.options].map((option, index) => ({ option, index })).reverse().find(({ option }) => !option.disabled)?.index ?? -1 + : props.options.findIndex((option) => !option.disabled); + if (index < 0) return; + setKeyboardActiveIndex(index); + }, + [open, openMenu, props.options, setKeyboardActiveIndex], + ); + + const onKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + move(1); + break; + case "ArrowUp": + event.preventDefault(); + move(-1); + break; + case "Home": + event.preventDefault(); + moveToBoundary(false); + break; + case "End": + event.preventDefault(); + moveToBoundary(true); + break; + case "Enter": + case " ": + event.preventDefault(); + if (open) closeMenu(true); + else openMenu(); + break; + case "Escape": + if (open) { + event.preventDefault(); + closeMenu(false); + } + break; + case "Tab": + if (open) closeMenu(true); + break; + } + }; + + const onBlur = (event: FocusEvent) => { + const related = event.relatedTarget; + if (related instanceof Node && (rootRef.current?.contains(related) || popupRef.current?.contains(related))) { + return; + } + if (open) closeMenu(false); + }; + + const selectedLabel = props.options.find((option) => option.value === props.value)?.label ?? ""; + const triggerClassName = ["platform-select-trigger", props.className].filter(Boolean).join(" "); + const popupStyle: CSSProperties | undefined = popupPosition + ? { + ...(popupPosition.placement === "above" + ? { bottom: popupPosition.bottom } + : { top: popupPosition.top }), + left: popupPosition.left, + width: popupPosition.width, + maxHeight: popupPosition.maxHeight, + } + : undefined; + + return ( +
+ + {open && popupPosition && typeof document !== "undefined" + ? createPortal( +
+ {props.options.map((option, index) => ( +
event.preventDefault()} + onMouseEnter={() => !option.disabled && setHoverActiveIndex(index)} + onClick={() => selectIndex(index)} + > + {option.label} +
+ ))} +
, + document.body, + ) + : null} +
+ ); +} + +export function PlatformSelect(props: PlatformSelectProps) { + return props.linux ? : ; +} diff --git a/app/src/TerminalPane.tsx b/app/src/TerminalPane.tsx index fe4d43535..982d0c3d6 100644 --- a/app/src/TerminalPane.tsx +++ b/app/src/TerminalPane.tsx @@ -6,6 +6,12 @@ import { WebglAddon } from "@xterm/addon-webgl"; import "@xterm/xterm/css/xterm.css"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + LINUX_TERMINAL_FONT_FAMILY, + isLinuxTerminalCopyShortcut, + platformClassForUserAgent, +} from "./platform"; import { createWriteBatcher } from "./writeBatcher"; import { attachWebglAddon } from "./webglAttach"; @@ -74,9 +80,13 @@ export function TerminalPane({ useEffect(() => { let disposed = false; + const fontFamily = + platformClassForUserAgent(navigator.userAgent) === "platform-linux" + ? LINUX_TERMINAL_FONT_FAMILY + : DEFAULT_TERMINAL_FONT_FAMILY; const term = new Terminal({ fontSize, - fontFamily: "Menlo, Monaco, 'Courier New', monospace", + fontFamily, cursorBlink: true, theme: { background: "#0b0e14", foreground: "#c5c8c6" }, }); @@ -86,6 +96,38 @@ export function TerminalPane({ term.loadAddon(fit); term.open(ref.current!); + // GTK's native Edit menu is intentionally absent on Linux: its predefined + // copy action is inert in the WebKitGTK build. Keep the terminal's normal + // xterm copy event as the first path so the same selection handling used by + // right-click Copy remains in use for Ctrl+Shift+C. Clipboard API support + // is not guaranteed for Tauri's custom scheme, so fall back to it only if + // execCommand did not handle the copy. Other platforms keep xterm's + // default key handling unchanged. + term.attachCustomKeyEventHandler((event) => { + if ( + !isLinuxTerminalCopyShortcut(navigator.userAgent, event, term.hasSelection()) + ) { + return true; + } + event.preventDefault(); + event.stopPropagation(); + term.focus(); + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + if (!copied) { + const selection = term.getSelection(); + const writeText = navigator.clipboard?.writeText; + if (selection && writeText) { + void writeText.call(navigator.clipboard, selection).catch(() => {}); + } + } + return false; + }); + // Fit to the container's CURRENT size and tell the PTY — but only when the // pane is actually laid out. A pane that mounts while its tab is inactive // (or before first layout) has 0 size; fitting then would size the terminal diff --git a/app/src/modals.tsx b/app/src/modals.tsx index 367c0ff7e..d5aef90ed 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import { SUPPORTED_LANGUAGES } from "./i18n"; +import { PlatformSelect } from "./PlatformSelect"; import { AUTO_TIMEZONE, detectTimeZone, isValidTimeZone, listTimeZones } from "./time"; type BrowseDir = (current: string) => Promise; @@ -234,6 +235,8 @@ export function AgentModal(props: { defaultProject?: string; /** Spawnable agent types, from agmsg's registry. */ types: string[]; + /** Linux uses the portal-backed select; other platforms keep native select. */ + linux: boolean; }) { const { t } = useTranslation(); const [type, setType] = useState(props.types[0] ?? ""); @@ -261,13 +264,13 @@ export function AgentModal(props: { >