diff --git a/CHANGELOG.md b/CHANGELOG.md index 131cfedd..0f694172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/). Releases are -cut with `/release:release vX.Y.Z` — it curates the section below, commits, tags +cut with `/release-workflows:release vX.Y.Z` — it curates the section below, commits, tags `vX.Y.Z`, and pushes; the tag triggers `.github/workflows/release.yml`, which builds the DMG + `.deb`s and publishes to the apt repo. Bump `[workspace.package].version` in `Cargo.toml` to match before tagging (the diff --git a/CLAUDE.md b/CLAUDE.md index 3ea1342f..51ced112 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,12 +3,18 @@ ## Direction (read first) Roost is a cross-platform (Mac + Linux) desktop terminal multiplexer -built around libghostty-vt. The architecture is two native UIs that -each embed the workspace + PTY supervisor in-process and serve a JSON -IPC socket for external tooling (`roostctl`, Claude hooks). No daemon. +built around libghostty-vt. It ships **two platform products** — Swift ++ AppKit on macOS and Rust + iced on Linux — that each embed the +workspace + PTY supervisor in-process and serve a JSON IPC socket for +external tooling (`roostctl`, Claude hooks). No daemon. **Three UI +implementations** live in the repo: `mac/` (Swift + AppKit), +`crates/roost-iced/` (iced, what the Linux package ships), and +`crates/roost-linux/` (gtk4-rs, the in-repo Linux development/parity +implementation). * Mac UI: Swift + AppKit, `mac/` (bundle id `ai.stridelabs.Roost`). -* Linux UI: gtk4-rs + libadwaita, `crates/roost-linux/`. +* Linux UI (shipped): Rust + iced, `crates/roost-iced/` (packaged as `/usr/bin/roost`). +* Linux UI (in-repo dev/parity): gtk4-rs + libadwaita, `crates/roost-linux/`. * CLI: `crates/roost-cli/` (binary `roostctl`). * IPC + path resolution: `crates/roost-ipc/`. * libghostty-vt FFI + OSC: `crates/roost-vt/`, `crates/roost-osc/`. @@ -16,12 +22,12 @@ IPC socket for external tooling (`roostctl`, Claude hooks). No daemon. **North star.** Every surface — UI clicks, hotkeys, `roostctl`, and Lua scripts — routes through **one core: the workspace operation set**; the UI is a *reaction* to the core's events, never its own source of truth. -One contract (`roost-ipc`'s op set), two implementations (Swift + AppKit, -Rust + GTK) kept at behavioral parity. Optimize for **testability, +One contract (`roost-ipc`'s op set), three implementations (Swift + AppKit, +Rust + GTK, Rust + iced) kept at behavioral parity. Optimize for **testability, programmability, clean architecture**: adding a capability is "add an op + thin adapters", not per-surface logic. When in doubt, ask: *does this route through the one op set, keep the UI reactive, and stay at parity -across both implementations?* +across all three implementations?* See [docs/development/vision.md](docs/development/vision.md) for the full architecture, principles, and decision log; @@ -56,16 +62,18 @@ See `docs/development/vision.md` for the design rationale and ## Architecture -- Two UIs (Swift Mac, gtk4-rs Linux). Each embeds the workspace + PTY +- Three UIs (Swift Mac; gtk4-rs Linux, in-repo dev/parity; Rust + iced + Linux, what the package ships). Each embeds the workspace + PTY supervisor in-process. -- libghostty-vt is the terminal engine on both UIs — VT parsing, +- libghostty-vt is the terminal engine on all three UIs — VT parsing, screen state, OSC parsing, key/mouse encoding. -- The renderer is ours on both sides: AppKit + Core Graphics on Mac, - Cairo + Pango on `GtkDrawingArea` on Linux. We walk - libghostty-vt's render state and draw cell-aligned rects + text. +- The renderer is ours across all three: AppKit + Core Graphics on + Mac, Cairo + Pango on `GtkDrawingArea` for the GTK dev UI, and iced + + wgpu for the shipped Linux UI. We walk libghostty-vt's render state + and draw cell-aligned rects + text. - The PTY is ours — `forkpty(3)` directly on Mac (`mac/Sources/Roost/ - PtySupervisor.swift`), `portable-pty` on Linux (`crates/roost-linux/ - src/daemon/pty.rs`). One PTY per tab. + PtySupervisor.swift`), `portable-pty` on Linux (`crates/roost-engine/ + src/pty.rs`, shared by both Linux UIs). One PTY per tab. - External tools dial the running UI process at the bundle profile's socket path (`~/Library/Caches/Roost/roost.sock` for Mac, `$XDG_RUNTIME_DIR/roost/roost.sock` for Linux — fallback @@ -157,10 +165,11 @@ wrapper small. `` by default; the file appender uses `privacy: .public` to defeat that. For raw values without redaction, prefer the file log. -- **Linux UI logs**: `roost-linux` writes `$XDG_STATE_HOME/roost/roost.log` +- **Linux UI logs**: the shipped iced UI writes `$XDG_STATE_HOME/roost/roost.log` (default `~/.local/state/roost/roost.log`) **and** tees to stdout - (synchronous file appender in `crates/roost-linux/src/main.rs`, so - entries survive a crash). `tail -f` it while reproducing; set + (synchronous file appender in `crates/roost-iced/src/main.rs`, so + entries survive a crash) — the in-repo GTK UI's own log lives at the + same profile path under its `Gtk` bundle profile. `tail -f` it while reproducing; set `RUST_LOG=info,roost_ipc=debug` to adjust. On macOS the Gtk dev profile writes `~/Library/Logs/Roost-gtk/roost.log` — a **distinct** file from the Swift app's `~/Library/Logs/Roost/roost.log`, so both UIs diff --git a/Makefile b/Makefile index 50c166e3..0aaa0b75 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ # Roost — common dev tasks. Run `make` (or `make help`) to list them. # # Three native UIs around libghostty-vt: Swift + AppKit (mac/), -# Rust + gtk4-rs (crates/roost-linux), and the isolated Iced POC -# (crates/roost-iced), plus the roostctl CLI. See -# docs/development/vision.md for the architecture + north star. +# Rust + gtk4-rs (crates/roost-linux, in-repo dev/parity), and Rust + +# iced (crates/roost-iced, what the Linux package ships), plus the +# roostctl CLI. See docs/development/vision.md for the architecture + +# north star. .DEFAULT_GOAL := help diff --git a/README.md b/README.md index 9798b332..1409ffae 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@ libghostty-vt terminal per tab. The `roostctl` companion CLI surfaces notifications when an agent in a tab needs attention. -Two native UIs — **Swift + AppKit on macOS** (`Roost.app`) and **Rust + gtk4-rs +Two native UIs — **Swift + AppKit on macOS** (`Roost.app`) and **Rust + iced on Linux** (`roost`) — each embed the workspace + PTY supervisor + a JSON-IPC -server **in-process** (no daemon). External tooling (`roostctl`, Claude Code -hooks) talks to the running UI over newline-delimited JSON on a Unix-domain -socket; the wire contract is in [`docs/reference/ipc.md`](docs/reference/ipc.md). +server **in-process** (no daemon). The Linux package ships the iced UI; GTK +(`crates/roost-linux`, gtk4-rs) remains in-repo as the development/parity +implementation. External tooling (`roostctl`, Claude Code hooks) talks to +the running UI over newline-delimited JSON on a Unix-domain socket; the wire +contract is in [`docs/reference/ipc.md`](docs/reference/ipc.md). ## Install @@ -41,9 +43,12 @@ cd roost mise install # Rust (rust-toolchain.toml) + Zig 0.15.x ./third_party/ghostty/build.sh # clones Ghostty at the pinned SHA, builds libghostty-vt -# Linux UI (needs: sudo apt install libgtk-4-dev libadwaita-1-dev pkg-config): +# Linux UI — iced, what the packaged .deb ships (needs: sudo apt install libclang-dev pkg-config): +cargo build --release -p roost-iced -p roost-cli # → target/release/{roost-iced,roostctl} +./linux/scripts/build-deb.sh 0.0.1-dev # …or build an installable .deb (stages it as /usr/bin/roost) + +# Linux UI — gtk4-rs, the in-repo development/parity UI (needs: sudo apt install libgtk-4-dev libadwaita-1-dev pkg-config): cargo build --release -p roost-linux -p roost-cli # → target/release/{roost,roostctl} -./linux/scripts/build-deb.sh 0.0.1-dev # …or build an installable .deb # macOS UI (needs: brew install gtk4 libadwaita): cd mac && swift build # or: ./mac/scripts/bundle.sh release → mac/build/Roost.app diff --git a/crates/README.md b/crates/README.md index 1fe69d90..c1990bed 100644 --- a/crates/README.md +++ b/crates/README.md @@ -10,7 +10,10 @@ Rust workspace for the Roost UIs and supporting crates: - `roost-cli` — shell-integration CLI; binary is `roostctl`. - `roost-engine` — toolkit-neutral workspace, persistence, PTY runtime, events, and IPC dispatch. - `roost-ui-model` — toolkit-neutral config, themes, keybinds, palettes, providers, and projections. -- `roost-linux` — gtk4-rs + libadwaita adapter over the shared Rust engine. +- `roost-linux` — gtk4-rs + libadwaita adapter over the shared Rust engine; the in-repo + Linux development/parity implementation. +- `roost-iced` — iced adapter over the shared Rust engine; what the packaged Linux `.deb` + ships as `/usr/bin/roost`. - `roost-vt` / `roost-osc` — libghostty-vt FFI wrapper + OSC scanner. The daemon-era crates (`roost-core`, `roost-proto`, `roost-common`, diff --git a/docs/development/claude-testing.md b/docs/development/claude-testing.md index 3b4fbd9f..a03c52c2 100644 --- a/docs/development/claude-testing.md +++ b/docs/development/claude-testing.md @@ -148,7 +148,7 @@ above tests: # macOS (Swift Roost.app) tail -f ~/Library/Logs/Roost/roost.log -# Linux (gtk4-rs roost) — also tees to stdout +# Linux (iced roost, the packaged UI) — also tees to stdout tail -f "${XDG_STATE_HOME:-$HOME/.local/state}/roost/roost.log" ``` diff --git a/docs/development/setup.md b/docs/development/setup.md index 6b5ca72d..dab83d63 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -2,7 +2,7 @@ Roost has two active development surfaces: -1. The Rust workspace at `crates/` (`roost-ipc`, `roost-vt`, `roost-osc`, `roost-cli`, `roost-linux`). +1. The Rust workspace at `crates/` (`roost-ipc`, `roost-vt`, `roost-osc`, `roost-cli`, `roost-linux`, `roost-iced`). 2. The Swift package at `mac/` (the macOS UI, `Roost.app`). Both link the same vendored `libghostty-vt` static archive built from `third_party/ghostty/`. @@ -35,11 +35,13 @@ mise install # Rust + Zig | Goal | Command | |---|---| -| Run the Linux UI | `~/.cargo/bin/cargo run -p roost-linux` | +| Run the Linux UI (iced, what the package ships) | `~/.cargo/bin/cargo run -p roost-iced` | +| Run the Linux UI (GTK, in-repo dev/parity) | `~/.cargo/bin/cargo run -p roost-linux` | | Run the Mac UI | `./mac/scripts/bundle.sh debug && open mac/build/Roost.app` | | Smoke-test the CLI | `~/.cargo/bin/cargo run -p roost-cli -- identify` | | Rust unit tests | `~/.cargo/bin/cargo test --workspace --exclude roost-linux` | -| Linux UI tests | `~/.cargo/bin/cargo test -p roost-linux` (needs GTK) | +| Linux UI tests (GTK) | `~/.cargo/bin/cargo test -p roost-linux` (needs GTK) | +| Linux UI tests (iced) | `~/.cargo/bin/cargo test -p roost-iced` | | Mac unit tests | `cd mac && swift test` | | Rust formatting | `~/.cargo/bin/cargo fmt --all` | | Rust lint | `~/.cargo/bin/cargo clippy --workspace --all-targets` | @@ -47,7 +49,7 @@ mise install # Rust + Zig The IPC sockets live at: -| OS | Swift/AppKit | GTK | Iced POC | +| OS | Swift/AppKit | GTK | Iced (dev build) | |---|---|---|---| | macOS | `~/Library/Caches/Roost/roost.sock` | `~/Library/Caches/Roost-gtk/roost.sock` | `~/Library/Caches/Roost-iced/roost.sock` | | Linux | n/a | `$XDG_RUNTIME_DIR/roost/roost.sock` | `$XDG_RUNTIME_DIR/roost-iced/roost.sock` | @@ -73,7 +75,8 @@ Rust tests live next to the code they exercise. Major coverage: | `roost-vt` | FFI smoke tests against the vendored `libghostty-vt` archive (gated on `--features ffi`) | | `roost-engine` | Workspace, PTY supervision, persistence, events, IPC dispatch, instance lock | | `roost-ui-model` | Config, theme, keybind, palette, provider, and agent projection models | -| `roost-linux` | GTK presentation, native ports, input, and terminal rendering adapter | +| `roost-linux` | GTK presentation, native ports, input, and terminal rendering adapter — in-repo dev/parity UI | +| `roost-iced` | iced presentation, native ports, input, and terminal rendering adapter — what the Linux package ships | | `roost-cli` | Escape decoder, shell quoter, target arg mapping | Mac tests are under `mac/Tests/RoostTests/`; they cover the workspace state machine, PTY supervisor lifecycle, IPC server framing, single-instance flock, renderer, OSC scanner, key encoder, drag/drop math, and tab pill state machine. They run in headless `swift test` (no NSWindow required for any covered surface). diff --git a/docs/development/shared-rust-engine.md b/docs/development/shared-rust-engine.md index 34d976c4..76b9f7a4 100644 --- a/docs/development/shared-rust-engine.md +++ b/docs/development/shared-rust-engine.md @@ -1,7 +1,8 @@ # Shared Rust engine `roost-engine` is Roost's toolkit-neutral authoritative Rust application -engine. GTK consumes it today; the Iced POC consumes it directly. A future +engine. Both Linux UIs consume it directly — the shipped iced UI and the +in-repo GTK development/parity UI. A future Swift adapter can adopt the same boundary incrementally without exposing Rust layouts across an ABI. diff --git a/docs/development/vision.md b/docs/development/vision.md index 1899f00d..6a21a612 100644 --- a/docs/development/vision.md +++ b/docs/development/vision.md @@ -8,11 +8,13 @@ the north star every PR is measured against. A single-window, cross-platform terminal multiplexer: a sidebar of projects, tabs per project, one terminal per tab. The differentiator is the multi-project workspace with **notification routing for AI coding -agents** (Claude Code, Codex, …). It ships as **two native UIs that each -embed the workspace + PTY supervisor in-process** — Swift + AppKit on -macOS (`Roost.app`), Rust + gtk4-rs on Linux (`roost-linux`). -`libghostty-vt` is vendored once and linked into both for in-process VT -parsing and rendering. There is no daemon. +agents** (Claude Code, Codex, …). It ships **two platform products, each +embedding the workspace + PTY supervisor in-process** — Swift + AppKit on +macOS (`Roost.app`), Rust + iced on Linux (`roost`, the packaged `.deb`). +A third UI implementation, Rust + gtk4-rs (`roost-linux`), lives in the +repo as the Linux development/parity implementation. `libghostty-vt` is +vendored once and linked into all three for in-process VT parsing and +rendering. There is no daemon. ## The command core (north star) @@ -39,15 +41,17 @@ truth.** - A hotkey (`Cmd+Shift+T`), a `roostctl` call, and a Lua script all invoke the **same** command — e.g. "run action" or "open tab". -**One contract, two implementations.** There is no shared *codebase* -core — Swift and Rust can't share one. There is one shared **contract** -— the IPC op set in [`crates/roost-ipc`](../reference/ipc.md) — -implemented by **Swift `Workspace` + AppKit** and **Rust `Workspace` + -GTK**. "Same interface" means same op contract + behavioral parity, -which the cross-platform E2E suite ([test-automation.md](test-automation.md)) -exists to enforce. Per platform: identical command surface, -platform-specific guts (`forkpty` vs `portable-pty`, Core Graphics vs -Cairo). +**One contract, three implementations.** There is no shared *codebase* +core across languages — Swift and Rust can't share one. There is one +shared **contract** — the IPC op set in +[`crates/roost-ipc`](../reference/ipc.md) — implemented by **Swift +`Workspace` + AppKit**, **Rust + GTK**, and **Rust + iced** (the two +Rust UIs additionally share `roost-engine`). "Same interface" means same +op contract + behavioral parity, which the cross-platform E2E suite +([test-automation.md](test-automation.md)) exists to enforce. Per +implementation: identical command surface, platform-specific guts +(`forkpty` vs `portable-pty`; Core Graphics vs Cairo + Pango vs +iced + wgpu). **Two seams** connect the surfaces to the core: @@ -75,7 +79,7 @@ for at once: Every decision below — and every new feature — is measured against it: *does it route through the one op set, keep the UI reactive, and stay at -parity across both implementations?* +parity across all three implementations?* ## Why this shape @@ -131,8 +135,8 @@ flowchart LR macWS --- macIPC end - subgraph LinuxApp["roost-linux (Rust + gtk4-rs)"] - linuxView["Cell renderer
(Cairo + Pango)"] + subgraph LinuxApp["roost (Rust + iced) — packaged Linux UI"] + linuxView["Cell renderer
(iced + wgpu)"] linuxVT["libghostty-vt
(in-process VT parse)"] linuxWS["Workspace + PtySupervisor
(tokio + portable-pty)"] linuxIPC["JSON IPC server
(tokio UnixListener)"] diff --git a/docs/index.md b/docs/index.md index aa9f1b0f..0360c3f2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ Roost is a desktop terminal multiplexer for AI coding agents. It runs on macOS and Linux, presents a sidebar of projects with tabs inside each project, and notifies you when an agent in a tab needs your attention. -The terminal engine is [libghostty-vt](https://ghostty.org), the parser/screen-state library extracted from Ghostty. Roost ships two native UIs — Swift + AppKit on macOS (`Roost.app`) and Rust + gtk4-rs on Linux (`roost`). There is no daemon: each UI embeds the workspace, the PTY supervisor, and a JSON IPC server in-process. External tooling (`roostctl`, Claude Code hooks) reaches the running UI over a Unix domain socket speaking newline-delimited JSON; see [IPC](reference/ipc.md) for the wire format. Persistence is a small `state.json` written atomically. +The terminal engine is [libghostty-vt](https://ghostty.org), the parser/screen-state library extracted from Ghostty. Roost ships two native UIs — Swift + AppKit on macOS (`Roost.app`) and Rust + iced on Linux (`roost`). GTK (`crates/roost-linux`, gtk4-rs) remains in the repo as the Linux development/parity implementation, but the packaged `.deb` ships iced. There is no daemon: each UI embeds the workspace, the PTY supervisor, and a JSON IPC server in-process. External tooling (`roostctl`, Claude Code hooks) reaches the running UI over a Unix domain socket speaking newline-delimited JSON; see [IPC](reference/ipc.md) for the wire format. Persistence is a small `state.json` written atomically. ## Install diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 515ea48f..8e752f2d 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -1,30 +1,35 @@ # Architecture -Roost ships two production native UIs — Swift + AppKit on macOS (`Roost.app`) and Rust + gtk4-rs on Linux (`roost-linux`) — that each embed their runtime in-process. The GTK runtime's authoritative state is provided by the toolkit-neutral `roost-engine` crate. External tooling (the `roostctl` CLI, Claude Code hooks) talks to a running UI via newline-delimited JSON over a Unix-domain socket; the wire format is documented in [`docs/reference/ipc.md`](ipc.md). `libghostty-vt` is vendored once and linked directly into both UIs for in-process VT parsing and rendering. +Roost ships two platform products — Swift + AppKit on macOS (`Roost.app`) and Rust + iced on Linux (`roost`, packaged as the `.deb`) — that each embed their runtime in-process. A third UI implementation, Rust + gtk4-rs (`crates/roost-linux`), lives in the repo as the Linux development/parity implementation; both Linux UIs share their authoritative state via the toolkit-neutral `roost-engine` crate. External tooling (the `roostctl` CLI, Claude Code hooks) talks to a running UI via newline-delimited JSON over a Unix-domain socket; the wire format is documented in [`docs/reference/ipc.md`](ipc.md). `libghostty-vt` is vendored once and linked directly into all three UIs for in-process VT parsing and rendering. -The `poc/iced` branch proposes extending the shared Rust engine to Iced and, incrementally, Swift. This is a POC architecture proposal, not approval to migrate the production AppKit implementation; see [the reviewed Iced POC plan](../development/iced-poc-plan.md). +iced (`crates/roost-iced`) has shipped as the Linux package's production UI since the `poc/iced` branch merged to `main`; the production Swift + AppKit implementation on macOS is unaffected by that work — see [the Iced POC plan](../development/iced-poc-plan.md) for the design record that led here. For the durable design rationale (why two languages, why in-process, why local UDS) see [Vision](../development/vision.md). ## Stack -| Layer | macOS | Linux | -|---|---|---| -| Window + chrome | Swift + AppKit | Rust + gtk4-rs + libadwaita | -| Renderer | Core Graphics over libghostty-vt cell grid | Cairo + Pango over libghostty-vt cell grid | -| Terminal engine | `libghostty-vt` (vendored, shared archive) | `libghostty-vt` (vendored, shared archive) | -| Workspace | `mac/Sources/Roost/Workspace.swift` (`@MainActor`) | `crates/roost-engine/src/workspace.rs` | -| PTY supervisor | `mac/Sources/Roost/PtySupervisor.swift` (forkpty + DispatchSourceRead) | `crates/roost-linux/src/daemon/pty.rs` (`portable-pty` + tokio tasks) | -| Persistence | `state.json` via tmp + fsync + `replaceItemAt` | `state.json` via tmp + fsync + rename + parent-dir fsync | -| IPC server | `mac/Sources/Roost/IPCServer.swift` (Darwin sockets) | `crates/roost-ipc/src/server.rs` (tokio `UnixListener`) | -| IPC wire types | `mac/Sources/Roost/IPCMessages.swift` (Codable) | `crates/roost-ipc/src/messages.rs` (serde) | -| OSC scanning | `mac/Sources/Roost/OscScanner.swift` per `TerminalView` | `roost-osc` crate per per-tab drain task | -| Agent state model (shell/lifecycle/attention/ownership axes, `tab.state` derivation) | `mac/Sources/Roost/AgentState.swift` | `crates/roost-ipc/src/agent.rs` | -| Agent adapters (Claude Code today) | `roostctl claude-hook` (binary from `crates/roost-cli`, links `crates/roost-agent`) — same binary on both platforms | (same) | -| Single-instance | `mac/Sources/Roost/SingleInstance.swift` (flock via `@_silgen_name`) | `crates/roost-linux/src/single_instance.rs` (`fs2::FileExt::try_lock_exclusive`) | -| Shell-integration CLI | `roostctl` (binary from `crates/roost-cli`) — same binary on both platforms | (same) | - -The UIs are written separately and idiomatic to their platform; only the JSON IPC wire format is shared between them (via the `roost-ipc` crate on the Rust side + its hand-mirrored Swift counterpart in `IPCMessages.swift`). +Linux splits into two columns because the two Linux UIs share almost +everything below the window/renderer layer — both adapt the same +toolkit-neutral `roost-engine` — but differ in windowing toolkit and +renderer, and only one of them ships. + +| Layer | macOS | Linux — GTK (`crates/roost-linux`, dev/parity) | Linux — iced (`crates/roost-iced`, shipped) | +|---|---|---|---| +| Window + chrome | Swift + AppKit | Rust + gtk4-rs + libadwaita (`crates/roost-linux/src/app.rs`) | Rust + iced (`crates/roost-iced/src/app.rs`) | +| Renderer | Core Graphics over libghostty-vt cell grid | Cairo + Pango over libghostty-vt cell grid (`crates/roost-linux/src/terminal_view.rs`) | iced + wgpu over libghostty-vt cell grid (`crates/roost-iced/src/terminal_widget.rs`) | +| Terminal engine | `libghostty-vt` (vendored, shared archive) | `libghostty-vt` (vendored, shared archive) | `libghostty-vt` (vendored, shared archive) | +| Workspace | `mac/Sources/Roost/Workspace.swift` (`@MainActor`) | `crates/roost-engine/src/workspace.rs` (shared) | `crates/roost-engine/src/workspace.rs` (shared) | +| PTY supervisor | `mac/Sources/Roost/PtySupervisor.swift` (forkpty + DispatchSourceRead) | `crates/roost-engine/src/pty.rs` (`portable-pty` + tokio tasks, shared) | `crates/roost-engine/src/pty.rs` (shared) | +| Persistence | `state.json` via tmp + fsync + `replaceItemAt` | `state.json` via tmp + fsync + rename + parent-dir fsync (`crates/roost-engine/src/persistence.rs`, shared) | `crates/roost-engine/src/persistence.rs` (shared) | +| IPC server | `mac/Sources/Roost/IPCServer.swift` (Darwin sockets) | `crates/roost-ipc/src/server.rs` (tokio `UnixListener`, shared) | `crates/roost-ipc/src/server.rs` (shared) | +| IPC wire types | `mac/Sources/Roost/IPCMessages.swift` (Codable) | `crates/roost-ipc/src/messages.rs` (serde, shared) | `crates/roost-ipc/src/messages.rs` (shared) | +| OSC scanning | `mac/Sources/Roost/OscScanner.swift` per `TerminalView` | `roost-osc` crate + `crates/roost-engine/src/osc.rs` (`OscRouter`, shared) per per-tab drain task | same (shared) | +| Agent state model (shell/lifecycle/attention/ownership axes, `tab.state` derivation) | `mac/Sources/Roost/AgentState.swift` | `crates/roost-ipc/src/agent.rs` (shared) | `crates/roost-ipc/src/agent.rs` (shared) | +| Agent adapters (Claude Code today) | `roostctl claude-hook` (binary from `crates/roost-cli`, links `crates/roost-agent`) — same binary on all three UIs | (same) | (same) | +| Single-instance | `mac/Sources/Roost/SingleInstance.swift` (flock via `@_silgen_name`) | `crates/roost-engine/src/single_instance.rs` (`fs2::FileExt::try_lock_exclusive`, shared) | `crates/roost-engine/src/single_instance.rs` (shared) | +| Shell-integration CLI | `roostctl` (binary from `crates/roost-cli`) — same binary on all three UIs | (same) | (same) | + +The UIs are written separately and idiomatic to their platform; the JSON IPC wire format is shared across all three (via the `roost-ipc` crate on the Rust side + its hand-mirrored Swift counterpart in `IPCMessages.swift`), and the two Linux UIs additionally share `roost-engine` (workspace, PTY, persistence, events, single-instance) and `roost-ui-model` (config, theme, keybinds, palette, providers, agent/notification projections) — see [Shared Rust engine](../development/shared-rust-engine.md). ## Repository layout @@ -33,10 +38,13 @@ crates/ roost-ipc/ # JSON wire format, framing, client, server, paths, target picker roost-agent/ # Pure agent adapters (Claude Code today) — hook event JSON in, # tab.agent_report params out; no I/O, no socket, no clap + roost-engine/ # Toolkit-neutral workspace, PTY, persistence, events, IPC dispatch — shared by both Linux UIs + roost-ui-model/ # Toolkit-neutral config, theme, keybind, palette, provider, agent/notification projections roost-vt/ # libghostty-vt FFI wrapper (--features ffi) roost-osc/ # OSC scanner + state machine roost-cli/ # roostctl binary - roost-linux/ # Linux UI (gtk4-rs) — embeds Workspace + PtySupervisor + IPC server + roost-linux/ # Linux UI (gtk4-rs) — in-repo development/parity implementation + roost-iced/ # Linux UI (iced) — what the packaged .deb ships as /usr/bin/roost mac/ Sources/Roost/ # Swift Mac UI — embeds Workspace + PtySupervisor + IPC server Resources/ # themes, Info.plist.template, Roost.entitlements @@ -105,7 +113,7 @@ The Mac PTY read path uses a dedicated pattern: the `DispatchSourceRead` closure - `libghostty-vt` lives inside each UI for VT parsing + rendering. - OSC scanning lives in the UI (`OscScanner.swift` on macOS, `roost-osc` crate on Linux) because OSC parsing walks the same byte stream the VT parser does. OSC events apply directly to the local workspace via `LocalClient.applyOSC`. - Terminal *query* replies (the program asking the terminal for its colors, device attributes, etc.) split across two channels — embedder-synthesized OSC color replies vs. libghostty-answered device replies. See [Terminal query replies](terminal-queries.md) for which is which and why. -- The IPC server is per-UI: external tooling (`roostctl`, Claude hooks) talks to the bundle profile's socket. The Iced POC adds isolated `Roost-iced`/`roost-iced` paths alongside the existing Mac and GTK paths. `roostctl --target {mac,gtk,iced}` routes explicitly; with no selector it probes every distinct candidate concurrently and requires a choice if multiple UIs answer. +- The IPC server is per-UI: external tooling (`roostctl`, Claude hooks) talks to the bundle profile's socket. Dev builds of iced use isolated `Roost-iced`/`roost-iced` paths alongside the existing Mac and GTK paths; the packaged `.deb` build adopts the production `roost`/`Roost` profile instead (see [Paths & Environment](paths.md)). `roostctl --target {mac,gtk,iced}` routes explicitly; with no selector it probes every distinct candidate concurrently and requires a choice if multiple UIs answer. - Single-instance enforcement uses `flock(LOCK_EX | LOCK_NB)` on a pidfile next to the socket. Second launches read the holder PID and exit 0. `ROOST_ALLOW_MULTI=1` bypasses for dev/test workflows. See [Vision → Decision log](../development/vision.md#decision-log) for the rationale behind each major choice. diff --git a/docs/reference/config.md b/docs/reference/config.md index 3b681979..67ade9f8 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -2,9 +2,13 @@ Roost reads a single user-level config file at `~/.config/roost/config.conf` (XDG-style on macOS by deliberate divergence from Apple HIG — matching -Ghostty / nvim / fish). Both UIs (Swift Mac app, Linux gtk4-rs binary) -parse the same file with the same semantics, so a config tuned on one -platform is portable to the other. +Ghostty / nvim / fish). All three UIs (Swift Mac app, Linux gtk4-rs +binary, Linux iced binary) parse the same file with the same +semantics, so a config tuned on one platform is portable to the +others. A few settings are honored by only some UIs — where that is the +case, the setting's own section says so explicitly (see +[`link-modifier`](#link-modifier)); unknown and unsupported keys are +dropped rather than erroring, so a portable file stays valid everywhere. The file is plain text, one `key = value` per line, `#`-prefixed comments allowed, whitespace forgiving. Unknown keys are silently dropped — this @@ -161,9 +165,11 @@ common-terminal convention) just set: link-modifier = ctrl ``` -> **Scope:** this setting is honored by the **GTK app only**. The Swift -> Mac app's modifier is currently fixed to Cmd, so the key is silently -> ignored there (harmless — unknown keys are always dropped). +> **Scope:** this setting is honored by **both Linux UIs** — GTK and the +> shipped iced app (`link_modifier_held` in +> `crates/roost-iced/src/app.rs`). The Swift Mac app's modifier is +> currently fixed to Cmd, so the key is silently ignored there (harmless +> — unknown keys are always dropped). > > **Heads up (Linux):** some window managers/compositors grab `Alt`+drag > to move windows, which can swallow `Alt`+click. If link-clicking feels diff --git a/docs/reference/ipc.md b/docs/reference/ipc.md index 09cd6243..907eca34 100644 --- a/docs/reference/ipc.md +++ b/docs/reference/ipc.md @@ -13,7 +13,7 @@ The socket path is the bundle profile's `socket_path` (see * Mac (Swift `Roost.app`): `~/Library/Caches/Roost/roost.sock` * GTK dev mode on Mac: `~/Library/Caches/Roost-gtk/roost.sock` -* Iced POC on Mac: `~/Library/Caches/Roost-iced/roost.sock` +* Iced dev build on Mac: `~/Library/Caches/Roost-iced/roost.sock` * GTK on Linux (XDG): `$XDG_RUNTIME_DIR/roost/roost.sock` * Iced on Linux (XDG): `$XDG_RUNTIME_DIR/roost-iced/roost.sock` * Linux fallback: `/tmp/roost[-iced]-/roost.sock` diff --git a/docs/reference/paths.md b/docs/reference/paths.md index 5712f69e..18b67058 100644 --- a/docs/reference/paths.md +++ b/docs/reference/paths.md @@ -46,7 +46,7 @@ Same shape as the `Mac` profile with `Roost-gtk` in place of `Roost`: ### macOS — `Iced` profile (`cargo run -p roost-iced`) -The Iced POC uses the same shape with `Roost-iced`, so all three UIs can run +The Iced dev build uses the same shape with `Roost-iced`, so all three UIs can run at once: | Path | Purpose | @@ -227,13 +227,13 @@ rm "$HOME/Library/Application Support/Roost/state.json" # macOS — Gtk dev profile (cargo run -p roost-linux on Mac) rm "$HOME/Library/Application Support/Roost-gtk/state.json" -# macOS — Iced POC +# macOS — Iced dev build rm "$HOME/Library/Application Support/Roost-iced/state.json" # Linux (uses XDG_DATA_HOME with the spec-default fallback) rm "${XDG_DATA_HOME:-$HOME/.local/share}/roost/state.json" -# Linux — Iced POC +# Linux — Iced dev build rm "${XDG_DATA_HOME:-$HOME/.local/share}/roost-iced/state.json" ``` diff --git a/tools/roosttest_unit/test_socket_paths.py b/tools/roosttest_unit/test_socket_paths.py new file mode 100644 index 00000000..6d393980 --- /dev/null +++ b/tools/roosttest_unit/test_socket_paths.py @@ -0,0 +1,122 @@ +"""Regression coverage for tools/screenshot/lib.sh's ut_socket_for(). + +`ut_socket_for` is a bash reimplementation of roost-ipc's BundleProfile +socket resolver (crates/roost-ipc/src/paths.rs — the non-macOS +`resolve_paths` + `xdg_runtime_dir`) for the screenshot/e2e test harness. +It must stay byte-identical to that resolver or tooling silently dials +the wrong socket. + +Runs the real `ut_socket_for` bash function in a subprocess (not a +Python port of its logic) with a stub `uname` prepended onto PATH, so +the non-Darwin code path is exercised deterministically on every host +running this suite — including a developer's Mac. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +LIB_SH = Path(__file__).resolve().parents[1] / "screenshot" / "lib.sh" + +UID = os.getuid() + + +def _write_fake_uname(bin_dir: Path, kernel_name: str) -> None: + """Write a stub `uname` that always reports `kernel_name`. + + lib.sh's gtk/iced arms branch on `uname -s`. Faking it forces the + non-Darwin branch deterministically regardless of the host OS, + instead of relying on (or re-deriving) the runner's real platform. + """ + script = bin_dir / "uname" + script.write_text(f'#!/bin/sh\necho "{kernel_name}"\n') + script.chmod(script.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + +def _ut_socket_for(target: str, *, xdg_runtime_dir: "str | None") -> str: + """Source lib.sh in bash and return `ut_socket_for(target)`'s stdout.""" + with tempfile.TemporaryDirectory() as fake_bin: + fake_bin_path = Path(fake_bin) + _write_fake_uname(fake_bin_path, "Linux") + + env = dict(os.environ) + env["PATH"] = f"{fake_bin_path}:{env.get('PATH', '')}" + if xdg_runtime_dir is None: + env.pop("XDG_RUNTIME_DIR", None) + else: + env["XDG_RUNTIME_DIR"] = xdg_runtime_dir + + result = subprocess.run( + ["bash", "-c", 'set -euo pipefail; . "$1"; ut_socket_for "$2"', "_", str(LIB_SH), target], + env=env, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"ut_socket_for {target} failed (rc={result.returncode}): {result.stderr}") + return result.stdout.strip() + + +class UtSocketForGtkTests(unittest.TestCase): + """Parity with roost-ipc's non-macOS resolve_paths() for BundleProfileKind::Gtk.""" + + def test_absolute_xdg_runtime_dir_is_used(self) -> None: + self.assertEqual( + _ut_socket_for("gtk", xdg_runtime_dir="/run/user/1000"), + "/run/user/1000/roost/roost.sock", + ) + + def test_unset_xdg_runtime_dir_falls_back(self) -> None: + self.assertEqual( + _ut_socket_for("gtk", xdg_runtime_dir=None), + f"/tmp/roost-{UID}/roost.sock", + ) + + def test_empty_xdg_runtime_dir_falls_back(self) -> None: + self.assertEqual( + _ut_socket_for("gtk", xdg_runtime_dir=""), + f"/tmp/roost-{UID}/roost.sock", + ) + + def test_relative_xdg_runtime_dir_falls_back(self) -> None: + self.assertEqual( + _ut_socket_for("gtk", xdg_runtime_dir="relative/path"), + f"/tmp/roost-{UID}/roost.sock", + ) + + +class UtSocketForIcedTests(unittest.TestCase): + """Same XDG_RUNTIME_DIR parity, for BundleProfileKind::Iced's isolated namespace.""" + + def test_absolute_xdg_runtime_dir_is_used(self) -> None: + self.assertEqual( + _ut_socket_for("iced", xdg_runtime_dir="/run/user/1000"), + "/run/user/1000/roost-iced/roost.sock", + ) + + def test_unset_xdg_runtime_dir_falls_back(self) -> None: + self.assertEqual( + _ut_socket_for("iced", xdg_runtime_dir=None), + f"/tmp/roost-iced-{UID}/roost.sock", + ) + + def test_empty_xdg_runtime_dir_falls_back(self) -> None: + self.assertEqual( + _ut_socket_for("iced", xdg_runtime_dir=""), + f"/tmp/roost-iced-{UID}/roost.sock", + ) + + def test_relative_xdg_runtime_dir_falls_back(self) -> None: + self.assertEqual( + _ut_socket_for("iced", xdg_runtime_dir="relative/path"), + f"/tmp/roost-iced-{UID}/roost.sock", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/screenshot/lib.sh b/tools/screenshot/lib.sh index d556e307..fe404a11 100644 --- a/tools/screenshot/lib.sh +++ b/tools/screenshot/lib.sh @@ -32,15 +32,19 @@ ut_resolve_roostctl() { # Per-target socket path (matches roost-ipc's BundleProfile resolver). # macOS uses three `~/Library/Caches/Roost*` namespaces. Linux keeps the -# production GTK socket under `roost/` and the POC under `roost-iced/`. +# production `roost/` namespace — owned by the gtk profile, which the +# packaged iced UI adopts — and the isolated iced dev profile on +# `roost-iced/`. ut_socket_for() { case "$1" in mac) echo "${HOME}/Library/Caches/Roost/roost.sock" ;; gtk) if [[ "$(uname -s)" == "Darwin" ]]; then echo "${HOME}/Library/Caches/Roost-gtk/roost.sock" + elif [[ -n "${XDG_RUNTIME_DIR:-}" && "${XDG_RUNTIME_DIR}" == /* ]]; then + echo "${XDG_RUNTIME_DIR}/roost/roost.sock" else - echo "${XDG_RUNTIME_DIR:-/tmp/roost-$(id -u)}/roost/roost.sock" + echo "/tmp/roost-$(id -u)/roost.sock" fi ;; iced)