From ae760d8ca6ea05b728c08fd55eacfb04876bc6b5 Mon Sep 17 00:00:00 2001 From: cachewraith Date: Tue, 27 Oct 2026 08:23:05 +0700 Subject: [PATCH 1/2] feat(host): the Rust host on macOS (#76 phase 3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/build-mac-host.sh builds aarch64 and x86_64 and lipos one universal wraithgrid-host into host-rs/target/release, where electron-builder takes it for both the arm64 and the x64 app (ad-hoc signed with them); `bun run dist:mac` runs it first - the app looks for the binary on darwin too; Settings → Advanced "Use the Rust host" shows on macOS - CI: the macOS build job runs cargo test and the host parity suite Windows is phase 3b: portable-pty always quotes arguments, so the ready cmd.exe line of an npm .cmd CLI needs a ConPTY spawn of our own. Refs #76 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 11 +++++++++-- CHANGELOG.md | 1 + docs/CONTEXT.md | 4 ++-- docs/DECISIONS.md | 1 + docs/TODO.md | 2 +- electron-builder.yml | 5 +++++ package.json | 2 +- scripts/build-mac-host.sh | 15 +++++++++++++++ src/main/app.ts | 3 ++- src/renderer/components/SettingsView.tsx | 6 +++--- 10 files changed, 40 insertions(+), 10 deletions(-) create mode 100755 scripts/build-mac-host.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2bac7c..af0fc50 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,8 +19,8 @@ permissions: contents: read jobs: - # The Rust pty host (ADR 0004, phase 1): its own checks, then the parity suite against the - # Node host. Linux only until phase 3; the runner's preinstalled Rust toolchain. + # The Rust pty host (ADR 0004): its own checks, then the parity suite against the Node host, + # on Linux here and on macOS in the build job (phase 3); the runner's preinstalled Rust. rust-host: runs-on: ubuntu-22.04 steps: @@ -91,6 +91,13 @@ jobs: - name: E2E smoke test if: runner.os == 'Linux' run: xvfb-run -a bun run test:e2e + # The Rust host on macOS (ADR 0004 phase 3): its tests and the parity suite against the + # Node host, as the rust-host job runs them on Linux. + - name: Rust host on macOS + if: runner.os == 'macOS' + run: | + cargo test --locked --manifest-path host-rs/Cargo.toml + bun run test:host - if: inputs.package run: bun run ${{ matrix.dist }} env: diff --git a/CHANGELOG.md b/CHANGELOG.md index c617db7..d387d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-10-27 +- [host] Rust host phase 3a (#76, ADR 0004): macOS builds carry it too: `bun run dist:mac` builds one universal (arm64 + x64) `wraithgrid-host` (`scripts/build-mac-host.sh`, lipo) bundled in both apps and ad-hoc signed with them; Settings → Advanced "Use the Rust host" shows on macOS; CI's macOS job runs `cargo test` and the host parity suite. Windows is phase 3b: portable-pty always quotes arguments, so the ready `cmd.exe /d /s /c "…"` line of an npm `.cmd` CLI needs a ConPTY spawn of our own (files: scripts/build-mac-host.sh, package.json, electron-builder.yml, .github/workflows/build.yml, src/main/app.ts, src/renderer/components/SettingsView.tsx, docs/CONTEXT.md) - [chat] A ↑ recall or a `/` pick no longer moves the caret a frame later (cause of the intermittent `chat-history.spec.ts` CI failures, e.g. `second promptline one`): the frame could land between a select-all and the typing that replaces it, so the text was added instead; the caret is now placed in a layout effect right after the text is in the box (files: src/renderer/components/chat/Composer.tsx) - [chat] A sideways swipe over the chat no longer pauses follow (the cause of the intermittent `chat.spec.ts:299` CI failure): Chromium sometimes scrolls the chat by the swipe's few px of vertical noise, and that upward move read as the reader going back; for 150 ms after a mostly sideways wheel the chat's own upward scroll is ignored (files: src/renderer/components/chat/ChatView.tsx) - [git] Changes panel: commit message, push/pull confirm and History "Load more" belong to their pane (keyed by pane); a diff past git's 8 MB buffer shows its start marked cut, not the unstaged-only diff; too many changes to list still reads as a repo; paths with spaces in binary/mode-only entries are right (#140) (files: src/renderer/components/DiffPanel.tsx, src/renderer/lib/diff.ts, src/main/git.ts, src/main/app.ts) diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 39a75fa..6541602 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -18,7 +18,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `bun run test:host` — build both pty hosts (electron-vite + `cargo build --release` in host-rs) and run the parity suite (`tests/host`, Linux/macOS); `cargo test`/`cargo clippy` in `host-rs/` - `bun run test:e2e` — build, then Playwright (`tests/e2e`; needs a display, CI uses xvfb-run) - `bun run build` — production build into `out/` -- `bun run dist:linux:portable` — Linux packages in Ubuntu 22.04 Docker; `bun run dist:win` on Windows; `bun run dist:mac` on a Mac +- `bun run dist:linux:portable` — Linux packages in Ubuntu 22.04 Docker; `bun run dist:win` on Windows; `bun run dist:mac` on a Mac (builds a universal Rust host first, `scripts/build-mac-host.sh`) - Release: `bun pm version -m "chore: release %s" && git push --follow-tags` ## Directory map @@ -27,7 +27,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/main/host-start.ts`: start a detached host (fd masking) + socket connect; used by app and bridge `src/main/pane-router.ts`: routes pane ops to the local host or a machine's (by workspace `machineId`) - `src/main/spawn-resolve.ts`: pane spec → exact process (binary, args, cwd, env); local in ipc, remote in the bridge `src/main/ssh.ts`: `~/.ssh/config` hosts, ssh args, ssh child as a duplex stream - `src/main/ipc.ts`: all IPC handlers; validates every payload with zod contracts `src/main/pane-host.ts`: GUI side of the pty host: find/start it, hello + token, create/attach/write/kill, version mismatch -- `host-rs/`: the Rust pty host (ADR 0004; phase 2: Settings → Advanced "Use the Rust host", off by default, Linux; `settings.rustHost` or `WRAITHGRID_RUST_HOST=1` → app.ts `startHost` runs the binary from resources (packaged) or `host-rs/target/release` (dev) instead of host.js; Linux packages bundle it via `extraResources`): `main.rs` lock/token/socket/signals, `server.rs` clients+panes (same frames as `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid, `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio `, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan: the rules) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite vs the Node host (`bun run test:host`) +- `host-rs/`: the Rust pty host (ADR 0004; phase 2/3a: Settings → Advanced "Use the Rust host", off by default, Linux and macOS (Windows: phase 3b); `settings.rustHost` or `WRAITHGRID_RUST_HOST=1` → app.ts `startHost` runs the binary from resources (packaged) or `host-rs/target/release` (dev) instead of host.js; Linux packages bundle it via `extraResources`): `main.rs` lock/token/socket/signals, `server.rs` clients+panes (same frames as `src/host/protocol.ts`), `pty.rs` portable-pty + waitpid, `screen.rs` vt100 snapshot/text, `protocol.rs` frame validation; phase 5: `bridge.rs` = `wraithgrid-host stdio `, the ssh end on a Linux machine (starts/joins a host under `~/.wraithgrid/remote`, injects the token, turns `plan` frames into `create`), `plan.rs` = login-shell PATH, binary, folder, env on that machine; GUI side `src/main/remote-plan.ts` (spec → plan: the rules) + `remote-bin.ts` (preflight `uname -sm`, upload once per version to `~/.wraithgrid/bin`, fallback `wraithgrid host-stdio`), static musl binaries from `scripts/build-remote-hosts.sh` → `host-rs/target/remote` → resources `remote/`; `tests/host/` = parity suite vs the Node host (`bun run test:host`) - `src/host/`: background pty host (ADR 0001), built as `out/main/host.js`, never imports electron: `index.ts` entry (lock, token, socket), `server.ts` panes + clients, `protocol.ts` NDJSON frames (zod), `paths.ts` socket/token paths per user-data dir, `screen.ts` headless xterm + serialize, `fds.ts` inherited-fd masking, `pty-manager.ts` spawns/kills pane processes (per-OS KillPolicy) `src/main/chat/`: chat panes (ADR 0002): `sessions.ts` runs one headless CLI per pane (pipes, JSON lines), `adapter.ts` registry, `claude.ts` stream-json ↔ ChatEvent (+ approvals), `agy.ts` agy's stream-json (no approvals/interrupt: `controls` event → the view restarts it), `codex.ts` `codex app-server` JSON-RPC, `images.ts` pasted images → temp files, `spawn.ts` masked fds/process group, `attach.ts` files from the attach picker, `transcript.ts` copies a conversation to another account's dir (claude transcript, or codex rollout under `sessions/YYYY/MM/DD`, #60) (switch account, #29); add a CLI = one adapter + `chat: true` in agent-clis - `src/main/login.ts`: sign-in dialog backend, per CLI (`LOGIN_FLOWS`): claude `auth login` + `auth status --json`; codex `login --device-auth` (one-time code) + `login status`; `DIALOG_LOGIN_CLIS` in agent-clis says who gets the dialog `src/main/clipboard-image.ts`: clipboard image → temp file → text to paste (path, or `@path` for gemini/agy) - `src/main/pane-env.ts`: builds a pane's env (per-CLI config dir var for agent panes; mod folders as `CLAUDE_CODE_PLUGIN_DIRS` for claude; strips the launching terminal's vars; UTF-8 locale fallback) `src/main/config-store.ts`: config.json load/save, atomic write, bad-file quarantine diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 290669f..86e2290 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -141,3 +141,4 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri - 2026-10-26 | Rust pty host phase 1 (#76, ADR 0004 Proposed): `host-rs/` crate, same socket/frames, `vt100` for the reattach screen, parity suite against the Node host | No native rebuilds per Electron, ~5 MB idle vs ~110 MB, one binary for remote hosts; parity is tested, not described | Tauri move (webview risks, e2e rewrite); `alacritty_terminal` (no row formatting/scrollback capture; kept as fallback) - 2026-10-26 | Rust host phase 5 (#76): the window sends a `plan` (rules as data: candidates, env drop/set, login-shell probe) and the remote Rust bridge only does the machine-specific lookups; the binary is copied over ssh per version and must run (`--version`) before it is used, else `wraithgrid host-stdio` | One set of rules in TS, no Wraithgrid install needed on Linux machines, and a binary that can't run there (untested arm64) falls back instead of breaking the machine | Porting spawn-resolve/pane-env to Rust (two copies of the rules); a separate remote user-data dir shared with an installed Wraithgrid (its host may be another version) - 2026-10-26 | A chat's live view is capped at 1000 items, dropping to 800 (#141) | Bounds memory and per-render work of a pane left open for days; the saved view already keeps only 400, so nothing a restart would show is lost | No cap (memory grows until the pane closes); virtualized list (bigger change, breaks find-in-page and follow) +- 2026-10-27 | Rust host phase 3 split: macOS first (3a), Windows after (3b) with a ConPTY spawn of our own | portable-pty always quotes each argument on Windows, so the ready `cmd.exe /d /s /c "…"` line for npm `.cmd` CLIs (platform.ts) can't pass through it; macOS needs no code change. One universal mac binary (lipo) serves both the arm64 and x64 apps | Splitting the line into argv (cmd.exe doesn't read `\"` escapes); a per-arch mac binary (two extraResources configs for one value) diff --git a/docs/TODO.md b/docs/TODO.md index eec4a03..2c5d75c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,7 +1,7 @@ # TODO ## Now -- #76 Rust host: phase 3 (macOS + Windows) and 4 (default on, drop node-pty) left; phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed +- #76 Rust host: 3a macOS done (never run on a real Mac: only CI's parity suite); 3b Windows next (own ConPTY spawn with a raw command line, named pipe, taskkill tree, Ctrl-C), then 4 (default on, drop node-pty); phase 5 arm64 binary never run on real arm64; old `~/.wraithgrid/bin/wraithgrid-host-*` versions on remote machines are never removed - CI flakes fixed 2026-10-27 (PR #152): chat.spec.ts:299 (sideways-wheel noise paused follow), chat-history.spec.ts (caret placed a frame late collapsed a select-all). Still seen once locally: responsive.spec.ts:131 (Settings nav heading not hidden in a small window), passed 3/3 on rerun - Audit (2026-10-26) bugs: all fixed (#135–#138 PR #148, #139 PR #150, #140 PR #151). Not seen live: #138 cancelled pkexec prompt, #140 renderer-crash reattach, codex cancel/stop paths, agy first-start effort - Audit (2026-10-26) perf: #141 done (PR #149); not measured live yet (a DevTools profile of a long streaming chat before/after would confirm) diff --git a/electron-builder.yml b/electron-builder.yml index d2fe746..c01deeb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -132,6 +132,11 @@ mac: artifactName: ${productName}-${version}-mac-${arch}.${ext} extendInfo: LSMinimumSystemVersion: '12.0' + # The Rust background host (ADR 0004 phase 3), off unless Settings → Advanced turns it on: + # one universal binary for both apps (`bun run dist:mac` runs scripts/build-mac-host.sh). + extraResources: + - from: host-rs/target/release/wraithgrid-host + to: wraithgrid-host dmg: artifactName: ${productName}-${version}-mac-${arch}.${ext} # In-app updates (electron-updater) read latest.yml / latest-linux.yml from this repo's diff --git a/package.json b/package.json index 87c78ac..19c9bba 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dist:linux": "electron-vite build && cargo build --release --manifest-path host-rs/Cargo.toml && bash scripts/build-remote-hosts.sh && electron-builder --linux --publish never", "dist:linux:portable": "bash scripts/dist-linux-docker.sh", "dist:win": "electron-vite build && electron-builder --win --publish never", - "dist:mac": "electron-vite build && electron-builder --mac --publish never", + "dist:mac": "electron-vite build && bash scripts/build-mac-host.sh && electron-builder --mac --publish never", "postinstall": "electron-builder install-app-deps" }, "devDependencies": { diff --git a/scripts/build-mac-host.sh b/scripts/build-mac-host.sh new file mode 100755 index 0000000..d616fcf --- /dev/null +++ b/scripts/build-mac-host.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# The Rust host for the macOS app (ADR 0004 phase 3): one universal binary (Apple Silicon and +# Intel) at host-rs/target/release/wraithgrid-host, where electron-builder takes it from for +# both the arm64 and the x64 app. Run on a Mac. +set -euo pipefail +cd "$(dirname "$0")/../host-rs" +targets=(aarch64-apple-darwin x86_64-apple-darwin) +for t in "${targets[@]}"; do + if command -v rustup >/dev/null; then rustup target add "$t" >/dev/null 2>&1 || true; fi + cargo build --release --locked --target "$t" +done +mkdir -p target/release +lipo -create -output target/release/wraithgrid-host \ + "target/${targets[0]}/release/wraithgrid-host" "target/${targets[1]}/release/wraithgrid-host" +lipo -info target/release/wraithgrid-host diff --git a/src/main/app.ts b/src/main/app.ts index 9ad7f34..1c8019b 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -181,7 +181,8 @@ const hostFiles = hostPaths({ * when packaged, the cargo release build next to `out/` in development; null when there is none. */ const rustHostBin = ((): string | null => { - if (process.platform !== 'linux') return null + // Linux and macOS (ADR 0004 phases 2, 3); Windows waits for its own ConPTY spawn. + if (process.platform !== 'linux' && process.platform !== 'darwin') return null const bin = app.isPackaged ? path.join(process.resourcesPath, 'wraithgrid-host') : path.join(__dirname, '../../host-rs/target/release/wraithgrid-host') diff --git a/src/renderer/components/SettingsView.tsx b/src/renderer/components/SettingsView.tsx index cc69036..af4a202 100644 --- a/src/renderer/components/SettingsView.tsx +++ b/src/renderer/components/SettingsView.tsx @@ -631,13 +631,13 @@ function CliRow() { ) } -/** The Rust background host (ADR 0004 phase 2): off by default, Linux builds that have it. */ +/** The Rust background host (ADR 0004): off by default, Linux and macOS builds that have it. */ function RustHostRow() { const actions = useActions() const on = useApp((s) => s.config.settings.rustHost) const available = useApp((s) => s.info.rustHost) - const linux = useApp((s) => s.info.platform === 'linux') - if (!linux) return null + const unix = useApp((s) => s.info.platform === 'linux' || s.info.platform === 'darwin') + if (!unix) return null return (
From 0d7ea86eeca635a68989b6bb5a191279378203a8 Mon Sep 17 00:00:00 2001 From: cachewraith Date: Tue, 27 Oct 2026 08:23:19 +0700 Subject: [PATCH 2/2] ci: build the universal macOS Rust host on every run, not only at release Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index af0fc50..f60d9fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,6 +98,7 @@ jobs: run: | cargo test --locked --manifest-path host-rs/Cargo.toml bun run test:host + bash scripts/build-mac-host.sh - if: inputs.package run: bun run ${{ matrix.dist }} env: