diff --git a/.changeset/calm-action-models.md b/.changeset/calm-action-models.md new file mode 100644 index 000000000..7a4581956 --- /dev/null +++ b/.changeset/calm-action-models.md @@ -0,0 +1,7 @@ +--- +"helmor": patch +--- + +Fix model settings for action helpers: +- Keep Opus 4.7 selections for Review and Action models after restarting Helmor. +- Rename the PR/MR model setting to Action model and use it for create PR/MR, reopen PR/MR, and commit-and-push helpers. diff --git a/.changeset/linux-support.md b/.changeset/linux-support.md new file mode 100644 index 000000000..390cb63f6 --- /dev/null +++ b/.changeset/linux-support.md @@ -0,0 +1,10 @@ +--- +"helmor": minor +--- + +Add native Linux desktop support (Ubuntu 22.04+) as a community fork: + +- Ship Linux x86_64 `.deb` and `.AppImage` release artifacts from the fork's release pipeline. +- Read Claude OAuth credentials from `~/.claude/.credentials.json` on Linux instead of the macOS Keychain (no libsecret/D-Bus dependency). +- Wire "reveal in file manager" through `xdg-open`, and route clipboard actions through `wl-copy` (Wayland) / `xclip` (X11). +- Keep macOS releases unaffected — Linux changes are gated behind `#[cfg(target_os = "linux")]` and a separate publish workflow. diff --git a/.github/actions/setup-rust-tauri/action.yml b/.github/actions/setup-rust-tauri/action.yml index 0174bc5a8..5567cb877 100644 --- a/.github/actions/setup-rust-tauri/action.yml +++ b/.github/actions/setup-rust-tauri/action.yml @@ -1,5 +1,5 @@ name: Setup Rust Tauri -description: Setup Rust toolchain and cache for macOS Tauri builds +description: Setup Rust toolchain and cache for Tauri builds (macOS + Linux) runs: using: composite @@ -12,6 +12,14 @@ runs: # are host-local and invalidate sccache lookups, so sccache never # hits. Disable incremental in CI only. echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV" + # sccache object cache lives in different default dirs per OS. + # Pin via SCCACHE_DIR so actions/cache below can target it + # without an OS-conditional path expression. + if [ "$RUNNER_OS" = "Linux" ]; then + echo "SCCACHE_DIR=$HOME/.cache/sccache" >> "$GITHUB_ENV" + else + echo "SCCACHE_DIR=$HOME/Library/Caches/Mozilla.sccache" >> "$GITHUB_ENV" + fi - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -21,7 +29,7 @@ runs: with: cache-on-failure: true cache-workspace-crates: true - shared-key: macos-tauri-v1 + shared-key: ${{ runner.os }}-tauri-v1 workspaces: src-tauri -> target # Persist sccache's default object cache to complement rust-cache's @@ -30,9 +38,9 @@ runs: - name: Cache sccache objects uses: actions/cache@v4 with: - path: ~/Library/Caches/Mozilla.sccache - key: sccache-macos-${{ hashFiles('src-tauri/Cargo.lock') }} - restore-keys: sccache-macos- + path: ${{ env.SCCACHE_DIR }} + key: sccache-${{ runner.os }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: sccache-${{ runner.os }}- - name: Install cargo-nextest uses: taiki-e/install-action@nextest diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml new file mode 100644 index 000000000..95734fe4b --- /dev/null +++ b/.github/workflows/publish-linux.yml @@ -0,0 +1,226 @@ +name: Publish Release (Linux) + +# Linux-only publish for the jcadmin/helmor fork. Matrix produces: +# - Linux x86_64 .deb + .AppImage updater artifact +# Triggered separately from the upstream macOS publish so they don't +# collide on the same tag namespace — Linux releases use `v*-linux*` +# (e.g. v0.21.3-linux1), upstream uses plain `v*`. + +on: + workflow_dispatch: + inputs: + draft: + description: "Create the GitHub release as a draft" + required: false + default: true + type: boolean + push: + tags: + - "v*-linux*" + +concurrency: + group: publish-linux-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +env: + CI: true + +jobs: + preflight: + name: Release Preflight + # ubuntu-22.04 (glibc 2.35) — wider runtime compat than 24.04. Match + # this against the runners used in build-and-publish to keep glibc + # symbol versions consistent between toolchain and bundled binary. + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.version.outputs.version }} + release_body: ${{ steps.notes.outputs.release_body }} + draft: ${{ steps.release-mode.outputs.draft }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup JS toolchain + uses: ./.github/actions/setup-js + + - name: Verify release config + run: | + bun run release:verify + test -n "${HELMOR_UPDATER_PUBKEY}" + test -n "${HELMOR_UPDATER_ENDPOINTS}" + test -n "${TAURI_SIGNING_PRIVATE_KEY}" + env: + HELMOR_UPDATER_PUBKEY: ${{ secrets.HELMOR_UPDATER_PUBKEY }} + HELMOR_UPDATER_ENDPOINTS: ${{ secrets.HELMOR_UPDATER_ENDPOINTS }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + + - name: Read version + id: version + run: | + version=$(node -p "require('./package.json').version") + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Validate pushed tag matches app version + if: github.ref_type == 'tag' + run: | + # Linux tags follow `v-linux` so strip the `-linux*` + # suffix before comparing against the app version in package.json. + stripped="${GITHUB_REF_NAME%-linux*}" + expected="v${{ steps.version.outputs.version }}" + if [ "${stripped}" != "${expected}" ]; then + echo "Tag ${GITHUB_REF_NAME} (stripped: ${stripped}) does not match app version ${expected}" + exit 1 + fi + + - name: Extract release notes + id: notes + run: | + { + echo 'release_body<> "$GITHUB_OUTPUT" + + - name: Resolve release mode + id: release-mode + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "draft=${{ inputs.draft }}" >> "$GITHUB_OUTPUT" + else + echo "draft=false" >> "$GITHUB_OUTPUT" + fi + + build-and-publish: + name: Build and Publish (${{ matrix.target-triple }}) + needs: preflight + strategy: + fail-fast: false + matrix: + include: + - target-triple: x86_64-unknown-linux-gnu + tauri-args: "--target x86_64-unknown-linux-gnu --bundles deb,appimage" + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install Linux Tauri system dependencies + # Tauri v2 on Linux needs WebKit + GTK headers to compile and a + # FUSE2 runtime for the AppImage bundler (linuxdeploy). patchelf + # is required by the AppImage post-processor; rsvg loads the + # tray icon at runtime; libayatana-appindicator hosts the tray. + # On 22.04 the FUSE2 package is `libfuse2`; 24.04 renamed it + # to `libfuse2t64`. Pin to 22.04 so we can use the stable name. + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libsoup-3.0-dev \ + librsvg2-dev \ + libayatana-appindicator3-dev \ + patchelf \ + libfuse2 + + - name: Setup JS toolchain + uses: ./.github/actions/setup-js + + - name: Setup Rust Tauri + uses: ./.github/actions/setup-rust-tauri + + - name: Build + stage bundle binaries + uses: ./.github/actions/build-sidecar + with: + target-triple: ${{ matrix.target-triple }} + + # TODO(linux-appimage): tauri-action will fail on `--bundles appimage` + # because linuxdeploy aborts on our self-contained binaries + # (helmor-sidecar, vendor/claude-code, vendor/codex are bun + # --compile; vendor/gh, vendor/glab are static Go). The .deb + # bundle in the same step succeeds. Before this workflow goes + # live, swap in scripts/bundle-appimage-linux.sh: + # 1. Run tauri-action with `--bundles deb` only (or this step + # with `|| true` so the appimage failure doesn't fail the + # job). + # 2. Add a follow-up `run:` step that invokes + # scripts/bundle-appimage-linux.sh on the AppDir tauri + # leaves behind, producing the final .AppImage. + # 3. Manually upload both artifacts to the GitHub release + # (since tauri-action would otherwise miss the .AppImage + # we built outside its flow). + # See LINUX_BUILD.md "AppImage may need a manual repackage step" + # for the local equivalent. + - name: Build and publish (${{ matrix.target-triple }}) + id: tauri + uses: tauri-apps/tauri-action@v0.6 + env: + TAURI_TARGET_TRIPLE: ${{ matrix.target-triple }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + HELMOR_UPDATER_PUBKEY: ${{ secrets.HELMOR_UPDATER_PUBKEY }} + HELMOR_UPDATER_ENDPOINTS: ${{ secrets.HELMOR_UPDATER_ENDPOINTS }} + with: + tagName: v__VERSION__-linux1 + releaseName: Helmor v__VERSION__ (Linux) + releaseBody: ${{ needs.preflight.outputs.release_body }} + releaseDraft: ${{ needs.preflight.outputs.draft == 'true' }} + prerelease: false + includeUpdaterJson: true + args: ${{ matrix.tauri-args }} + # Force tauri-action to use bun instead of auto-detecting (it only knows npm/yarn/pnpm) + tauriScript: bun run tauri + + - name: Verify Linux bundles (${{ matrix.target-triple }}) + shell: bash + run: | + bundle_root="src-tauri/target/${{ matrix.target-triple }}/release/bundle" + + deb_path=$(ls "${bundle_root}"/deb/Helmor_*_amd64.deb 2>/dev/null | head -1) + appimage_path=$(ls "${bundle_root}"/appimage/Helmor_*_amd64.AppImage 2>/dev/null | head -1) + + if [ -z "${deb_path}" ] || [ ! -f "${deb_path}" ]; then + echo "::error::.deb bundle missing under ${bundle_root}/deb/" + exit 1 + fi + if [ -z "${appimage_path}" ] || [ ! -f "${appimage_path}" ]; then + echo "::error::.AppImage bundle missing under ${bundle_root}/appimage/" + exit 1 + fi + + echo ".deb:" + file "${deb_path}" + ls -lh "${deb_path}" + dpkg-deb --info "${deb_path}" | head -20 + + echo ".AppImage:" + file "${appimage_path}" + ls -lh "${appimage_path}" + + notify-marketing: + name: Notify marketing site + needs: [preflight, build-and-publish] + if: needs.preflight.outputs.draft == 'false' + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Revalidate helmor.ai + env: + SECRET: ${{ secrets.HELMOR_MARKETING_REVALIDATE_SECRET }} + run: | + if [ -z "${SECRET}" ]; then + echo "HELMOR_MARKETING_REVALIDATE_SECRET not set; skipping" + exit 0 + fi + curl --fail --silent --show-error \ + --retry 3 --retry-delay 10 --retry-connrefused \ + -X POST "https://helmor.ai/api/revalidate" \ + -H "x-revalidate-secret: ${SECRET}" \ + -H "content-type: application/json" diff --git a/LINUX_BUILD.md b/LINUX_BUILD.md new file mode 100644 index 000000000..c8bc8dd6d --- /dev/null +++ b/LINUX_BUILD.md @@ -0,0 +1,147 @@ +# Building Helmor on Linux + +This guide covers building and running Helmor on Linux from source. For an installer-only path, see the `.deb` / `.AppImage` artifacts on the [fork's Releases page](https://github.com/jcadmin/helmor/releases). + +## Supported Distributions + +- **Ubuntu 22.04+** / **Debian 12+** — primary target, fully tested +- **Fedora 39+** — untested but should work with the equivalent dependencies (`webkit2gtk4.1-devel`, `gtk3-devel`, `libsoup3-devel`, `librsvg2-devel`, `libayatana-appindicator3-devel`, `patchelf`) + +WebKit2GTK 4.1 is required. Ubuntu 20.04 and other distros that only ship 4.0 are not supported. + +## System Dependencies + +On Debian/Ubuntu: + +```bash +sudo apt update +sudo apt install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libsoup-3.0-dev \ + librsvg2-dev \ + libayatana-appindicator3-dev \ + patchelf \ + build-essential \ + curl \ + wget \ + file +``` + +## Toolchain + +- **Rust stable 1.95+** — install via [rustup](https://rustup.rs/) +- **Bun 1.3+** — install via `curl -fsSL https://bun.sh/install | bash` +- **Node 20+** — required by a few sidecar scripts + +If you prefer a reproducible environment, the repo ships a `flake.nix` with all of the above pinned. See [NIX_SETUP.md](./NIX_SETUP.md). + +## Build Steps + +```bash +git clone https://github.com/jcadmin/helmor.git +cd helmor + +bun install +bun run tauri build --bundles deb,appimage --target x86_64-unknown-linux-gnu +``` + +Artifacts land in: + +- `src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/Helmor_*.deb` +- `src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor*.AppImage` + +For development, `bun run dev` works the same as on macOS. + +### AppImage may need a manual repackage step + +`tauri build --bundles appimage` invokes `linuxdeploy`, which by default +walks every executable in the AppDir and runs `ldd` / `patchelf` on +each one to deploy shared-library deps. Helmor ships several binaries +linuxdeploy chokes on: + +- `helmor-sidecar`, `vendor/claude-code/claude`, `vendor/codex/codex` + are produced by `bun build --compile`. The runtime+bytecode embedding + format upsets `ldd` (exits 1 with no output) and linuxdeploy aborts + with `Failed to run ldd: exited with code 1`. +- `vendor/gh/gh`, `vendor/glab/glab` are static Go binaries with no + `.dynamic` section, so linuxdeploy's patchelf step aborts with + `cannot find section .dynamic`. + +When this happens the `.deb` bundle from the same `tauri build` +invocation succeeds, but `.AppImage` is never written. Repackage with +`scripts/bundle-appimage-linux.sh`, which hides those binaries from +linuxdeploy and squashes them back in afterwards: + +```bash +# Initial build — .deb succeeds, AppImage step fails (we'll redo it). +bun run tauri build --bundles deb,appimage --target x86_64-unknown-linux-gnu || true + +# Repackage the AppDir tauri left behind into a working AppImage. +version=$(node -p "require('./package.json').version") +appimage_dir="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage" + +scripts/bundle-appimage-linux.sh \ + "${appimage_dir}/Helmor.AppDir" \ + "${appimage_dir}/Helmor_${version}_amd64.AppImage" +``` + +The `.deb` flow is unaffected — Debian packaging doesn't use linuxdeploy. + +## Install + +`.deb`: + +```bash +sudo dpkg -i src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/Helmor_*.deb +sudo apt -f install # if dpkg complains about missing runtime deps +``` + +`.AppImage`: + +```bash +chmod +x src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor*.AppImage +./src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor*.AppImage +``` + +## Known Differences vs macOS + +This fork ports Helmor to Linux but does not try to emulate every macOS-specific behavior. Notable differences: + +- **No macOS Keychain integration.** Claude OAuth credentials are read directly from `~/.claude/.credentials.json` (the same file the `claude` CLI writes). There is no libsecret/D-Bus fallback. +- **No Finder "Reveal in Finder".** The equivalent action opens the containing directory in the system default file manager via `xdg-open`. +- **No native macOS menu bar.** Linux uses GTK's default window menu; menu items wired to the macOS app menu (e.g. global Quit, Edit submenu) are not duplicated. +- **Clipboard backend is platform-detected.** On Wayland the app shells out to `wl-copy` / `wl-paste`; on X11 it uses `xclip`. Install whichever one matches your session if clipboard actions stop working. + +These differences are gated in source behind `#[cfg(target_os = "linux")]` blocks so upstream macOS code stays untouched. + +## Troubleshooting + +**`could not execute process sccache (No such file or directory)`** + +The repo's `.cargo/config.toml` only sets `rustc-wrapper = "sccache"` when `sccache` is on `PATH`. If you see this anyway, install sccache (`cargo install sccache`) or unset the env var that's forcing it. + +**`Package webkit2gtk-4.1 was not found`** + +You're on a distro that only has 4.0. Upgrade to Ubuntu 22.04+ / Debian 12+ — Tauri v2 dropped 4.0 support upstream and we don't backport it. + +**AppImage refuses to start / exits silently** + +Run with `--appimage-extract-and-run` to bypass FUSE, or extract and inspect: + +```bash +./Helmor*.AppImage --appimage-extract +./squashfs-root/AppRun +``` + +Most "silent exit" cases are missing `libfuse2` (install `sudo apt install libfuse2t64` on 24.04+). + +**`deb` install reports missing dependencies** + +Run `sudo apt -f install` after `dpkg -i` to pull in transitive runtime deps. The `.deb` declares the same `libwebkit2gtk-4.1-0`, `libgtk-3-0`, etc. that the build needs. + +## Updating from Upstream + +This repository is a fork of [dohooo/helmor](https://github.com/dohooo/helmor) and rebases onto upstream `main` periodically. Linux-specific code is intentionally kept inside `#[cfg(target_os = "linux")]` blocks (or platform-suffixed files like `linux_credentials.rs`) so upstream merges produce minimal conflicts. + +If you're contributing a Linux fix, please keep the same convention — don't touch shared code paths unless the change benefits macOS as well. diff --git a/README.md b/README.md index 23412591d..c4a76959c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ Helmor is an open-source local workbench for multi-agent software development.

+> **🐧 Linux Fork** — This is a community fork of [dohooo/helmor](https://github.com/dohooo/helmor) maintained by [@jcadmin](https://github.com/jcadmin) specifically for Linux desktop support. Upstream macOS releases continue to be published by the original author. +> +> **For Linux users:** +> - Download `.deb` / `.AppImage` from this fork's [Releases page](https://github.com/jcadmin/helmor/releases). +> - Build from source: see [LINUX_BUILD.md](./LINUX_BUILD.md). +> - Report Linux-specific issues to [this fork's issue tracker](https://github.com/jcadmin/helmor/issues), not upstream. +> +> **For macOS users:** please use the [official upstream release](https://github.com/dohooo/helmor/releases) — this fork does not maintain macOS builds. +

Discord diff --git a/scripts/bundle-appimage-linux.sh b/scripts/bundle-appimage-linux.sh new file mode 100755 index 000000000..f89f6c7e0 --- /dev/null +++ b/scripts/bundle-appimage-linux.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Wrap linuxdeploy + plugin-appimage to package Helmor's AppDir as a .AppImage +# while sidestepping the bug where linuxdeploy crashes on our self-contained +# binaries. +# +# Background +# ---------- +# `tauri build --bundles appimage` invokes linuxdeploy which, by default, +# walks every ELF executable under /usr/bin/ and /usr/lib/ +# to compute and copy in their shared-library dependencies. For most +# binaries this is fine, but Helmor ships several that linuxdeploy chokes +# on: +# +# * helmor-sidecar (Bun `bun build --compile` output) +# * vendor/codex/codex (Bun --compile) +# * vendor/claude-code/claude (Bun --compile) +# The Bun-compile format embeds a JS runtime + bytecode after the +# ELF section table, and the resulting binary upsets `ldd` (exits 1 +# with no output). linuxdeploy then aborts with a C++ +# runtime_error: "Failed to run ldd: exited with code 1". +# +# * vendor/gh/gh (Go static binary) +# * vendor/glab/glab(Go static binary) +# Static Go binaries have no .dynamic section. linuxdeploy still +# calls patchelf on them and aborts with "cannot find section +# .dynamic". +# +# All five are self-contained — they have no shared-library dependencies +# linuxdeploy could supply anyway. The fix is to hide them from +# linuxdeploy during dependency deployment, then put them back before +# squashing the AppDir into the final .AppImage. +# +# Workflow +# -------- +# 1. Stash the problem binaries outside the AppDir. +# 2. Run linuxdeploy --output appimage so it can ldd the well-behaved +# binaries (helmor, helmor-cli, …) and copy in WebKit/GTK runtime +# libs. +# 3. Restore the stashed binaries. +# 4. Re-invoke linuxdeploy-plugin-appimage on the now-complete AppDir. +# This step only mksquashfs's the directory and prepends the +# AppImage runtime — it does not re-run dependency analysis. +# 5. Rename the AppImage to the requested output name. +# +# Usage +# ----- +# scripts/bundle-appimage-linux.sh +# +# Example +# scripts/bundle-appimage-linux.sh \ +# src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor.AppDir \ +# src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor_0.21.3_amd64.AppImage +# +# Prerequisites: `linuxdeploy-x86_64.AppImage` and +# `linuxdeploy-plugin-appimage.AppImage` are downloaded under +# ~/.cache/tauri/ by a prior `tauri build` (or by `tauri-action` in CI). +# +# Exit status: 0 on success, non-zero with a stderr message on failure. +# A trap restores any stashed binaries even if a step fails. + +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 64 +fi + +APPDIR="$1" +OUTPUT="$2" + +if [ ! -d "$APPDIR" ]; then + echo "error: AppDir not found: $APPDIR" >&2 + exit 1 +fi + +LINUXDEPLOY="${LINUXDEPLOY_BIN:-$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage}" +LINUXDEPLOY_PLUGIN_APPIMAGE="${LINUXDEPLOY_PLUGIN_APPIMAGE_BIN:-$HOME/.cache/tauri/linuxdeploy-plugin-appimage.AppImage}" + +for tool in "$LINUXDEPLOY" "$LINUXDEPLOY_PLUGIN_APPIMAGE"; do + if [ ! -x "$tool" ]; then + echo "error: missing tool: $tool" >&2 + echo " run \`tauri build --bundles appimage\` once to populate ~/.cache/tauri/" >&2 + exit 1 + fi +done + +# Self-contained binaries that break linuxdeploy. Paths are relative to +# the AppDir root. If a path doesn't exist (e.g. a future build drops +# one of these tools), we silently skip it — no need to fail the bundle +# over a missing optional vendor binary. +STASH_PATHS=( + "usr/bin/helmor-sidecar" + "usr/lib/Helmor/vendor/claude-code" + "usr/lib/Helmor/vendor/codex" + "usr/lib/Helmor/vendor/gh" + "usr/lib/Helmor/vendor/glab" +) + +STASH_DIR="$(mktemp -d -t helmor-appimage-stash.XXXXXX)" + +restore_stash() { + local rc=$? + # Best-effort rehydrate of the AppDir on failure. Idempotent — if the + # success path already moved a stashed entry back, we skip it. + if [ -d "$STASH_DIR" ]; then + for rel in "${STASH_PATHS[@]}"; do + local stashed_name + stashed_name="$(printf '%s' "$rel" | tr '/' '__')" + local src="$STASH_DIR/$stashed_name" + local dst="$APPDIR/$rel" + if [ -e "$src" ] && [ ! -e "$dst" ]; then + mkdir -p "$(dirname "$dst")" + cp -a "$src" "$dst" + fi + done + rm -rf -- "$STASH_DIR" + fi + exit "$rc" +} +trap restore_stash EXIT + +# Step 1 — stash. +for rel in "${STASH_PATHS[@]}"; do + src="$APPDIR/$rel" + if [ -e "$src" ]; then + stashed_name="$(printf '%s' "$rel" | tr '/' '__')" + mv "$src" "$STASH_DIR/$stashed_name" + fi +done + +# Step 2 — let linuxdeploy compute and deploy real shared-lib deps for +# the remaining (well-behaved) binaries. NO_STRIP=true keeps debug +# symbols in helmor-cli for crash analysis. +NO_STRIP=true \ + "$LINUXDEPLOY" \ + --appdir "$APPDIR" \ + --plugin gtk \ + --output appimage \ + > /dev/null + +# Step 2 produces an .AppImage in the cwd that's missing the binaries we +# stashed. Throw it away — we'll repackage in step 4. +rm -f -- *.AppImage + +# Step 3 — restore stashed binaries before squashing the final AppImage. +# We do this here (rather than waiting for the EXIT trap) so the binaries +# are in place before plugin-appimage runs. The trap's `! -e dst` guard +# makes the duplicate restore on success a no-op. +for rel in "${STASH_PATHS[@]}"; do + stashed_name="$(printf '%s' "$rel" | tr '/' '__')" + src="$STASH_DIR/$stashed_name" + dst="$APPDIR/$rel" + if [ -e "$src" ]; then + mkdir -p "$(dirname "$dst")" + mv "$src" "$dst" + fi +done + +# Step 4 — repackage the now-complete AppDir. plugin-appimage only runs +# mksquashfs + prepends the runtime; it doesn't re-traverse with ldd, so +# our self-contained binaries pass through untouched. APPIMAGE_EXTRACT_AND_RUN +# avoids needing libfuse2 just to run the plugin itself. +APPIMAGE_EXTRACT_AND_RUN=1 \ + "$LINUXDEPLOY_PLUGIN_APPIMAGE" \ + --appdir "$APPDIR" \ + > /dev/null + +# Step 5 — plugin-appimage names the output `-x86_64.AppImage` in +# the cwd. Move it to the requested output path. +produced="$(ls *.AppImage 2>/dev/null | head -n 1 || true)" +if [ -z "$produced" ] || [ ! -f "$produced" ]; then + echo "error: plugin-appimage did not produce an .AppImage in $(pwd)" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUTPUT")" +mv "$produced" "$OUTPUT" + +echo "$OUTPUT" diff --git a/sidecar/scripts/stage-vendor.ts b/sidecar/scripts/stage-vendor.ts index d72d2b084..2a1f6a0ba 100644 --- a/sidecar/scripts/stage-vendor.ts +++ b/sidecar/scripts/stage-vendor.ts @@ -1,5 +1,5 @@ // Stage claude-code + codex + gh + glab into `sidecar/dist/vendor/` -// for Tauri to ship as bundle resources. macOS host only. +// for Tauri to ship as bundle resources. macOS + Linux hosts. // // Cross-arch staging: in CI the host is always Apple Silicon (macos-26 // runner), but we publish both aarch64-apple-darwin and x86_64-apple-darwin @@ -9,8 +9,8 @@ // // Claude Code and Codex are each shipped as a single self-contained native // binary, pulled from the platform-specific npm sub-package -// (@anthropic-ai/claude-code-darwin-{arm64,x64}/claude, -// @openai/codex-darwin-{arm64,x64}/.../codex). +// (@anthropic-ai/claude-code-{darwin,linux}-{arm64,x64}/claude, +// @openai/codex-{darwin,linux}-{arm64,x64}/.../codex). import { execFileSync } from "node:child_process"; import { @@ -45,31 +45,81 @@ const GH_SHA256 = { amd64: "8806784f93603fe6d3f95c3583a08df38f175df9ebc123dc8b15f919329980e2", } as const; +// Linux release tarballs for `gh` (separate table because Linux uses .tar.gz, +// macOS uses .zip; SHAs come from the same upstream `gh_${VER}_checksums.txt`). +// To bump: refresh from +// https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_checksums.txt +// Look for `gh_${VERSION}_linux_amd64.tar.gz` and `_linux_arm64.tar.gz`. +const GH_SHA256_LINUX = { + arm64: "ccbed39c472d3dc1c501d1e164a9cffd934c5f6fce1012811a1a59d24cb7d7c6", + amd64: "304a0d2460f4a8847d2f192bad4e2a32cd9420d28716e7ae32198181b65b5f9c", +} as const; + const GLAB_VERSION = "1.93.0"; const GLAB_SHA256 = { arm64: "6d6ffa97d430b5e7ff912e64dbac14703acc57967df654be1950ae71858d5b6f", amd64: "79d1a4f933919689c5fb7774feb1dd08f30b9c896dff4283b4a7387689ee0531", } as const; +// Linux glab uses the lowercase `linux` slug +// (glab_${VER}_linux_{amd64,arm64}.tar.gz) — confirmed against the asset +// list returned by GitLab's release API. To bump, fetch each tarball from +// https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_${arch}.tar.gz +// and run `sha256sum` on the cached file. +const GLAB_SHA256_LINUX = { + arm64: "8d336072e190e540c1ecb187c17d039b3ab408cbb6f1b3c843327a77033145d0", + amd64: "300f3c12bd75f298747364f382f978bbe63809ef660bb2969925f343f9c20ae4", +} as const; + // Codex version is whatever sidecar/package.json pulled in. The SHAs below // must match THAT version — bump them together (or staging cross-arch will // abort with a clear error). -const CODEX_SHA256: Readonly> = { +// +// `linux` entries are placeholders until we run the first Linux build. +// TODO(linux): compute via `shasum -a 256` (or `sha256sum`) on the cached +// .tgz at sidecar/.bundle-cache/codex-${version}-linux-{arm64,x64}.tgz. +const CODEX_SHA256: Readonly< + Record< + string, + { + arm64: string; + x64: string; + linux?: { arm64: string; x64: string }; + } + > +> = { "0.130.0": { arm64: "f6fef2ceee8977079ad3b3296b4c14c2707934e6b4ec1aa1a32d6e512196b12d", x64: "21f161ffd79fab88c5bd91e40d14c894fe6d4ad61ea4ebc80d4fcf20130960c2", + linux: { + arm64: "3f217260abe83c6f0fd7a83e056cb453537e435c6754cd335ecd935823d8f3de", + x64: "91e12a56c49c702c86c5c42811cfa3d515b6d6bc196d70ba9cea25227302aa8f", + }, }, }; // Same versioning rule as Codex: must match whatever sidecar/package.json // pulled in (`@anthropic-ai/claude-code`). Cross-arch staging downloads // straight from the npm registry and verifies against this table. +// +// TODO(linux): replace placeholders with real digests on first Linux build. const CLAUDE_CODE_SHA256: Readonly< - Record + Record< + string, + { + arm64: string; + x64: string; + linux?: { arm64: string; x64: string }; + } + > > = { "2.1.139": { arm64: "ed9a4c64c8b5374da8389ff6aa4b58fce7a792f90ef2261a14445d9082a80799", x64: "71d18ce1d457f37b427bdcb5933424c83bf22b39b2b7628415028585b832fe6c", + linux: { + arm64: "91d54cb3f13f884823f343206ac0de2edcce75ab8958d21460d500e40b692d3c", + x64: "671e12d58883de4bc6db3654884dcafc74143598da9798f23d6b77c5627b14e5", + }, }, }; @@ -79,19 +129,21 @@ const CLAUDE_CODE_SHA256: Readonly< // staging where no env var is set. // --------------------------------------------------------------------------- -type DarwinArch = "arm64" | "x64"; +type SupportedArch = "arm64" | "x64"; +type SupportedPlatform = "darwin" | "linux"; interface TargetInfo { - arch: DarwinArch; - /** `@anthropic-ai/claude-code-darwin-` is the platform sub-package. */ + platform: SupportedPlatform; + arch: SupportedArch; + /** `@anthropic-ai/claude-code--` is the platform sub-package. */ claudeCodePkg: string; - /** claude-code npm tarball suffix: `darwin-arm64` / `darwin-x64`. */ + /** claude-code npm tarball suffix: `darwin-arm64` / `linux-x64` / etc. */ claudeCodeNpmSuffix: string; - /** `@openai/codex-darwin-` is the npm optional-dep package. */ + /** `@openai/codex--` is the npm optional-dep package. */ codexPkg: string; /** Target triple inside the codex platform package. */ codexTriple: string; - /** Codex npm tarball suffix: `darwin-arm64` / `darwin-x64`. */ + /** Codex npm tarball suffix: `darwin-arm64` / `linux-x64` / etc. */ codexNpmSuffix: string; /** `gh` release naming: `arm64` / `amd64`. */ ghArch: "arm64" | "amd64"; @@ -99,35 +151,71 @@ interface TargetInfo { glabArch: "arm64" | "amd64"; } -function infoForArch(arch: DarwinArch): TargetInfo { +function infoForPlatform( + platform: SupportedPlatform, + arch: SupportedArch, +): TargetInfo { + if (platform === "darwin") { + if (arch === "arm64") { + return { + platform, + arch, + claudeCodePkg: "@anthropic-ai/claude-code-darwin-arm64", + claudeCodeNpmSuffix: "darwin-arm64", + codexPkg: "@openai/codex-darwin-arm64", + codexTriple: "aarch64-apple-darwin", + codexNpmSuffix: "darwin-arm64", + ghArch: "arm64", + glabArch: "arm64", + }; + } + return { + platform, + arch, + claudeCodePkg: "@anthropic-ai/claude-code-darwin-x64", + claudeCodeNpmSuffix: "darwin-x64", + codexPkg: "@openai/codex-darwin-x64", + codexTriple: "x86_64-apple-darwin", + codexNpmSuffix: "darwin-x64", + ghArch: "amd64", + glabArch: "amd64", + }; + } + + // Linux. Codex publishes its Linux binaries as statically-linked musl, + // so the npm tarball nests under `vendor/-unknown-linux-musl/` + // even when host/target rust toolchain is `*-linux-gnu`. if (arch === "arm64") { return { + platform, arch, - claudeCodePkg: "@anthropic-ai/claude-code-darwin-arm64", - claudeCodeNpmSuffix: "darwin-arm64", - codexPkg: "@openai/codex-darwin-arm64", - codexTriple: "aarch64-apple-darwin", - codexNpmSuffix: "darwin-arm64", + claudeCodePkg: "@anthropic-ai/claude-code-linux-arm64", + claudeCodeNpmSuffix: "linux-arm64", + codexPkg: "@openai/codex-linux-arm64", + codexTriple: "aarch64-unknown-linux-musl", + codexNpmSuffix: "linux-arm64", ghArch: "arm64", glabArch: "arm64", }; } return { + platform, arch, - claudeCodePkg: "@anthropic-ai/claude-code-darwin-x64", - claudeCodeNpmSuffix: "darwin-x64", - codexPkg: "@openai/codex-darwin-x64", - codexTriple: "x86_64-apple-darwin", - codexNpmSuffix: "darwin-x64", + claudeCodePkg: "@anthropic-ai/claude-code-linux-x64", + claudeCodeNpmSuffix: "linux-x64", + codexPkg: "@openai/codex-linux-x64", + codexTriple: "x86_64-unknown-linux-musl", + codexNpmSuffix: "linux-x64", ghArch: "amd64", glabArch: "amd64", }; } function detectTarget(): TargetInfo { - if (process.platform !== "darwin") { + const hostPlatform = process.platform; + if (hostPlatform !== "darwin" && hostPlatform !== "linux") { throw new Error( - `[stage-vendor] Helmor only builds on macOS; host platform is ${process.platform}`, + `[stage-vendor] Helmor only builds on macOS or Linux; host platform is ${hostPlatform}`, ); } @@ -138,17 +226,26 @@ function detectTarget(): TargetInfo { process.env.CARGO_BUILD_TARGET?.trim(); if (triple) { - if (triple === "aarch64-apple-darwin") return infoForArch("arm64"); - if (triple === "x86_64-apple-darwin") return infoForArch("x64"); + if (triple === "aarch64-apple-darwin") + return infoForPlatform("darwin", "arm64"); + if (triple === "x86_64-apple-darwin") + return infoForPlatform("darwin", "x64"); + if (triple === "x86_64-unknown-linux-gnu") + return infoForPlatform("linux", "x64"); + if (triple === "aarch64-unknown-linux-gnu") + return infoForPlatform("linux", "arm64"); throw new Error( - `[stage-vendor] unsupported TAURI_TARGET_TRIPLE for macOS: ${triple}`, + `[stage-vendor] unsupported TAURI_TARGET_TRIPLE: ${triple}`, ); } const arch = process.arch; - if (arch === "arm64") return infoForArch("arm64"); - if (arch === "x64") return infoForArch("x64"); - throw new Error(`[stage-vendor] unsupported macOS host arch: ${arch}`); + if (arch !== "arm64" && arch !== "x64") { + throw new Error( + `[stage-vendor] unsupported host arch on ${hostPlatform}: ${arch}`, + ); + } + return infoForPlatform(hostPlatform, arch); } // --------------------------------------------------------------------------- @@ -202,9 +299,12 @@ function ensureCacheDir(): void { } function sha256OfFile(path: string): string { - const out = execFileSync("shasum", ["-a", "256", path], { - encoding: "utf8", - }); + // macOS ships `shasum`; most Linux distros only ship `sha256sum`. Both + // emit ` ` so the parsing is identical. + const useSha256sum = process.platform === "linux"; + const out = useSha256sum + ? execFileSync("sha256sum", [path], { encoding: "utf8" }) + : execFileSync("shasum", ["-a", "256", path], { encoding: "utf8" }); const digest = out.split(/\s+/)[0]; if (!digest) throw new Error(`[stage-vendor] empty shasum for ${path}`); return digest; @@ -244,6 +344,9 @@ function freshExtractDir(path: string): void { } function maybeSignMacBinary(path: string, withEntitlements: boolean): void { + // codesign is macOS-only; on any other host this is a no-op even if the + // caller forgets to gate it. + if (process.platform !== "darwin") return; const identity = process.env.APPLE_SIGNING_IDENTITY?.trim(); if (!identity) return; @@ -288,8 +391,30 @@ function locateExtractedBin(extractDir: string, name: string): string { ); } -function stageGhBinary(arch: "arm64" | "amd64"): string { +function stageGhBinary(target: TargetInfo): string { ensureCacheDir(); + const arch = target.ghArch; + if (target.platform === "linux") { + // Linux releases are tar.gz, named `gh_${VER}_linux_{amd64,arm64}.tar.gz`. + const slug = `gh_${GH_VERSION}_linux_${arch}`; + const archive = join(BUNDLE_CACHE, `${slug}.tar.gz`); + const url = `https://github.com/cli/cli/releases/download/v${GH_VERSION}/${slug}.tar.gz`; + downloadAndVerify(url, archive, GH_SHA256_LINUX[arch]); + + const extractDir = join(BUNDLE_CACHE, slug); + freshExtractDir(extractDir); + execFileSync("tar", ["-xzf", archive, "-C", extractDir], { + stdio: "inherit", + }); + + const binSrc = locateExtractedBin(extractDir, "gh"); + const binDest = join(DIST_VENDOR, "gh", "gh"); + copyFile(binSrc, binDest); + chmodSync(binDest, 0o755); + return binDest; + } + + // macOS releases are .zip with the slug `gh_${VER}_macOS_{arm64,amd64}`. const slug = `gh_${GH_VERSION}_macOS_${arch}`; const archive = join(BUNDLE_CACHE, `${slug}.zip`); const url = `https://github.com/cli/cli/releases/download/v${GH_VERSION}/${slug}.zip`; @@ -309,12 +434,19 @@ function stageGhBinary(arch: "arm64" | "amd64"): string { return binDest; } -function stageGlabBinary(arch: "arm64" | "amd64"): string { +function stageGlabBinary(target: TargetInfo): string { ensureCacheDir(); - const slug = `glab_${GLAB_VERSION}_darwin_${arch}`; + const arch = target.glabArch; + // glab uses lowercase OS slugs (`linux`/`darwin`) — confirmed against the + // asset list returned by GitLab's release API. Same archive shape + // (tar.gz with bin/glab inside) on both. + const osSlug = target.platform === "linux" ? "linux" : "darwin"; + const slug = `glab_${GLAB_VERSION}_${osSlug}_${arch}`; const archive = join(BUNDLE_CACHE, `${slug}.tar.gz`); const url = `https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${slug}.tar.gz`; - downloadAndVerify(url, archive, GLAB_SHA256[arch]); + const sha = + target.platform === "linux" ? GLAB_SHA256_LINUX[arch] : GLAB_SHA256[arch]; + downloadAndVerify(url, archive, sha); const extractDir = join(BUNDLE_CACHE, slug); freshExtractDir(extractDir); @@ -387,11 +519,20 @@ function stageClaudeCodeBinary(target: TargetInfo): string { `[stage-vendor] no pinned SHA256 for claude-code ${version} — add it to CLAUDE_CODE_SHA256 in stage-vendor.ts`, ); } + const expectedSha = + target.platform === "linux" + ? shaTable.linux?.[target.arch] + : shaTable[target.arch]; + if (!expectedSha) { + throw new Error( + `[stage-vendor] no pinned SHA256 for claude-code ${version} on ${target.platform}/${target.arch} — add it to CLAUDE_CODE_SHA256`, + ); + } ensureCacheDir(); const slug = `claude-code-${target.claudeCodeNpmSuffix}-${version}`; const archive = join(BUNDLE_CACHE, `${slug}.tgz`); const url = `https://registry.npmjs.org/${target.claudeCodePkg}/-/claude-code-${target.claudeCodeNpmSuffix}-${version}.tgz`; - downloadAndVerify(url, archive, shaTable[target.arch]); + downloadAndVerify(url, archive, expectedSha); const extractDir = join(BUNDLE_CACHE, slug); freshExtractDir(extractDir); @@ -485,11 +626,20 @@ function stageCodexBinary(target: TargetInfo): void { `[stage-vendor] no pinned SHA256 for codex ${version} — add it to CODEX_SHA256 in stage-vendor.ts`, ); } + const expectedSha = + target.platform === "linux" + ? shaTable.linux?.[target.arch] + : shaTable[target.arch]; + if (!expectedSha) { + throw new Error( + `[stage-vendor] no pinned SHA256 for codex ${version} on ${target.platform}/${target.arch} — add it to CODEX_SHA256`, + ); + } ensureCacheDir(); const slug = `codex-${version}-${target.codexNpmSuffix}`; const archive = join(BUNDLE_CACHE, `${slug}.tgz`); const url = `https://registry.npmjs.org/@openai/codex/-/${slug}.tgz`; - downloadAndVerify(url, archive, shaTable[target.arch]); + downloadAndVerify(url, archive, expectedSha); const extractDir = join(BUNDLE_CACHE, slug); freshExtractDir(extractDir); @@ -514,7 +664,7 @@ function stageCodexBinary(target: TargetInfo): void { const target = detectTarget(); console.log( - `[stage-vendor] host=darwin/${process.arch} target=darwin/${target.arch} (${target.codexTriple})`, + `[stage-vendor] host=${process.platform}/${process.arch} target=${target.platform}/${target.arch} (${target.codexTriple})`, ); // Clean @@ -528,8 +678,8 @@ stageClaudeCodeBinary(target); stageCodexBinary(target); // ----- gh + glab (forge CLIs) ----- -stageGhBinary(target.ghArch); -stageGlabBinary(target.glabArch); +stageGhBinary(target); +stageGlabBinary(target); // ----- Summary ----- console.log(`[stage-vendor] ✓ staged → ${DIST_VENDOR}`); diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml index 7cf37df38..e5b6124bd 100644 --- a/src-tauri/.cargo/config.toml +++ b/src-tauri/.cargo/config.toml @@ -1,5 +1,2 @@ -[build] -rustc-wrapper = "sccache" - [target.aarch64-apple-darwin] rustflags = ["-C", "link-arg=-Wl,-no_deduplicate"] diff --git a/src-tauri/src/commands/editors.rs b/src-tauri/src/commands/editors.rs index f8a6204d0..d0d8f92ea 100644 --- a/src-tauri/src/commands/editors.rs +++ b/src-tauri/src/commands/editors.rs @@ -30,6 +30,9 @@ pub struct EditorSpec { pub id: &'static str, pub name: &'static str, /// macOS `CFBundleIdentifier`s. Multiple entries cover stable/preview/CE variants. + /// Only `mdfind_candidate_paths` (macOS-only) reads this in the lib build; + /// catalog tests read it on every platform via `#[cfg(test)]`. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub bundle_ids: &'static [&'static str], /// Well-known install paths. `$HOME` is expanded at runtime. pub known_paths: &'static [&'static str], @@ -448,13 +451,26 @@ fn launch_with_open( cmd.spawn().map(|_| ()).context("open command failed") } -#[cfg(not(target_os = "macos"))] +/// Linux: hand the workspace directory to the user's default handler via +/// `xdg-open`. Selecting a *specific* editor (the macOS `open -a` behavior) +/// would require parsing `.desktop` files and per-DE conventions; we let the +/// user configure their preferred handler at the OS level instead. +#[cfg(target_os = "linux")] +fn launch_with_open( + _app_path: Option<&str>, + _app_name: &str, + dir: &std::path::Path, +) -> anyhow::Result<()> { + xdg_open(dir) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn launch_with_open( _app_path: Option<&str>, _app_name: &str, _dir: &std::path::Path, ) -> anyhow::Result<()> { - anyhow::bail!("Opening third-party editors is only supported on macOS") + anyhow::bail!("Opening third-party editors is only supported on macOS and Linux") } #[cfg(target_os = "macos")] @@ -466,9 +482,37 @@ fn reveal_in_finder(dir: &std::path::Path) -> anyhow::Result<()> { .context("open command failed") } -#[cfg(not(target_os = "macos"))] +/// Linux: `xdg-open` on the parent directory so the workspace is highlighted +/// in the default file manager, matching the "reveal" semantics implied by +/// the function name. +#[cfg(target_os = "linux")] +fn reveal_in_finder(dir: &std::path::Path) -> anyhow::Result<()> { + let target = dir.parent().unwrap_or(dir); + xdg_open(target) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn reveal_in_finder(_dir: &std::path::Path) -> anyhow::Result<()> { - anyhow::bail!("Opening Finder is only supported on macOS") + anyhow::bail!("Opening a file manager is only supported on macOS and Linux") +} + +/// Spawn `xdg-open `. On Linux this is the canonical "open this in the +/// user's default handler" entry point; we deliberately don't probe Wayland +/// vs X11 ourselves — `xdg-open` is the abstraction designed for that. +/// +/// Detached spawn (no wait): the file manager / editor lives well past the +/// point where the user expects this Tauri command to return. +#[cfg(target_os = "linux")] +fn xdg_open(path: &std::path::Path) -> anyhow::Result<()> { + use std::process::Command; + + match Command::new("xdg-open").arg(path).spawn() { + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("xdg-open not found, please install xdg-utils") + } + Err(error) => Err(anyhow::Error::new(error).context("xdg-open spawn failed")), + } } #[tauri::command] diff --git a/src-tauri/src/commands/system_commands.rs b/src-tauri/src/commands/system_commands.rs index 8ce794d99..2b0e2bc7f 100644 --- a/src-tauri/src/commands/system_commands.rs +++ b/src-tauri/src/commands/system_commands.rs @@ -67,7 +67,26 @@ pub struct HelmorSkillsStatus { pub command: String, } -/// Where Helmor installs its managed CLI entrypoint on macOS. +/// Where Helmor installs its managed CLI entrypoint. +/// +/// macOS: `/usr/local/bin/` — the canonical Homebrew-style path that +/// `$PATH` already covers for most users. +/// +/// Linux: `~/.local/bin/` — the XDG-recommended user-local bindir, +/// which avoids needing `sudo` to drop a symlink in a system directory. +/// The freedesktop.org `user-dirs` spec ensures this is on `$PATH` for +/// modern distros; on older setups the user is told to add it. +#[cfg(target_os = "macos")] +fn cli_install_target() -> std::path::PathBuf { + std::path::PathBuf::from(format!("/usr/local/bin/{}", installed_cli_name())) +} + +#[cfg(target_os = "linux")] +fn cli_install_target() -> std::path::PathBuf { + home_dir().join(".local/bin").join(installed_cli_name()) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn cli_install_target() -> std::path::PathBuf { std::path::PathBuf::from(format!("/usr/local/bin/{}", installed_cli_name())) } @@ -196,7 +215,19 @@ fn install_cli_symlink( { install_cli_symlink_elevated(bundled_cli, install_path) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + { + // Linux installs to ~/.local/bin (user-writable) so the unprivileged + // path above should normally succeed. Hitting this branch means the + // user-local install dir itself is not writable — likely a permissions + // problem on $HOME, not something `sudo` would fix. + anyhow::bail!( + "Failed to install the CLI to {}. Check write permissions on the directory, or run:\n {}", + install_path.display(), + cli_install_remediation(bundled_cli, install_path) + ) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] { anyhow::bail!( "Installing the CLI requires elevated privileges. Run:\n {}", @@ -310,6 +341,10 @@ fn build_elevated_install_script( /// Quote a path so it survives both `do shell script "..."` (AppleScript string /// literal) and the shell that AppleScript hands the script to. +/// +/// Only `build_elevated_install_script` (macOS-only) calls this in the lib build; +/// its unit tests run on every platform via `#[cfg(test)]`. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn applescript_shell_arg(path: &std::path::Path) -> String { let raw = path.display().to_string(); // 1. Single-quote for the shell, escaping embedded single quotes via `'\''`. @@ -1067,9 +1102,27 @@ fn reveal_file_in_finder(path: &std::path::Path) -> anyhow::Result<()> { .context("open command failed") } -#[cfg(not(target_os = "macos"))] +/// Linux: file managers vary widely in their "select this exact file" RPC +/// surface (Nautilus, Dolphin, Nemo all differ). Falling back to opening +/// the parent directory in the default file manager keeps a single code +/// path that works everywhere — the user can spot the file there. +#[cfg(target_os = "linux")] +fn reveal_file_in_finder(path: &std::path::Path) -> anyhow::Result<()> { + let target = path.parent().unwrap_or(path); + let status = std::process::Command::new("xdg-open").arg(target).status(); + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => anyhow::bail!("xdg-open exited with status {s}"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("xdg-open not found, please install xdg-utils") + } + Err(error) => Err(anyhow::Error::new(error).context("xdg-open spawn failed")), + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn reveal_file_in_finder(_path: &std::path::Path) -> anyhow::Result<()> { - anyhow::bail!("Showing images in Finder is only supported on macOS") + anyhow::bail!("Showing images in a file manager is only supported on macOS and Linux") } #[cfg(target_os = "macos")] @@ -1081,9 +1134,23 @@ fn open_directory_in_finder(path: &std::path::Path) -> anyhow::Result<()> { .context("open command failed") } -#[cfg(not(target_os = "macos"))] +/// Linux: hand the directory to the default file manager via `xdg-open`. +#[cfg(target_os = "linux")] +fn open_directory_in_finder(path: &std::path::Path) -> anyhow::Result<()> { + let status = std::process::Command::new("xdg-open").arg(path).status(); + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => anyhow::bail!("xdg-open exited with status {s}"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("xdg-open not found, please install xdg-utils") + } + Err(error) => Err(anyhow::Error::new(error).context("xdg-open spawn failed")), + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn open_directory_in_finder(_path: &std::path::Path) -> anyhow::Result<()> { - anyhow::bail!("Opening Finder is only supported on macOS") + anyhow::bail!("Opening a file manager is only supported on macOS and Linux") } #[cfg(target_os = "macos")] @@ -1117,11 +1184,100 @@ fn copy_image_file_to_clipboard(path: &std::path::Path) -> anyhow::Result<()> { } } -#[cfg(not(target_os = "macos"))] +/// Linux: pipe the image bytes into a clipboard utility. Prefer `wl-copy` +/// under Wayland and `xclip` under X11; treat the absence of both as a +/// hard error rather than silently dropping the copy. +#[cfg(target_os = "linux")] +fn copy_image_file_to_clipboard(path: &std::path::Path) -> anyhow::Result<()> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mime = mime_for_image_path(path); + let bytes = std::fs::read(path) + .with_context(|| format!("Failed to read image at {}", path.display()))?; + + let prefer_wayland = std::env::var("XDG_SESSION_TYPE") + .map(|value| value.eq_ignore_ascii_case("wayland")) + .unwrap_or(false); + + let attempts: &[(&str, &[&str])] = if prefer_wayland { + &[ + ("wl-copy", &["--type", mime]), + ("xclip", &["-selection", "clipboard", "-t", mime, "-i"]), + ] + } else { + &[ + ("xclip", &["-selection", "clipboard", "-t", mime, "-i"]), + ("wl-copy", &["--type", mime]), + ] + }; + + let mut last_spawn_error: Option = None; + for (program, args) in attempts { + let mut command = Command::new(program); + command.args(*args); + command.stdin(Stdio::piped()); + command.stdout(Stdio::null()); + command.stderr(Stdio::piped()); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + last_spawn_error = Some(error); + continue; + } + Err(error) => { + return Err(anyhow::Error::new(error).context(format!("{program} spawn failed"))) + } + }; + + if let Some(stdin) = child.stdin.as_mut() { + stdin + .write_all(&bytes) + .with_context(|| format!("Failed to write image bytes to {program}"))?; + } + // Closing stdin signals EOF to wl-copy / xclip so they finish reading. + drop(child.stdin.take()); + + let output = child + .wait_with_output() + .with_context(|| format!("Failed to wait on {program}"))?; + if output.status.success() { + return Ok(()); + } + anyhow::bail!( + "{program} exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let _ = last_spawn_error; + anyhow::bail!("wl-copy or xclip required to copy images on Linux") +} + +#[cfg(target_os = "linux")] +fn mime_for_image_path(path: &std::path::Path) -> &'static str { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("bmp") => "image/bmp", + _ => "image/png", + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn copy_image_file_to_clipboard(_path: &std::path::Path) -> anyhow::Result<()> { - anyhow::bail!("Copying images is only supported on macOS") + anyhow::bail!("Copying images is only supported on macOS and Linux") } +#[cfg(target_os = "macos")] fn applescript_escape(input: &str) -> String { input.replace('\\', "\\\\").replace('"', "\\\"") } diff --git a/src-tauri/src/forge/bundled.rs b/src-tauri/src/forge/bundled.rs index 94ae8a004..159b9e578 100644 --- a/src-tauri/src/forge/bundled.rs +++ b/src-tauri/src/forge/bundled.rs @@ -71,20 +71,41 @@ fn resolve_from_running_exe() -> BundledForgeCliPaths { } fn resolve_for_exe(exe: &Path) -> Option { - let exe_dir = exe.parent()?; - let contents_dir = exe_dir.parent()?; - let resources_dir = contents_dir.join("Resources"); + // Linux dpkg / AppImage layout: `/bin/helmor` with vendored + // binaries under `/lib/Helmor/vendor/`. On a system .deb the + // prefix is `/usr`; under an AppImage AppDir it's `/usr`. + #[cfg(target_os = "linux")] + { + let exe_dir = exe.parent()?; + let prefix = exe_dir.parent()?; + let vendor = prefix.join("lib").join("Helmor").join("vendor"); + let gh = vendor.join("gh/gh"); + let glab = vendor.join("glab/glab"); + return Some(BundledForgeCliPaths { + gh: gh.is_file().then_some(gh), + glab: glab.is_file().then_some(glab), + }); + } - let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" }; - let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" }; + // macOS: `/Helmor.app/Contents/MacOS/Helmor` → `Resources/vendor/`. + // Windows: NSIS bundle uses the same `/Resources/vendor/` shape. + #[cfg_attr(target_os = "linux", allow(unreachable_code))] + { + let exe_dir = exe.parent()?; + let contents_dir = exe_dir.parent()?; + let resources_dir = contents_dir.join("Resources"); - let gh = resources_dir.join(format!("vendor/gh/{gh_name}")); - let glab = resources_dir.join(format!("vendor/glab/{glab_name}")); + let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" }; + let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" }; - Some(BundledForgeCliPaths { - gh: gh.is_file().then_some(gh), - glab: glab.is_file().then_some(glab), - }) + let gh = resources_dir.join(format!("vendor/gh/{gh_name}")); + let glab = resources_dir.join(format!("vendor/glab/{glab_name}")); + + Some(BundledForgeCliPaths { + gh: gh.is_file().then_some(gh), + glab: glab.is_file().then_some(glab), + }) + } } #[cfg(debug_assertions)] @@ -124,6 +145,7 @@ fn resolve_for_dev_workspace(workspace_root: &Path) -> BundledForgeCliPaths { mod tests { use super::*; + #[cfg(target_os = "macos")] #[test] fn resolve_finds_binaries_under_resources_vendor() { let root = tempfile::tempdir().unwrap(); @@ -148,6 +170,27 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn linux_dpkg_layout_resolves_vendor() { + let root = tempfile::tempdir().unwrap(); + let usr = root.path().join("usr"); + let bin = usr.join("bin"); + let vendor = usr.join("lib/Helmor/vendor"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(vendor.join("gh")).unwrap(); + std::fs::create_dir_all(vendor.join("glab")).unwrap(); + let exe = bin.join("helmor"); + std::fs::write(&exe, b"x").unwrap(); + std::fs::write(vendor.join("gh/gh"), b"x").unwrap(); + std::fs::write(vendor.join("glab/glab"), b"x").unwrap(); + + let paths = resolve_for_exe(&exe).unwrap(); + + assert_eq!(paths.gh.unwrap(), vendor.join("gh/gh")); + assert_eq!(paths.glab.unwrap(), vendor.join("glab/glab")); + } + #[test] fn resolve_returns_none_when_binaries_missing() { let root = tempfile::tempdir().unwrap(); @@ -173,7 +216,7 @@ mod tests { assert_eq!(paths.glab.unwrap(), vendor.join("glab/glab")); } - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, target_os = "macos"))] #[test] fn app_bundle_paths_win_over_debug_vendor() { let root = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77a7f6d94..1a157d398 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -462,7 +462,9 @@ fn emit_quit_requested(app_handle: &tauri::AppHandle) { } } +#[cfg(target_os = "macos")] const HELMOR_QUIT_MENU_ID: &str = "helmor-quit"; +#[cfg(target_os = "macos")] const HELMOR_CLOSE_CURRENT_SESSION_MENU_ID: &str = "helmor-close-current-session"; #[cfg(target_os = "macos")] @@ -530,6 +532,7 @@ fn install_macos_menu(app: &tauri::AppHandle) -> tauri::Result<()> { Ok(()) } +#[cfg(target_os = "macos")] fn emit_close_current_session_requested(app_handle: &tauri::AppHandle) { if let Err(e) = app_handle.emit("helmor://close-current-session", ()) { tracing::warn!(error = %e, "Failed to emit close-current-session event"); diff --git a/src-tauri/src/rate_limits/claude/keychain.rs b/src-tauri/src/rate_limits/claude/keychain.rs index 95a73b558..0474c4cac 100644 --- a/src-tauri/src/rate_limits/claude/keychain.rs +++ b/src-tauri/src/rate_limits/claude/keychain.rs @@ -1,252 +1,26 @@ -//! Reading Claude OAuth credentials from the macOS keychain. +//! Cross-platform Claude OAuth credentials loader. //! -//! Metadata probe (no UI) enumerates accounts; then `/usr/bin/security -//! find-generic-password -w` reads the password for each. Routing the -//! read through the system binary attaches the user's "Always Allow" -//! grant to a signature that never changes, instead of Helmor's -//! (which changes on every upgrade and dev rebuild). - -use anyhow::{anyhow, Result}; - -use super::credentials::{now_ms, parse_credentials, sort_credentials, ClaudeOAuthCredentials}; - -pub(super) const CLAUDE_KEYCHAIN_SERVICE: &str = "Claude Code-credentials"; - -#[cfg(target_os = "macos")] -const SECURITY_BINARY_PATH: &str = "/usr/bin/security"; - -/// Per-candidate keychain CLI timeout. On the happy path the read -/// returns in single-digit ms; this only matters when a macOS prompt -/// is up and waiting for the user. 5 min is intentionally generous so -/// users who get up to grab coffee mid-prompt don't come back to a -/// killed dialog. -#[cfg(target_os = "macos")] -const SECURITY_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60); - -/// How long we wait between SIGTERM-ing the process group and -/// escalating to SIGKILL. Long enough for `/usr/bin/security` to -/// release its keychain handle and tear down the dialog cleanly. -#[cfg(target_os = "macos")] -const SECURITY_CLI_SIGTERM_GRACE: std::time::Duration = std::time::Duration::from_millis(400); - -/// Poll interval while waiting for `/usr/bin/security`. Tighter than -/// `process::run_with_timeout`'s 50ms because we want to react to a -/// timeout fast (the user might be staring at a stuck prompt) and the -/// extra wakeups are negligible for a process that lives ms to s. -#[cfg(target_os = "macos")] -const SECURITY_CLI_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20); - -/// Pick the best credential entry available across all matching -/// keychain items. -pub(super) fn load_best_credentials() -> Result { - let mut credentials = load_keychain_credentials()?; - let now = now_ms(); - sort_credentials(&mut credentials, now); - credentials - .into_iter() - .rev() - .find(|credential| !credential.access_token.trim().is_empty()) - .ok_or_else(|| anyhow!("No Claude Code OAuth credentials found in Keychain")) -} - -#[cfg(target_os = "macos")] -fn load_keychain_credentials() -> Result> { - let mut credentials = Vec::new(); - for account in keychain_account_candidates().into_iter().take(3) { - let Some(data) = read_via_security_cli(CLAUDE_KEYCHAIN_SERVICE, Some(&account)) else { - continue; - }; - if let Some(credential) = parse_credentials(&data) { - credentials.push(credential); - } - } - Ok(credentials) -} - -#[cfg(not(target_os = "macos"))] -fn load_keychain_credentials() -> Result> { - Ok(Vec::new()) -} - -fn keychain_account_candidates() -> Vec { - // The metadata probe lists every account name actually present in - // the keychain for our service — when it succeeds we know exactly - // which accounts to try and don't need the env-var guesses or the - // "Claude Code" fallback. - let probed = keychain_accounts_without_prompt(); - if !probed.is_empty() { - return probed; - } - - let mut accounts = Vec::new(); - for key in ["USER", "LOGNAME"] { - if let Ok(value) = std::env::var(key) { - push_unique_account(&mut accounts, value); - } - } - push_unique_account(&mut accounts, "Claude Code".to_string()); - accounts -} +//! Each platform has its own backend: +//! - macOS: Keychain via `/usr/bin/security` + the Security framework +//! (in `macos_keychain`). +//! - Linux: the on-disk `~/.claude/.credentials.json` file Claude CLI +//! maintains itself (in `super::linux_credentials`). +//! - Other targets: hard error (no Claude credentials path defined). +//! +//! This module's only job is to expose `load_best_credentials` on every +//! supported target and dispatch to the right backend. #[cfg(target_os = "macos")] -fn keychain_accounts_without_prompt() -> Vec { - use core_foundation::base::{CFTypeRef, TCFType}; - use core_foundation::string::CFString; - use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult}; - use security_framework_sys::item::kSecAttrAccount; - - let results = match ItemSearchOptions::new() - .class(ItemClass::generic_password()) - .service(CLAUDE_KEYCHAIN_SERVICE) - .load_attributes(true) - .skip_authenticated_items(true) - .limit(Limit::All) - .search() - { - Ok(results) => results, - Err(error) => { - tracing::debug!("Claude Keychain account probe failed: {error}"); - return Vec::new(); - } - }; - - // SAFETY: `kSecAttrAccount` is a static `CFStringRef` exported by - // the Security framework. Casting it to `CFTypeRef` is the standard - // way to use it as a dictionary key — the underlying object is - // immortal so no retain/release dance is required. - let account_key = unsafe { kSecAttrAccount as CFTypeRef }; - results - .into_iter() - .filter_map(|result| { - let SearchResult::Dict(attrs) = result else { - return None; - }; - let account = attrs.find(account_key)?; - // SAFETY: `attrs` returned a `CFTypeRef` we know is a - // `CFStringRef` (account attribute). `wrap_under_get_rule` - // takes a borrowed reference and increments the retain - // count, balanced by `CFString`'s `Drop`. - let account = unsafe { CFString::wrap_under_get_rule(*account as _) }; - let account = account.to_string(); - (!account.trim().is_empty()).then_some(account) - }) - .collect() -} - -#[cfg(not(target_os = "macos"))] -fn keychain_accounts_without_prompt() -> Vec { - Vec::new() -} - -fn push_unique_account(accounts: &mut Vec, account: String) { - let trimmed = account.trim(); - if trimmed.is_empty() || accounts.iter().any(|existing| existing == trimmed) { - return; - } - accounts.push(trimmed.to_string()); -} +mod macos_keychain; -/// `/usr/bin/security find-generic-password -s -a -w`. -/// -/// Returns the keychain item's password bytes on success, `None` -/// otherwise (binary missing, non-zero exit, timeout, hung prompt). -/// On timeout the entire process group is SIGTERM'd and then SIGKILL'd -/// to avoid leaking a hung subprocess; macOS dismisses the keychain -/// dialog when the requesting process dies. #[cfg(target_os = "macos")] -fn read_via_security_cli(service: &str, account: Option<&str>) -> Option> { - use std::io::Read; - use std::os::unix::process::CommandExt; - use std::process::{Command, Stdio}; - use std::time::Instant; - - if !std::path::Path::new(SECURITY_BINARY_PATH).exists() { - tracing::debug!("/usr/bin/security not present, skipping CLI read"); - return None; - } - - let mut cmd = Command::new(SECURITY_BINARY_PATH); - cmd.arg("find-generic-password").args(["-s", service]); - if let Some(a) = account.filter(|a| !a.is_empty()) { - cmd.args(["-a", a]); - } - cmd.arg("-w"); - cmd.stdout(Stdio::piped()) - .stderr(Stdio::null()) - .stdin(Stdio::null()); - - // SAFETY: `setpgid` is async-signal-safe, which is the only family - // of calls allowed between fork and exec. - unsafe { - cmd.pre_exec(|| { - if libc::setpgid(0, 0) != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } +pub(super) use macos_keychain::load_best_credentials; - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(error) => { - tracing::debug!("Failed to spawn /usr/bin/security: {error}"); - return None; - } - }; - let pgid = child.id() as libc::pid_t; - let deadline = Instant::now() + SECURITY_CLI_TIMEOUT; +#[cfg(target_os = "linux")] +pub(super) use super::linux_credentials::load_best_credentials; - loop { - match child.try_wait() { - Ok(Some(status)) => { - if !status.success() { - // Common cases: item not found, user denied, etc. - // Logged at debug since "not found" is normal on - // first launch before the user has run claude login. - tracing::debug!( - "/usr/bin/security exited with status {status:?} for service {service}" - ); - return None; - } - let mut buf = Vec::new(); - child.stdout.as_mut()?.read_to_end(&mut buf).ok()?; - while matches!(buf.last(), Some(b'\n' | b'\r')) { - buf.pop(); - } - if buf.is_empty() { - return None; - } - return Some(buf); - } - Ok(None) if Instant::now() >= deadline => { - tracing::warn!( - "/usr/bin/security read timed out after {:?}, killing process group", - SECURITY_CLI_TIMEOUT - ); - // SAFETY: passing a negative pid to `kill` signals the - // whole process group rooted at `pgid`. `pgid` is the - // child we just `setpgid(0, 0)`'d, so the only members - // are `/usr/bin/security` and any subprocess it forked. - unsafe { - libc::kill(-pgid, libc::SIGTERM); - } - std::thread::sleep(SECURITY_CLI_SIGTERM_GRACE); - if matches!(child.try_wait(), Ok(None)) { - // SAFETY: same as above. SIGKILL is the escalation - // when the SIGTERM grace period didn't reap. - unsafe { - libc::kill(-pgid, libc::SIGKILL); - } - let _ = child.kill(); - } - let _ = child.wait(); - return None; - } - Ok(None) => std::thread::sleep(SECURITY_CLI_POLL_INTERVAL), - Err(error) => { - tracing::debug!("/usr/bin/security try_wait failed: {error}"); - return None; - } - } - } +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +pub(super) fn load_best_credentials() -> anyhow::Result +{ + anyhow::bail!("Claude OAuth credentials not supported on this platform") } diff --git a/src-tauri/src/rate_limits/claude/linux_credentials.rs b/src-tauri/src/rate_limits/claude/linux_credentials.rs new file mode 100644 index 000000000..f720e583e --- /dev/null +++ b/src-tauri/src/rate_limits/claude/linux_credentials.rs @@ -0,0 +1,222 @@ +//! Reading Claude OAuth credentials from the on-disk file Claude CLI +//! uses on Linux. +//! +//! Unlike macOS (Keychain) and Windows (DPAPI), Linux Claude CLI does +//! not call out to libsecret / GNOME Keyring / kwallet. It writes the +//! credentials to `~/.claude/.credentials.json` (or the directory in +//! `$CLAUDE_CONFIG_DIR`) as a plain JSON file. We mirror the same +//! lookup order so Helmor reads from exactly the file Claude CLI +//! itself reads from — no IPC, no daemon, no extra dependencies. + +use std::fs; +use std::path::PathBuf; + +use anyhow::{anyhow, bail, Context, Result}; + +use super::credentials::{now_ms, parse_credentials, sort_credentials, ClaudeOAuthCredentials}; + +const CREDENTIALS_FILE_NAME: &str = ".credentials.json"; +const CLAUDE_DIR_NAME: &str = ".claude"; + +/// Pick the best credential entry available in the on-disk file. +/// +/// Mirrors `keychain::load_best_credentials` — same return type, same +/// "non-empty access_token, best by sort order" selection. +pub(super) fn load_best_credentials() -> Result { + let mut credentials = load_file_credentials()?; + let now = now_ms(); + sort_credentials(&mut credentials, now); + credentials + .into_iter() + .rev() + .find(|credential| !credential.access_token.trim().is_empty()) + .ok_or_else(|| anyhow!("No Claude Code OAuth credentials found in credentials file")) +} + +fn load_file_credentials() -> Result> { + let path = credentials_path()?; + if !path.exists() { + bail!("Claude credentials file not found at {}", path.display()); + } + let data = fs::read(&path).with_context(|| { + format!( + "Failed to read Claude credentials file at {}", + path.display() + ) + })?; + let credential = parse_credentials(&data).ok_or_else(|| { + anyhow!( + "Failed to parse Claude credentials file at {} as JSON", + path.display() + ) + })?; + Ok(vec![credential]) +} + +fn credentials_path() -> Result { + let dir = claude_config_dir()?; + Ok(dir.join(CREDENTIALS_FILE_NAME)) +} + +fn claude_config_dir() -> Result { + if let Some(value) = std::env::var_os("CLAUDE_CONFIG_DIR") { + let path = PathBuf::from(value); + if !path.as_os_str().is_empty() { + return Ok(path); + } + } + let home = home_dir() + .ok_or_else(|| anyhow!("Unable to locate home directory for Claude credentials lookup"))?; + Ok(home.join(CLAUDE_DIR_NAME)) +} + +/// Resolve `$HOME` without pulling in the `dirs` crate. On Linux the +/// `HOME` env var is the canonical answer; we never run as a service +/// without it set, and we do not want to add a getpwuid dependency +/// just for this single call site. +fn home_dir() -> Option { + std::env::var_os("HOME") + .map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Mutex; + + /// Env vars are process-global; the tests in this module mutate + /// `HOME` and `CLAUDE_CONFIG_DIR`. Serialize them so cargo's + /// per-binary parallelism doesn't corrupt each other's state. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + // SAFETY: protected by ENV_LOCK; no other thread in this + // test binary touches env while the guard is alive. + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } + + fn unset(key: &'static str) -> Self { + let previous = std::env::var_os(key); + // SAFETY: see `set`. + unsafe { std::env::remove_var(key) }; + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: see `set`. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + fn temp_dir(label: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.subsec_nanos()) + .unwrap_or(0); + path.push(format!("helmor-linux-creds-{label}-{pid}-{nanos}")); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + #[test] + fn returns_error_when_file_is_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + let dir = temp_dir("missing"); + let _guard = EnvGuard::set("CLAUDE_CONFIG_DIR", &dir); + let _home_guard = EnvGuard::unset("HOME"); + + let error = load_best_credentials().expect_err("missing file should fail"); + let message = error.to_string(); + assert!( + message.contains("not found"), + "expected not-found error, got: {message}" + ); + + fs::remove_dir_all(dir).ok(); + } + + #[test] + fn returns_error_when_json_is_corrupted() { + let _lock = ENV_LOCK.lock().unwrap(); + let dir = temp_dir("corrupt"); + let path = dir.join(CREDENTIALS_FILE_NAME); + fs::write(&path, b"this is not json").expect("write corrupt file"); + let _guard = EnvGuard::set("CLAUDE_CONFIG_DIR", &dir); + let _home_guard = EnvGuard::unset("HOME"); + + let error = load_best_credentials().expect_err("corrupt JSON should fail"); + let message = error.to_string(); + assert!( + message.contains("parse"), + "expected parse error, got: {message}" + ); + + fs::remove_dir_all(dir).ok(); + } + + #[test] + fn parses_single_valid_credential() { + let _lock = ENV_LOCK.lock().unwrap(); + let dir = temp_dir("valid"); + let path = dir.join(CREDENTIALS_FILE_NAME); + let body = br#"{"claudeAiOauth":{"accessToken":"acc-123","refreshToken":"ref-456","expiresAt":9999999999999,"scopes":["user:profile"],"subscriptionType":"pro"}}"#; + fs::write(&path, body).expect("write valid file"); + let _guard = EnvGuard::set("CLAUDE_CONFIG_DIR", &dir); + let _home_guard = EnvGuard::unset("HOME"); + + let credentials = load_best_credentials().expect("valid credentials should parse"); + assert_eq!(credentials.access_token, "acc-123"); + assert!(credentials.has_required_scope()); + assert!(!credentials.is_expired(0)); + + fs::remove_dir_all(dir).ok(); + } + + #[test] + fn falls_back_to_home_when_env_var_unset() { + let _lock = ENV_LOCK.lock().unwrap(); + let home = temp_dir("home-fallback"); + let claude_dir = home.join(CLAUDE_DIR_NAME); + fs::create_dir_all(&claude_dir).expect("create .claude dir"); + let path = claude_dir.join(CREDENTIALS_FILE_NAME); + let body = + br#"{"accessToken":"home-tok","expiresAt":9999999999999,"scopes":["user:profile"]}"#; + fs::write(&path, body).expect("write valid file"); + let _env_guard = EnvGuard::unset("CLAUDE_CONFIG_DIR"); + let _home_guard = EnvGuard::set("HOME", &home); + + let credentials = load_best_credentials().expect("home fallback should resolve"); + assert_eq!(credentials.access_token, "home-tok"); + + fs::remove_dir_all(home).ok(); + } + + #[test] + fn errors_when_neither_env_nor_home_is_set() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env_guard = EnvGuard::unset("CLAUDE_CONFIG_DIR"); + let _home_guard = EnvGuard::unset("HOME"); + + let error = load_best_credentials().expect_err("no env should fail"); + assert!(error.to_string().contains("home directory")); + } +} diff --git a/src-tauri/src/rate_limits/claude/macos_keychain.rs b/src-tauri/src/rate_limits/claude/macos_keychain.rs new file mode 100644 index 000000000..56de4fb23 --- /dev/null +++ b/src-tauri/src/rate_limits/claude/macos_keychain.rs @@ -0,0 +1,235 @@ +//! Reading Claude OAuth credentials from the macOS Keychain. +//! +//! Metadata probe (no UI) enumerates accounts; then `/usr/bin/security +//! find-generic-password -w` reads the password for each. Routing the +//! read through the system binary attaches the user's "Always Allow" +//! grant to a signature that never changes, instead of Helmor's +//! (which changes on every upgrade and dev rebuild). + +use anyhow::{anyhow, Result}; + +use super::credentials::{now_ms, parse_credentials, sort_credentials, ClaudeOAuthCredentials}; + +const CLAUDE_KEYCHAIN_SERVICE: &str = "Claude Code-credentials"; + +const SECURITY_BINARY_PATH: &str = "/usr/bin/security"; + +/// Per-candidate keychain CLI timeout. On the happy path the read +/// returns in single-digit ms; this only matters when a macOS prompt +/// is up and waiting for the user. 5 min is intentionally generous so +/// users who get up to grab coffee mid-prompt don't come back to a +/// killed dialog. +const SECURITY_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60); + +/// How long we wait between SIGTERM-ing the process group and +/// escalating to SIGKILL. Long enough for `/usr/bin/security` to +/// release its keychain handle and tear down the dialog cleanly. +const SECURITY_CLI_SIGTERM_GRACE: std::time::Duration = std::time::Duration::from_millis(400); + +/// Poll interval while waiting for `/usr/bin/security`. Tighter than +/// `process::run_with_timeout`'s 50ms because we want to react to a +/// timeout fast (the user might be staring at a stuck prompt) and the +/// extra wakeups are negligible for a process that lives ms to s. +const SECURITY_CLI_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20); + +/// Pick the best credential entry available across all matching +/// keychain items. +pub(super) fn load_best_credentials() -> Result { + let mut credentials = load_keychain_credentials()?; + let now = now_ms(); + sort_credentials(&mut credentials, now); + credentials + .into_iter() + .rev() + .find(|credential| !credential.access_token.trim().is_empty()) + .ok_or_else(|| anyhow!("No Claude Code OAuth credentials found in Keychain")) +} + +fn load_keychain_credentials() -> Result> { + let mut credentials = Vec::new(); + for account in keychain_account_candidates().into_iter().take(3) { + let Some(data) = read_via_security_cli(CLAUDE_KEYCHAIN_SERVICE, Some(&account)) else { + continue; + }; + if let Some(credential) = parse_credentials(&data) { + credentials.push(credential); + } + } + Ok(credentials) +} + +fn keychain_account_candidates() -> Vec { + // The metadata probe lists every account name actually present in + // the keychain for our service — when it succeeds we know exactly + // which accounts to try and don't need the env-var guesses or the + // "Claude Code" fallback. + let probed = keychain_accounts_without_prompt(); + if !probed.is_empty() { + return probed; + } + + let mut accounts = Vec::new(); + for key in ["USER", "LOGNAME"] { + if let Ok(value) = std::env::var(key) { + push_unique_account(&mut accounts, value); + } + } + push_unique_account(&mut accounts, "Claude Code".to_string()); + accounts +} + +fn keychain_accounts_without_prompt() -> Vec { + use core_foundation::base::{CFTypeRef, TCFType}; + use core_foundation::string::CFString; + use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult}; + use security_framework_sys::item::kSecAttrAccount; + + let results = match ItemSearchOptions::new() + .class(ItemClass::generic_password()) + .service(CLAUDE_KEYCHAIN_SERVICE) + .load_attributes(true) + .skip_authenticated_items(true) + .limit(Limit::All) + .search() + { + Ok(results) => results, + Err(error) => { + tracing::debug!("Claude Keychain account probe failed: {error}"); + return Vec::new(); + } + }; + + // SAFETY: `kSecAttrAccount` is a static `CFStringRef` exported by + // the Security framework. Casting it to `CFTypeRef` is the standard + // way to use it as a dictionary key — the underlying object is + // immortal so no retain/release dance is required. + let account_key = unsafe { kSecAttrAccount as CFTypeRef }; + results + .into_iter() + .filter_map(|result| { + let SearchResult::Dict(attrs) = result else { + return None; + }; + let account = attrs.find(account_key)?; + // SAFETY: `attrs` returned a `CFTypeRef` we know is a + // `CFStringRef` (account attribute). `wrap_under_get_rule` + // takes a borrowed reference and increments the retain + // count, balanced by `CFString`'s `Drop`. + let account = unsafe { CFString::wrap_under_get_rule(*account as _) }; + let account = account.to_string(); + (!account.trim().is_empty()).then_some(account) + }) + .collect() +} + +fn push_unique_account(accounts: &mut Vec, account: String) { + let trimmed = account.trim(); + if trimmed.is_empty() || accounts.iter().any(|existing| existing == trimmed) { + return; + } + accounts.push(trimmed.to_string()); +} + +/// `/usr/bin/security find-generic-password -s -a -w`. +/// +/// Returns the keychain item's password bytes on success, `None` +/// otherwise (binary missing, non-zero exit, timeout, hung prompt). +/// On timeout the entire process group is SIGTERM'd and then SIGKILL'd +/// to avoid leaking a hung subprocess; macOS dismisses the keychain +/// dialog when the requesting process dies. +fn read_via_security_cli(service: &str, account: Option<&str>) -> Option> { + use std::io::Read; + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + use std::time::Instant; + + if !std::path::Path::new(SECURITY_BINARY_PATH).exists() { + tracing::debug!("/usr/bin/security not present, skipping CLI read"); + return None; + } + + let mut cmd = Command::new(SECURITY_BINARY_PATH); + cmd.arg("find-generic-password").args(["-s", service]); + if let Some(a) = account.filter(|a| !a.is_empty()) { + cmd.args(["-a", a]); + } + cmd.arg("-w"); + cmd.stdout(Stdio::piped()) + .stderr(Stdio::null()) + .stdin(Stdio::null()); + + // SAFETY: `setpgid` is async-signal-safe, which is the only family + // of calls allowed between fork and exec. + unsafe { + cmd.pre_exec(|| { + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(error) => { + tracing::debug!("Failed to spawn /usr/bin/security: {error}"); + return None; + } + }; + let pgid = child.id() as libc::pid_t; + let deadline = Instant::now() + SECURITY_CLI_TIMEOUT; + + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + // Common cases: item not found, user denied, etc. + // Logged at debug since "not found" is normal on + // first launch before the user has run claude login. + tracing::debug!( + "/usr/bin/security exited with status {status:?} for service {service}" + ); + return None; + } + let mut buf = Vec::new(); + child.stdout.as_mut()?.read_to_end(&mut buf).ok()?; + while matches!(buf.last(), Some(b'\n' | b'\r')) { + buf.pop(); + } + if buf.is_empty() { + return None; + } + return Some(buf); + } + Ok(None) if Instant::now() >= deadline => { + tracing::warn!( + "/usr/bin/security read timed out after {:?}, killing process group", + SECURITY_CLI_TIMEOUT + ); + // SAFETY: passing a negative pid to `kill` signals the + // whole process group rooted at `pgid`. `pgid` is the + // child we just `setpgid(0, 0)`'d, so the only members + // are `/usr/bin/security` and any subprocess it forked. + unsafe { + libc::kill(-pgid, libc::SIGTERM); + } + std::thread::sleep(SECURITY_CLI_SIGTERM_GRACE); + if matches!(child.try_wait(), Ok(None)) { + // SAFETY: same as above. SIGKILL is the escalation + // when the SIGTERM grace period didn't reap. + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } + let _ = child.kill(); + } + let _ = child.wait(); + return None; + } + Ok(None) => std::thread::sleep(SECURITY_CLI_POLL_INTERVAL), + Err(error) => { + tracing::debug!("/usr/bin/security try_wait failed: {error}"); + return None; + } + } + } +} diff --git a/src-tauri/src/rate_limits/claude/mod.rs b/src-tauri/src/rate_limits/claude/mod.rs index 30fb3992b..9acd586aa 100644 --- a/src-tauri/src/rate_limits/claude/mod.rs +++ b/src-tauri/src/rate_limits/claude/mod.rs @@ -23,6 +23,8 @@ mod cache; mod credentials; mod keychain; +#[cfg(target_os = "linux")] +mod linux_credentials; mod process; mod refresh; mod user_agent; diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 89bf4dd5a..5d695fcc5 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -100,23 +100,43 @@ pub fn load_cursor_api_key() -> Option { } fn resolve_bundled_agent_paths_for_exe(exe: &std::path::Path) -> Option { - let exe_dir = exe.parent()?; - let contents_dir = exe_dir.parent()?; - let resources_dir = contents_dir.join("Resources"); - let claude_bin_name = if cfg!(windows) { - "claude.exe" - } else { - "claude" - }; - let codex_bin_name = if cfg!(windows) { "codex.exe" } else { "codex" }; + // Linux dpkg / AppImage layout: `/bin/helmor` with vendored + // agent binaries under `/lib/Helmor/vendor/`. Same shape as + // `forge::bundled::resolve_for_exe` — keep them aligned. + #[cfg(target_os = "linux")] + { + let exe_dir = exe.parent()?; + let vendor = exe_dir.parent()?.join("lib").join("Helmor").join("vendor"); + let claude_bin = vendor.join("claude-code/claude"); + let codex_bin = vendor.join("codex/codex"); + return Some(BundledAgentPaths { + claude_bin: claude_bin.is_file().then_some(claude_bin), + codex_bin: codex_bin.is_file().then_some(codex_bin), + }); + } - let claude_bin = resources_dir.join(format!("vendor/claude-code/{claude_bin_name}")); - let codex_bin = resources_dir.join(format!("vendor/codex/{codex_bin_name}")); + // macOS: `/Helmor.app/Contents/MacOS/Helmor` → `Resources/vendor/`. + // Windows: NSIS bundle uses the same `Resources/vendor/` shape. + #[cfg_attr(target_os = "linux", allow(unreachable_code))] + { + let exe_dir = exe.parent()?; + let contents_dir = exe_dir.parent()?; + let resources_dir = contents_dir.join("Resources"); + let claude_bin_name = if cfg!(windows) { + "claude.exe" + } else { + "claude" + }; + let codex_bin_name = if cfg!(windows) { "codex.exe" } else { "codex" }; + + let claude_bin = resources_dir.join(format!("vendor/claude-code/{claude_bin_name}")); + let codex_bin = resources_dir.join(format!("vendor/codex/{codex_bin_name}")); - Some(BundledAgentPaths { - claude_bin: claude_bin.is_file().then_some(claude_bin), - codex_bin: codex_bin.is_file().then_some(codex_bin), - }) + Some(BundledAgentPaths { + claude_bin: claude_bin.is_file().then_some(claude_bin), + codex_bin: codex_bin.is_file().then_some(codex_bin), + }) + } } impl SidecarProcess { @@ -860,6 +880,7 @@ mod tests { .is_err()); } + #[cfg(target_os = "macos")] #[test] fn bundled_agent_paths_resolve_from_running_app() { let root = tempfile::tempdir().unwrap(); @@ -883,4 +904,25 @@ mod tests { .join("Helmor.app/Contents/Resources/vendor/codex/codex") ); } + + #[cfg(target_os = "linux")] + #[test] + fn linux_dpkg_layout_resolves_bundled_agents() { + let root = tempfile::tempdir().unwrap(); + let usr = root.path().join("usr"); + let bin = usr.join("bin"); + let vendor = usr.join("lib/Helmor/vendor"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(vendor.join("claude-code")).unwrap(); + std::fs::create_dir_all(vendor.join("codex")).unwrap(); + let exe = bin.join("helmor"); + std::fs::write(&exe, b"x").unwrap(); + std::fs::write(vendor.join("claude-code/claude"), b"x").unwrap(); + std::fs::write(vendor.join("codex/codex"), b"x").unwrap(); + + let paths = resolve_bundled_agent_paths_for_exe(&exe).unwrap(); + + assert_eq!(paths.claude_bin.unwrap(), vendor.join("claude-code/claude")); + assert_eq!(paths.codex_bin.unwrap(), vendor.join("codex/codex")); + } } diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 000000000..1b1d4b4f8 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "label": "main", + "title": "Helmor", + "titleBarStyle": "Visible", + "decorations": true, + "transparent": false + } + ] + }, + "bundle": { + "linux": { + "deb": { + "depends": [ + "libwebkit2gtk-4.1-0", + "libsoup-3.0-0", + "libgtk-3-0t64", + "librsvg2-2", + "libayatana-appindicator3-1" + ] + }, + "appimage": { + "bundleMediaFramework": true + } + }, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.png" + ] + } +} diff --git a/src/App.tsx b/src/App.tsx index fee3a24ab..948e8a760 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,6 +57,7 @@ import { type WorkspaceDetail, type WorkspaceSessionSummary, } from "./lib/api"; +import { usesActionModelOverride } from "./lib/commit-button-prompts"; import { ComposerInsertProvider } from "./lib/composer-insert-context"; import { activeStreamsQueryOptions, @@ -798,12 +799,11 @@ function AppShell({ pushToast: pushWorkspaceToast, }); - // Wrapper that injects the configured PR/MR model overrides for the - // "create-pr" mode so the action runs on the user's preferred PR model - // (with effort + fast-mode falling back to defaults when null). + // Action model covers simple, bounded helper sessions. More involved + // fix/resolve flows keep following the default model. const handleCommitAction = useCallback( (mode: WorkspaceCommitButtonMode) => { - if (mode === "create-pr") { + if (usesActionModelOverride(mode)) { return handleInspectorCommitAction(mode, { modelId: appSettings.prModelId ?? appSettings.defaultModelId, effort: appSettings.prEffort ?? appSettings.defaultEffort, @@ -1080,7 +1080,7 @@ function AppShell({ }, { id: "action.commitAndPush" as const, - callback: () => void handleInspectorCommitAction("commit-and-push"), + callback: () => void handleCommitAction("commit-and-push"), }, { id: "action.pullLatest" as const, diff --git a/src/features/settings/index.tsx b/src/features/settings/index.tsx index feee2c8e9..991cdc4a7 100644 --- a/src/features/settings/index.tsx +++ b/src/features/settings/index.tsx @@ -531,15 +531,15 @@ export const SettingsDialog = memo(function SettingsDialog({ }} /> { const patch: Partial = {}; if (p.modelId !== undefined) patch.prModelId = p.modelId; diff --git a/src/lib/commit-button-prompts.test.ts b/src/lib/commit-button-prompts.test.ts index a9e38314b..b49649ec9 100644 --- a/src/lib/commit-button-prompts.test.ts +++ b/src/lib/commit-button-prompts.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { ForgeDetection } from "./api"; -import { buildCommitButtonPrompt } from "./commit-button-prompts"; +import { + buildCommitButtonPrompt, + usesActionModelOverride, +} from "./commit-button-prompts"; const GITLAB_FORGE: ForgeDetection = { provider: "gitlab", @@ -35,6 +38,15 @@ const GITHUB_FORGE: ForgeDetection = { }; describe("buildCommitButtonPrompt", () => { + it("uses the action model only for simple bounded action sessions", () => { + expect(usesActionModelOverride("create-pr")).toBe(true); + expect(usesActionModelOverride("commit-and-push")).toBe(true); + expect(usesActionModelOverride("open-pr")).toBe(true); + expect(usesActionModelOverride("fix")).toBe(false); + expect(usesActionModelOverride("resolve-conflicts")).toBe(false); + expect(usesActionModelOverride("push")).toBe(false); + }); + it("appends create-pr preferences after the built-in prompt", () => { expect( buildCommitButtonPrompt( diff --git a/src/lib/commit-button-prompts.ts b/src/lib/commit-button-prompts.ts index a38d34fba..30c6b899e 100644 --- a/src/lib/commit-button-prompts.ts +++ b/src/lib/commit-button-prompts.ts @@ -87,6 +87,14 @@ export function isActionSessionMode( ); } +export function usesActionModelOverride( + mode: WorkspaceCommitButtonMode, +): mode is "create-pr" | "commit-and-push" | "open-pr" { + return ( + mode === "create-pr" || mode === "commit-and-push" || mode === "open-pr" + ); +} + /** Whether a session created with this `ActionKind` is eligible for the * auto-hide flow (i.e. can be silently hidden once its post-stream verifier * passes). Auto-created action sessions still get fixed titles, but only a diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 3d34c0dfa..7321c416b 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -122,4 +122,18 @@ describe("settings", () => { }), ); }); + + it("keeps default as a valid model id", async () => { + invokeMock.mockResolvedValue({ + "app.default_model_id": "gpt-5.5", + "app.review_model_id": "default", + "app.pr_model_id": "default", + }); + + const settings = await loadSettings(); + + expect(settings.defaultModelId).toBe("gpt-5.5"); + expect(settings.reviewModelId).toBe("default"); + expect(settings.prModelId).toBe("default"); + }); }); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 1b3865d0c..5656d300b 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -213,14 +213,13 @@ export type AppSettings = { /** Fast-mode flag for the Review helper. When null, falls back to * `defaultFastMode`. */ reviewFastMode: boolean | null; - /** Model used when the inspector "Create PR/MR" action starts a session. - * Applies to both GitHub PRs and GitLab MRs. When null, falls back to - * `defaultModelId`. */ + /** Model used by simple action sessions: create/reopen PR/MR and + * commit-and-push. When null, falls back to `defaultModelId`. */ prModelId: string | null; - /** Effort level for the Create PR/MR helper. When null, falls back to + /** Effort level for simple action sessions. When null, falls back to * `defaultEffort`. */ prEffort: string | null; - /** Fast-mode flag for the Create PR/MR helper. When null, falls back to + /** Fast-mode flag for simple action sessions. When null, falls back to * `defaultFastMode`. */ prFastMode: boolean | null; defaultEffort: string | null; @@ -791,6 +790,10 @@ function readClampedInt( return Math.min(max, Math.max(min, Math.round(n))); } +function readModelId(value: string | undefined): string | null { + return value && value !== "" ? value : null; +} + export async function loadSettings(): Promise { try { const raw = await invoke>("get_app_settings"); @@ -849,14 +852,8 @@ export async function loadSettings(): Promise { raw[SETTINGS_KEY_MAP.workspaceRightSidebarMode] === "context" ? "context" : DEFAULT_SETTINGS.workspaceRightSidebarMode, - defaultModelId: - rawDefaultModelId && rawDefaultModelId !== "default" - ? rawDefaultModelId - : DEFAULT_SETTINGS.defaultModelId, - reviewModelId: - rawReviewModelId && rawReviewModelId !== "default" - ? rawReviewModelId - : DEFAULT_SETTINGS.reviewModelId, + defaultModelId: readModelId(rawDefaultModelId), + reviewModelId: readModelId(rawReviewModelId), reviewEffort: rawReviewEffort && rawReviewEffort !== "" ? rawReviewEffort @@ -867,10 +864,7 @@ export async function loadSettings(): Promise { : rawReviewFastMode === "false" ? false : DEFAULT_SETTINGS.reviewFastMode, - prModelId: - rawPrModelId && rawPrModelId !== "default" - ? rawPrModelId - : DEFAULT_SETTINGS.prModelId, + prModelId: readModelId(rawPrModelId), prEffort: rawPrEffort && rawPrEffort !== "" ? rawPrEffort