83f1c1f57d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3085 lines
100 KiB
Markdown
3085 lines
100 KiB
Markdown
# spacesh M0+M1 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build the M0 vertical slice (bytes flying GUI↔daemon↔PTY over a Unix socket) plus M1 persistence (daemon outlives GUI; reattach repaints the screen from a daemon-side grid snapshot).
|
||
|
||
**Architecture:** A Rust Cargo workspace with four crates. `spacesh-proto` defines the wire protocol (length-prefixed JSON). `spacesh-pty` spawns and reads PTYs with output batching. `spacesh-core` keeps an authoritative terminal grid (`alacritty_terminal`) and serializes it to an ANSI snapshot. `spaceshd` is the daemon: a Unix-domain-socket server with one actor task per surface that owns the PTY, the grid, and a broadcast fan-out. A Tauri 2 app is the thin GUI: `src-tauri` bridges the socket into the webview (commands via `invoke`, the output stream via `ipc::Channel`, rare events via `emit`); the React front renders one `xterm.js` panel at a time and switches between surfaces.
|
||
|
||
**Tech Stack:** Rust (tokio, tokio-util codec, serde/serde_json, bytes, base64, anyhow, thiserror), `portable-pty` 0.8, `alacritty_terminal` 0.25, Tauri 2, React + TypeScript + Vite, `@xterm/xterm` with `@xterm/addon-webgl`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md`. Base spec: `DOCS/MAIN.md`.
|
||
|
||
**Conventions:** English for all code/comments. camelCase vars/functions, PascalCase types, snake_case files, UPPER_CASE env vars. No hard-coded secrets. Socket path `~/.spacesh/sock`, lock `~/.spacesh/daemon.lock`.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
spacesh/
|
||
├── Cargo.toml # [virtual workspace manifest]
|
||
├── crates/
|
||
│ ├── spacesh-proto/
|
||
│ │ ├── Cargo.toml
|
||
│ │ └── src/
|
||
│ │ ├── lib.rs # re-exports
|
||
│ │ ├── ids.rs # SurfaceId, WorkspaceId newtypes
|
||
│ │ ├── message.rs # Envelope, Req, Res, Evt, Cmd, ErrorBody
|
||
│ │ └── codec.rs # length-prefix framing helpers (read_frame/write_frame)
|
||
│ ├── spacesh-pty/
|
||
│ │ ├── Cargo.toml
|
||
│ │ └── src/
|
||
│ │ └── lib.rs # PtyHandle: spawn, output rx (batched), input, resize, kill
|
||
│ ├── spacesh-core/
|
||
│ │ ├── Cargo.toml
|
||
│ │ └── src/
|
||
│ │ ├── lib.rs # re-exports
|
||
│ │ ├── grid.rs # GridSurface: feed bytes into alacritty Term
|
||
│ │ └── snapshot.rs # snapshot_ansi(&Term) -> Snapshot { ansi, cols, rows, cursor }
|
||
│ └── spaceshd/
|
||
│ ├── Cargo.toml
|
||
│ └── src/
|
||
│ ├── main.rs # entrypoint: parse argv, run daemon or `install-agent`
|
||
│ ├── lifecycle.rs # socket path, lock file, single-instance, lazy-start helper
|
||
│ ├── registry.rs # Registry: workspaces + surface senders
|
||
│ ├── surface.rs # surface actor (owns PTY, grid, broadcast)
|
||
│ ├── server.rs # accept loop + client task + command dispatch
|
||
│ └── launchd.rs # plist template + install
|
||
└── app/
|
||
├── package.json
|
||
├── vite.config.ts
|
||
├── index.html
|
||
├── src/
|
||
│ ├── main.tsx
|
||
│ ├── App.tsx # holds surface list + active surface
|
||
│ ├── socketBridge.ts # invoke wrappers + Channel/event subscriptions
|
||
│ ├── TerminalView.tsx # xterm.js instance bound to one surface
|
||
│ └── SurfaceList.tsx # switch active surface
|
||
└── src-tauri/
|
||
├── Cargo.toml
|
||
├── tauri.conf.json
|
||
├── build.rs
|
||
└── src/
|
||
├── main.rs
|
||
├── lib.rs # builder, manage(BridgeState), invoke_handler
|
||
└── bridge.rs # UDS connection, req/res correlation, output channels, evt emit
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 0 — Workspace scaffold
|
||
|
||
### Task 0: Create the Cargo workspace and crate skeletons
|
||
|
||
**Files:**
|
||
- Create: `Cargo.toml`
|
||
- Create: `crates/spacesh-proto/Cargo.toml`, `crates/spacesh-proto/src/lib.rs`
|
||
- Create: `crates/spacesh-pty/Cargo.toml`, `crates/spacesh-pty/src/lib.rs`
|
||
- Create: `crates/spacesh-core/Cargo.toml`, `crates/spacesh-core/src/lib.rs`
|
||
- Create: `crates/spaceshd/Cargo.toml`, `crates/spaceshd/src/main.rs`
|
||
|
||
- [ ] **Step 1: Create the workspace manifest**
|
||
|
||
`Cargo.toml`:
|
||
```toml
|
||
[workspace]
|
||
resolver = "2"
|
||
members = [
|
||
"crates/spacesh-proto",
|
||
"crates/spacesh-pty",
|
||
"crates/spacesh-core",
|
||
"crates/spaceshd",
|
||
]
|
||
|
||
[workspace.package]
|
||
edition = "2021"
|
||
version = "0.1.0"
|
||
|
||
[workspace.dependencies]
|
||
tokio = { version = "1", features = ["full"] }
|
||
tokio-util = { version = "0.7", features = ["codec"] }
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
bytes = "1"
|
||
base64 = "0.22"
|
||
anyhow = "1"
|
||
thiserror = "1"
|
||
futures = "0.3"
|
||
portable-pty = "0.8"
|
||
alacritty_terminal = "0.25"
|
||
fs2 = "0.4"
|
||
dirs = "5"
|
||
```
|
||
|
||
- [ ] **Step 2: Create `spacesh-proto` skeleton**
|
||
|
||
`crates/spacesh-proto/Cargo.toml`:
|
||
```toml
|
||
[package]
|
||
name = "spacesh-proto"
|
||
edition.workspace = true
|
||
version.workspace = true
|
||
|
||
[dependencies]
|
||
serde.workspace = true
|
||
serde_json.workspace = true
|
||
bytes.workspace = true
|
||
thiserror.workspace = true
|
||
tokio = { workspace = true }
|
||
tokio-util.workspace = true
|
||
```
|
||
|
||
`crates/spacesh-proto/src/lib.rs`:
|
||
```rust
|
||
pub mod codec;
|
||
pub mod ids;
|
||
pub mod message;
|
||
|
||
pub use ids::{SurfaceId, WorkspaceId};
|
||
pub use message::{Cmd, Envelope, ErrorBody, Evt};
|
||
```
|
||
|
||
- [ ] **Step 3: Create `spacesh-pty` skeleton**
|
||
|
||
`crates/spacesh-pty/Cargo.toml`:
|
||
```toml
|
||
[package]
|
||
name = "spacesh-pty"
|
||
edition.workspace = true
|
||
version.workspace = true
|
||
|
||
[dependencies]
|
||
portable-pty.workspace = true
|
||
tokio.workspace = true
|
||
bytes.workspace = true
|
||
anyhow.workspace = true
|
||
```
|
||
|
||
`crates/spacesh-pty/src/lib.rs`:
|
||
```rust
|
||
// PtyHandle implemented in Task 3.
|
||
```
|
||
|
||
- [ ] **Step 4: Create `spacesh-core` skeleton**
|
||
|
||
`crates/spacesh-core/Cargo.toml`:
|
||
```toml
|
||
[package]
|
||
name = "spacesh-core"
|
||
edition.workspace = true
|
||
version.workspace = true
|
||
|
||
[dependencies]
|
||
alacritty_terminal.workspace = true
|
||
serde.workspace = true
|
||
```
|
||
|
||
`crates/spacesh-core/src/lib.rs`:
|
||
```rust
|
||
pub mod grid;
|
||
pub mod snapshot;
|
||
|
||
pub use grid::GridSurface;
|
||
pub use snapshot::Snapshot;
|
||
```
|
||
|
||
(Leave `grid.rs` / `snapshot.rs` to Tasks 11–12; create them empty now so it compiles after those tasks. For Task 0, comment the `mod` lines out and restore them in Task 11.)
|
||
|
||
`crates/spacesh-core/src/lib.rs` (Task 0 version):
|
||
```rust
|
||
// modules added in Tasks 11-12
|
||
```
|
||
|
||
- [ ] **Step 5: Create `spaceshd` skeleton**
|
||
|
||
`crates/spaceshd/Cargo.toml`:
|
||
```toml
|
||
[package]
|
||
name = "spaceshd"
|
||
edition.workspace = true
|
||
version.workspace = true
|
||
|
||
[[bin]]
|
||
name = "spaceshd"
|
||
path = "src/main.rs"
|
||
|
||
[dependencies]
|
||
spacesh-proto = { path = "../spacesh-proto" }
|
||
spacesh-pty = { path = "../spacesh-pty" }
|
||
spacesh-core = { path = "../spacesh-core" }
|
||
tokio.workspace = true
|
||
tokio-util.workspace = true
|
||
serde.workspace = true
|
||
serde_json.workspace = true
|
||
bytes.workspace = true
|
||
base64.workspace = true
|
||
anyhow.workspace = true
|
||
thiserror.workspace = true
|
||
futures.workspace = true
|
||
fs2.workspace = true
|
||
dirs.workspace = true
|
||
```
|
||
|
||
`crates/spaceshd/src/main.rs`:
|
||
```rust
|
||
fn main() {
|
||
println!("spaceshd skeleton");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify the workspace builds**
|
||
|
||
Run: `cargo build`
|
||
Expected: PASS (compiles all four crates; `spaceshd` prints nothing yet but builds).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add Cargo.toml crates/
|
||
git commit -m "chore: scaffold cargo workspace and crate skeletons"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 1 — spacesh-proto
|
||
|
||
### Task 1: Id newtypes and message types
|
||
|
||
**Files:**
|
||
- Create: `crates/spacesh-proto/src/ids.rs`
|
||
- Create: `crates/spacesh-proto/src/message.rs`
|
||
- Test: inline `#[cfg(test)]` in `message.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `crates/spacesh-proto/src/message.rs`:
|
||
```rust
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::ids::{SurfaceId, WorkspaceId};
|
||
|
||
#[test]
|
||
fn req_round_trips_through_json() {
|
||
let env = Envelope::Req {
|
||
id: 42,
|
||
cmd: Cmd::Focus { surface_id: SurfaceId("s_8f3".into()) },
|
||
};
|
||
let json = serde_json::to_string(&env).unwrap();
|
||
let back: Envelope = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(env, back);
|
||
}
|
||
|
||
#[test]
|
||
fn res_ok_and_err_serialize_distinctly() {
|
||
let ok = Envelope::Res { id: 1, ok: true, data: serde_json::json!({"workspace_id":"w_1"}), error: None };
|
||
let err = Envelope::Res { id: 2, ok: false, data: serde_json::Value::Null,
|
||
error: Some(ErrorBody { code: "NOT_FOUND".into(), msg: "no surface".into() }) };
|
||
assert!(serde_json::to_string(&ok).unwrap().contains("\"ok\":true"));
|
||
assert!(serde_json::to_string(&err).unwrap().contains("NOT_FOUND"));
|
||
}
|
||
|
||
#[test]
|
||
fn evt_output_carries_workspace_scoped_surface() {
|
||
let evt = Envelope::Evt(Evt::Output {
|
||
surface_id: SurfaceId("s_1".into()),
|
||
bytes: vec![104, 105],
|
||
});
|
||
let json = serde_json::to_string(&evt).unwrap();
|
||
let back: Envelope = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(evt, back);
|
||
}
|
||
|
||
#[test]
|
||
fn new_surface_defaults_cmd_to_none() {
|
||
let json = r#"{"kind":"req","id":7,"cmd":{"cmd":"new_surface","args":{"workspace_id":"w_1","cols":80,"rows":24}}}"#;
|
||
let env: Envelope = serde_json::from_str(json).unwrap();
|
||
match env {
|
||
Envelope::Req { cmd: Cmd::NewSurface { command, args, .. }, .. } => {
|
||
assert!(command.is_none());
|
||
assert!(args.is_empty());
|
||
}
|
||
_ => panic!("wrong variant"),
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cargo test -p spacesh-proto`
|
||
Expected: FAIL to compile (`Envelope`, `Cmd`, `Evt`, `ErrorBody`, `SurfaceId` not defined).
|
||
|
||
- [ ] **Step 3: Write the id newtypes**
|
||
|
||
`crates/spacesh-proto/src/ids.rs`:
|
||
```rust
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub struct SurfaceId(pub String);
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub struct WorkspaceId(pub String);
|
||
|
||
impl std::fmt::Display for SurfaceId {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "{}", self.0)
|
||
}
|
||
}
|
||
impl std::fmt::Display for WorkspaceId {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "{}", self.0)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write the message types**
|
||
|
||
Prepend to `crates/spacesh-proto/src/message.rs` (above the test module):
|
||
```rust
|
||
use serde::{Deserialize, Serialize};
|
||
use crate::ids::{SurfaceId, WorkspaceId};
|
||
|
||
/// Wire envelope. `kind` is the serde tag.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||
pub enum Envelope {
|
||
Req {
|
||
id: u64,
|
||
cmd: Cmd,
|
||
},
|
||
Res {
|
||
id: u64,
|
||
ok: bool,
|
||
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
|
||
data: serde_json::Value,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
error: Option<ErrorBody>,
|
||
},
|
||
Evt(Evt),
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct ErrorBody {
|
||
pub code: String,
|
||
pub msg: String,
|
||
}
|
||
|
||
/// Client → daemon commands. The active subset for M0+M1.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "cmd", content = "args", rename_all = "snake_case")]
|
||
pub enum Cmd {
|
||
Open { path: String },
|
||
NewSurface {
|
||
workspace_id: WorkspaceId,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
command: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
args: Vec<String>,
|
||
cols: u16,
|
||
rows: u16,
|
||
},
|
||
Input {
|
||
surface_id: SurfaceId,
|
||
/// base64-encoded keyboard bytes.
|
||
bytes: String,
|
||
},
|
||
Resize { surface_id: SurfaceId, cols: u16, rows: u16 },
|
||
Attach { surface_id: SurfaceId },
|
||
Detach { surface_id: SurfaceId },
|
||
Focus { surface_id: SurfaceId },
|
||
Close { surface_id: SurfaceId },
|
||
Status,
|
||
Shutdown,
|
||
}
|
||
|
||
/// Daemon → subscribers push events. The active subset for M0+M1.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "evt", content = "data", rename_all = "snake_case")]
|
||
pub enum Evt {
|
||
Output { surface_id: SurfaceId, bytes: Vec<u8> },
|
||
Exit { surface_id: SurfaceId, code: i32 },
|
||
SurfaceCreated { surface_id: SurfaceId, workspace_id: WorkspaceId },
|
||
SurfaceClosed { surface_id: SurfaceId },
|
||
}
|
||
```
|
||
|
||
Note on the `Cmd::Input.bytes` field: it is base64 text so it survives JSON cleanly. `Evt::Output.bytes` is `Vec<u8>` and serde_json encodes it as a JSON array of numbers — acceptable for M0+M1; the MessagePack swap noted in the spec would change this later.
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `cargo test -p spacesh-proto`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add crates/spacesh-proto/src/ids.rs crates/spacesh-proto/src/message.rs
|
||
git commit -m "feat(proto): envelope, commands, events, ids with serde round-trip tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Length-prefix codec helpers
|
||
|
||
**Files:**
|
||
- Create: `crates/spacesh-proto/src/codec.rs`
|
||
- Test: inline `#[cfg(test)]` in `codec.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spacesh-proto/src/codec.rs`:
|
||
```rust
|
||
use crate::message::Envelope;
|
||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||
|
||
/// Maximum frame size we will accept (16 MiB). Guards against a corrupt length prefix.
|
||
pub const MAX_FRAME: u32 = 16 * 1024 * 1024;
|
||
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum CodecError {
|
||
#[error("io: {0}")]
|
||
Io(#[from] std::io::Error),
|
||
#[error("json: {0}")]
|
||
Json(#[from] serde_json::Error),
|
||
#[error("frame too large: {0} bytes")]
|
||
FrameTooLarge(u32),
|
||
}
|
||
|
||
/// Write one envelope as `u32` BE length prefix + JSON payload.
|
||
pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, env: &Envelope) -> Result<(), CodecError> {
|
||
let payload = serde_json::to_vec(env)?;
|
||
let len = payload.len() as u32;
|
||
if len > MAX_FRAME {
|
||
return Err(CodecError::FrameTooLarge(len));
|
||
}
|
||
w.write_all(&len.to_be_bytes()).await?;
|
||
w.write_all(&payload).await?;
|
||
w.flush().await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Read one length-prefixed envelope. Returns `Ok(None)` on clean EOF.
|
||
pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Option<Envelope>, CodecError> {
|
||
let mut len_buf = [0u8; 4];
|
||
match r.read_exact(&mut len_buf).await {
|
||
Ok(_) => {}
|
||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
|
||
Err(e) => return Err(e.into()),
|
||
}
|
||
let len = u32::from_be_bytes(len_buf);
|
||
if len > MAX_FRAME {
|
||
return Err(CodecError::FrameTooLarge(len));
|
||
}
|
||
let mut payload = vec![0u8; len as usize];
|
||
r.read_exact(&mut payload).await?;
|
||
let env: Envelope = serde_json::from_slice(&payload)?;
|
||
Ok(Some(env))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::ids::SurfaceId;
|
||
use crate::message::{Cmd, Envelope};
|
||
|
||
#[tokio::test]
|
||
async fn frame_round_trips_over_a_pipe() {
|
||
let (mut client, mut server) = tokio::io::duplex(1024);
|
||
let env = Envelope::Req { id: 9, cmd: Cmd::Status };
|
||
write_frame(&mut client, &env).await.unwrap();
|
||
let got = read_frame(&mut server).await.unwrap().unwrap();
|
||
assert_eq!(got, env);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn two_frames_are_decoded_independently() {
|
||
let (mut client, mut server) = tokio::io::duplex(4096);
|
||
let a = Envelope::Req { id: 1, cmd: Cmd::Status };
|
||
let b = Envelope::Req { id: 2, cmd: Cmd::Close { surface_id: SurfaceId("s_1".into()) } };
|
||
write_frame(&mut client, &a).await.unwrap();
|
||
write_frame(&mut client, &b).await.unwrap();
|
||
assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), a);
|
||
assert_eq!(read_frame(&mut server).await.unwrap().unwrap(), b);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn clean_eof_returns_none() {
|
||
let (client, mut server) = tokio::io::duplex(16);
|
||
drop(client);
|
||
assert!(read_frame(&mut server).await.unwrap().is_none());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cargo test -p spacesh-proto codec`
|
||
Expected: FAIL to compile until `codec` is wired (it is already declared in `lib.rs` from Task 0 Step 2). If `lib.rs` lacks `pub mod codec;`, it is present — confirm.
|
||
|
||
- [ ] **Step 3: Implementation already written in Step 1**
|
||
|
||
The module body above is the implementation. No additional code.
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cargo test -p spacesh-proto`
|
||
Expected: PASS (all proto tests, including 3 new codec tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add crates/spacesh-proto/src/codec.rs
|
||
git commit -m "feat(proto): length-prefixed frame read/write with EOF handling"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — spacesh-pty
|
||
|
||
### Task 3: PtyHandle — spawn, batched output, input, resize, kill
|
||
|
||
**Files:**
|
||
- Modify: `crates/spacesh-pty/src/lib.rs`
|
||
- Test: inline `#[cfg(test)]` in `lib.rs`
|
||
|
||
The PTY APIs are blocking (`portable-pty` reader is a blocking `Read`). We run the reader on a dedicated OS thread and forward raw chunks over an `mpsc`. Batching/coalescing happens in the daemon's surface actor (Task 13), not here — this crate exposes raw chunks plus a small read buffer. Keeping batching out of `spacesh-pty` keeps it a thin, testable I/O layer.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spacesh-pty/src/lib.rs`:
|
||
```rust
|
||
use std::io::{Read, Write};
|
||
use anyhow::Result;
|
||
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||
use tokio::sync::mpsc;
|
||
|
||
/// A spawned PTY with its child process. Output chunks arrive on `output`.
|
||
pub struct PtyHandle {
|
||
master: Box<dyn MasterPty + Send>,
|
||
writer: Box<dyn Write + Send>,
|
||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||
/// Raw output chunks read off the PTY master (already on the async side).
|
||
pub output: mpsc::Receiver<Vec<u8>>,
|
||
}
|
||
|
||
/// Parameters for spawning a surface's process.
|
||
pub struct SpawnSpec {
|
||
pub command: String,
|
||
pub args: Vec<String>,
|
||
pub cwd: std::path::PathBuf,
|
||
pub cols: u16,
|
||
pub rows: u16,
|
||
/// Extra environment variables (e.g. SPACESH_SURFACE_ID).
|
||
pub env: Vec<(String, String)>,
|
||
}
|
||
|
||
impl PtyHandle {
|
||
pub fn spawn(spec: SpawnSpec) -> Result<Self> {
|
||
let pty_system = native_pty_system();
|
||
let pair = pty_system.openpty(PtySize {
|
||
rows: spec.rows,
|
||
cols: spec.cols,
|
||
pixel_width: 0,
|
||
pixel_height: 0,
|
||
})?;
|
||
|
||
let mut cmd = CommandBuilder::new(&spec.command);
|
||
for a in &spec.args {
|
||
cmd.arg(a);
|
||
}
|
||
cmd.cwd(&spec.cwd);
|
||
for (k, v) in &spec.env {
|
||
cmd.env(k, v);
|
||
}
|
||
|
||
let child = pair.slave.spawn_command(cmd)?;
|
||
// The slave handle must be dropped so the child is the only holder; otherwise
|
||
// EOF is never observed on the master after the child exits.
|
||
drop(pair.slave);
|
||
|
||
let writer = pair.master.take_writer()?;
|
||
let mut reader = pair.master.try_clone_reader()?;
|
||
|
||
let (tx, rx) = mpsc::channel::<Vec<u8>>(256);
|
||
std::thread::spawn(move || {
|
||
let mut buf = [0u8; 8192];
|
||
loop {
|
||
match reader.read(&mut buf) {
|
||
Ok(0) => break, // EOF: child closed the pty
|
||
Ok(n) => {
|
||
if tx.blocking_send(buf[..n].to_vec()).is_err() {
|
||
break; // receiver gone
|
||
}
|
||
}
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
});
|
||
|
||
Ok(Self {
|
||
master: pair.master,
|
||
writer,
|
||
child,
|
||
output: rx,
|
||
})
|
||
}
|
||
|
||
pub fn write_input(&mut self, bytes: &[u8]) -> Result<()> {
|
||
self.writer.write_all(bytes)?;
|
||
self.writer.flush()?;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
||
self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Best-effort wait for the child's exit code (blocking).
|
||
pub fn wait(&mut self) -> i32 {
|
||
match self.child.wait() {
|
||
Ok(status) => status.exit_code() as i32,
|
||
Err(_) => -1,
|
||
}
|
||
}
|
||
|
||
pub fn kill(&mut self) {
|
||
let _ = self.child.kill();
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn shell_spec(script: &str) -> SpawnSpec {
|
||
SpawnSpec {
|
||
command: "/bin/sh".into(),
|
||
args: vec!["-c".into(), script.into()],
|
||
cwd: std::env::temp_dir(),
|
||
cols: 80,
|
||
rows: 24,
|
||
env: vec![("SPACESH_SURFACE_ID".into(), "s_test".into())],
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn spawn_echo_produces_output() {
|
||
let mut handle = PtyHandle::spawn(shell_spec("printf SPACESH_OK")).unwrap();
|
||
let mut collected = Vec::new();
|
||
// Drain until EOF (channel closes when the reader thread sees EOF).
|
||
while let Some(chunk) = handle.output.recv().await {
|
||
collected.extend_from_slice(&chunk);
|
||
}
|
||
let text = String::from_utf8_lossy(&collected);
|
||
assert!(text.contains("SPACESH_OK"), "got: {text:?}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn resize_does_not_error() {
|
||
let handle = PtyHandle::spawn(shell_spec("sleep 0.2")).unwrap();
|
||
handle.resize(120, 40).unwrap();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn input_is_echoed_back() {
|
||
// `cat` echoes stdin back to stdout on a pty.
|
||
let mut handle = PtyHandle::spawn(shell_spec("cat")).unwrap();
|
||
handle.write_input(b"hello\n").unwrap();
|
||
let mut collected = Vec::new();
|
||
// Read a few chunks then kill cat to end the stream.
|
||
if let Some(chunk) = handle.output.recv().await {
|
||
collected.extend_from_slice(&chunk);
|
||
}
|
||
handle.kill();
|
||
let text = String::from_utf8_lossy(&collected);
|
||
assert!(text.contains("hello"), "got: {text:?}");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cargo test -p spacesh-pty`
|
||
Expected: PASS actually — the implementation is included with the tests in this task. (Write the file in full as above; the "failing first" step here is degenerate because the type and tests are introduced together for an I/O wrapper. If you prefer strict red-green, paste only the `#[cfg(test)]` block first, run `cargo test -p spacesh-pty` → FAIL "cannot find PtyHandle", then paste the implementation.)
|
||
|
||
- [ ] **Step 3: Run tests to verify they pass**
|
||
|
||
Run: `cargo test -p spacesh-pty`
|
||
Expected: PASS (3 tests). If `input_is_echoed_back` is flaky on slow CI, the kill+single-recv keeps it bounded; re-run.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spacesh-pty/src/lib.rs
|
||
git commit -m "feat(pty): PtyHandle spawn/read/input/resize/kill over portable-pty"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3 — spaceshd (M0 core: bytes flying)
|
||
|
||
### Task 4: Lifecycle helpers — paths, lock, lazy start
|
||
|
||
**Files:**
|
||
- Create: `crates/spaceshd/src/lifecycle.rs`
|
||
- Modify: `crates/spaceshd/src/main.rs` (add `mod lifecycle;`)
|
||
- Test: inline `#[cfg(test)]` in `lifecycle.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spaceshd/src/lifecycle.rs`:
|
||
```rust
|
||
use std::path::PathBuf;
|
||
use anyhow::{Context, Result};
|
||
|
||
/// `~/.spacesh` directory, created if missing.
|
||
pub fn spacesh_dir() -> Result<PathBuf> {
|
||
let home = dirs::home_dir().context("no home dir")?;
|
||
let dir = home.join(".spacesh");
|
||
std::fs::create_dir_all(&dir)?;
|
||
Ok(dir)
|
||
}
|
||
|
||
pub fn socket_path() -> Result<PathBuf> {
|
||
Ok(spacesh_dir()?.join("sock"))
|
||
}
|
||
|
||
pub fn lock_path() -> Result<PathBuf> {
|
||
Ok(spacesh_dir()?.join("daemon.lock"))
|
||
}
|
||
|
||
/// Hold the single-instance lock for the lifetime of the daemon.
|
||
pub struct InstanceLock {
|
||
_file: std::fs::File,
|
||
}
|
||
|
||
/// Acquire the exclusive daemon lock. Returns `Ok(None)` if another live daemon holds it.
|
||
pub fn acquire_instance_lock() -> Result<Option<InstanceLock>> {
|
||
use fs2::FileExt;
|
||
let file = std::fs::OpenOptions::new()
|
||
.create(true)
|
||
.write(true)
|
||
.open(lock_path()?)?;
|
||
match file.try_lock_exclusive() {
|
||
Ok(()) => Ok(Some(InstanceLock { _file: file })),
|
||
Err(_) => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// If a stale socket file exists but no daemon answers, remove it so we can bind.
|
||
pub fn clear_stale_socket() -> Result<()> {
|
||
let path = socket_path()?;
|
||
if path.exists() {
|
||
// We hold the instance lock, so any existing socket is stale.
|
||
std::fs::remove_file(&path)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn paths_live_under_spacesh_dir() {
|
||
let dir = spacesh_dir().unwrap();
|
||
assert!(socket_path().unwrap().starts_with(&dir));
|
||
assert!(lock_path().unwrap().starts_with(&dir));
|
||
}
|
||
|
||
#[test]
|
||
fn lock_is_exclusive_within_process() {
|
||
let first = acquire_instance_lock().unwrap();
|
||
assert!(first.is_some(), "first acquire should succeed");
|
||
// A second attempt from the same process on the same fd path:
|
||
// fs2 advisory locks are per-handle; opening a new handle and locking
|
||
// should fail while `first` is held.
|
||
let second = acquire_instance_lock().unwrap();
|
||
assert!(second.is_none(), "second acquire should be blocked");
|
||
drop(first);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the module**
|
||
|
||
`crates/spaceshd/src/main.rs`:
|
||
```rust
|
||
mod lifecycle;
|
||
|
||
fn main() {
|
||
println!("spaceshd skeleton");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify behavior**
|
||
|
||
Run: `cargo test -p spaceshd lifecycle`
|
||
Expected: PASS (2 tests). `lock_is_exclusive_within_process` depends on `fs2` advisory locking being per-open-handle; if your platform coalesces locks per-process, change the assertion to acquire from a spawned thread holding its own handle. (macOS `flock` is per-handle → the test as written passes.)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/lifecycle.rs crates/spaceshd/src/main.rs
|
||
git commit -m "feat(daemon): lifecycle paths, single-instance lock, stale-socket cleanup"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Surface actor (M0 — no grid yet)
|
||
|
||
**Files:**
|
||
- Create: `crates/spaceshd/src/surface.rs`
|
||
- Modify: `crates/spaceshd/src/main.rs` (add `mod surface;`)
|
||
- Test: inline `#[cfg(test)]` in `surface.rs`
|
||
|
||
The actor owns the PTY and a `broadcast` sender for output fan-out. In M0 it forwards raw chunks straight to the broadcast (no grid feed, no coalescing yet — both added in Task 13). Commands arrive on an `mpsc`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spaceshd/src/surface.rs`:
|
||
```rust
|
||
use spacesh_proto::{SurfaceId, WorkspaceId};
|
||
use spacesh_pty::{PtyHandle, SpawnSpec};
|
||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||
|
||
/// Output broadcast capacity (chunks). Lagging subscribers drop intermediate frames.
|
||
const BROADCAST_CAP: usize = 1024;
|
||
|
||
/// Messages sent to a surface actor.
|
||
pub enum SurfaceMsg {
|
||
Input(Vec<u8>),
|
||
Resize { cols: u16, rows: u16 },
|
||
/// Subscribe to the output stream. Reply carries a fresh receiver.
|
||
Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
|
||
Close,
|
||
}
|
||
|
||
/// Handle the daemon keeps for a live surface.
|
||
pub struct SurfaceHandle {
|
||
pub id: SurfaceId,
|
||
pub workspace_id: WorkspaceId,
|
||
pub tx: mpsc::Sender<SurfaceMsg>,
|
||
}
|
||
|
||
/// Spawn the actor; returns its handle. `exit_tx` is fired once with the exit code.
|
||
pub fn spawn_surface(
|
||
id: SurfaceId,
|
||
workspace_id: WorkspaceId,
|
||
mut pty: PtyHandle,
|
||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||
) -> SurfaceHandle {
|
||
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
|
||
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
|
||
let actor_id = id.clone();
|
||
|
||
tokio::spawn(async move {
|
||
loop {
|
||
tokio::select! {
|
||
msg = rx.recv() => {
|
||
match msg {
|
||
Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); }
|
||
Some(SurfaceMsg::Resize { cols, rows }) => { let _ = pty.resize(cols, rows); }
|
||
Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); }
|
||
Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
|
||
}
|
||
}
|
||
chunk = pty.output.recv() => {
|
||
match chunk {
|
||
Some(bytes) => { let _ = bcast.send(bytes); }
|
||
None => { break; } // PTY EOF
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let code = pty.wait();
|
||
let _ = exit_tx.send((actor_id, code));
|
||
});
|
||
|
||
SurfaceHandle { id, workspace_id, tx }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use spacesh_pty::SpawnSpec;
|
||
|
||
fn spec(script: &str) -> SpawnSpec {
|
||
SpawnSpec {
|
||
command: "/bin/sh".into(),
|
||
args: vec!["-c".into(), script.into()],
|
||
cwd: std::env::temp_dir(),
|
||
cols: 80,
|
||
rows: 24,
|
||
env: vec![],
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn attach_receives_output() {
|
||
let pty = PtyHandle::spawn(spec("printf HELLO; sleep 0.3")).unwrap();
|
||
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
|
||
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, exit_tx);
|
||
|
||
let (reply_tx, reply_rx) = oneshot::channel();
|
||
handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap();
|
||
let mut sub = reply_rx.await.unwrap();
|
||
|
||
let mut collected = Vec::new();
|
||
// Collect for a short bounded window.
|
||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
|
||
while tokio::time::Instant::now() < deadline {
|
||
match tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await {
|
||
Ok(Ok(bytes)) => collected.extend_from_slice(&bytes),
|
||
_ => {}
|
||
}
|
||
if String::from_utf8_lossy(&collected).contains("HELLO") { break; }
|
||
}
|
||
assert!(String::from_utf8_lossy(&collected).contains("HELLO"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn exit_is_reported() {
|
||
let pty = PtyHandle::spawn(spec("exit 7")).unwrap();
|
||
let (exit_tx, mut exit_rx) = mpsc::unbounded_channel();
|
||
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, exit_tx);
|
||
let (sid, code) = tokio::time::timeout(tokio::time::Duration::from_secs(3), exit_rx.recv())
|
||
.await.unwrap().unwrap();
|
||
assert_eq!(sid, SurfaceId("s_2".into()));
|
||
assert_eq!(code, 7);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the module**
|
||
|
||
`crates/spaceshd/src/main.rs`:
|
||
```rust
|
||
mod lifecycle;
|
||
mod surface;
|
||
|
||
fn main() {
|
||
println!("spaceshd skeleton");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails then passes**
|
||
|
||
Run: `cargo test -p spaceshd surface`
|
||
Expected: PASS (2 tests). If the unused `SpawnSpec` import in the actor file warns, remove it from the top-level `use` (it is only used in tests).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/surface.rs crates/spaceshd/src/main.rs
|
||
git commit -m "feat(daemon): surface actor owning pty + broadcast fan-out (M0, no grid)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Registry
|
||
|
||
**Files:**
|
||
- Create: `crates/spaceshd/src/registry.rs`
|
||
- Modify: `crates/spaceshd/src/main.rs` (add `mod registry;`)
|
||
- Test: inline `#[cfg(test)]` in `registry.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spaceshd/src/registry.rs`:
|
||
```rust
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use spacesh_proto::{SurfaceId, WorkspaceId};
|
||
use crate::surface::SurfaceHandle;
|
||
|
||
#[derive(Clone)]
|
||
pub struct WorkspaceMeta {
|
||
pub id: WorkspaceId,
|
||
pub path: PathBuf,
|
||
}
|
||
|
||
/// Single-threaded owner of all live surfaces and workspaces.
|
||
/// Lives inside the server task; not shared across threads.
|
||
#[derive(Default)]
|
||
pub struct Registry {
|
||
counter: AtomicU64,
|
||
workspaces: HashMap<WorkspaceId, WorkspaceMeta>,
|
||
/// path → workspace, so `open` is idempotent.
|
||
by_path: HashMap<PathBuf, WorkspaceId>,
|
||
surfaces: HashMap<SurfaceId, SurfaceHandle>,
|
||
}
|
||
|
||
impl Registry {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
fn next_id(&self, prefix: &str) -> String {
|
||
let n = self.counter.fetch_add(1, Ordering::Relaxed);
|
||
format!("{prefix}_{n:x}")
|
||
}
|
||
|
||
/// Idempotent: opening the same canonicalized path returns the existing workspace.
|
||
pub fn open_workspace(&mut self, path: PathBuf) -> WorkspaceMeta {
|
||
let canonical = path.canonicalize().unwrap_or(path);
|
||
if let Some(id) = self.by_path.get(&canonical) {
|
||
return self.workspaces[id].clone();
|
||
}
|
||
let id = WorkspaceId(self.next_id("w"));
|
||
let meta = WorkspaceMeta { id: id.clone(), path: canonical.clone() };
|
||
self.workspaces.insert(id.clone(), meta.clone());
|
||
self.by_path.insert(canonical, id);
|
||
meta
|
||
}
|
||
|
||
pub fn workspace(&self, id: &WorkspaceId) -> Option<&WorkspaceMeta> {
|
||
self.workspaces.get(id)
|
||
}
|
||
|
||
pub fn new_surface_id(&self) -> SurfaceId {
|
||
SurfaceId(self.next_id("s"))
|
||
}
|
||
|
||
pub fn insert_surface(&mut self, handle: SurfaceHandle) {
|
||
self.surfaces.insert(handle.id.clone(), handle);
|
||
}
|
||
|
||
pub fn surface(&self, id: &SurfaceId) -> Option<&SurfaceHandle> {
|
||
self.surfaces.get(id)
|
||
}
|
||
|
||
pub fn remove_surface(&mut self, id: &SurfaceId) -> Option<SurfaceHandle> {
|
||
self.surfaces.remove(id)
|
||
}
|
||
|
||
/// Snapshot for the `status` command: (workspace, its surface ids).
|
||
pub fn status(&self) -> Vec<(WorkspaceMeta, Vec<SurfaceId>)> {
|
||
self.workspaces
|
||
.values()
|
||
.map(|w| {
|
||
let sids = self
|
||
.surfaces
|
||
.values()
|
||
.filter(|s| s.workspace_id == w.id)
|
||
.map(|s| s.id.clone())
|
||
.collect();
|
||
(w.clone(), sids)
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn open_is_idempotent_per_path() {
|
||
let mut reg = Registry::new();
|
||
let dir = std::env::temp_dir();
|
||
let a = reg.open_workspace(dir.clone());
|
||
let b = reg.open_workspace(dir.clone());
|
||
assert_eq!(a.id, b.id);
|
||
}
|
||
|
||
#[test]
|
||
fn ids_are_unique_and_prefixed() {
|
||
let reg = Registry::new();
|
||
let s1 = reg.new_surface_id();
|
||
let s2 = reg.new_surface_id();
|
||
assert!(s1.0.starts_with("s_"));
|
||
assert_ne!(s1, s2);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the module**
|
||
|
||
Update `crates/spaceshd/src/main.rs` to add `mod registry;` alongside the others.
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
Run: `cargo test -p spaceshd registry`
|
||
Expected: PASS (2 tests).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/registry.rs crates/spaceshd/src/main.rs
|
||
git commit -m "feat(daemon): registry for workspaces and surfaces with idempotent open"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Socket server — accept loop, client task, command dispatch
|
||
|
||
**Files:**
|
||
- Create: `crates/spaceshd/src/server.rs`
|
||
- Modify: `crates/spaceshd/src/main.rs`
|
||
- Test: inline `#[cfg(test)]` integration test in `server.rs`
|
||
|
||
Design: the `Registry` is owned by a single **router task** reachable via an `mpsc<ServerMsg>`. Each accepted connection spawns a **client task** that owns the socket write half and forwards (a) parsed requests to the router, (b) a subscription channel for events. The router executes commands against the registry and replies through per-client `mpsc<Envelope>` senders. This keeps the registry lock-free (single owner) and lets the router fan events out to all connected clients.
|
||
|
||
- [ ] **Step 1: Write the failing integration test**
|
||
|
||
`crates/spaceshd/src/server.rs`:
|
||
```rust
|
||
use std::collections::HashMap;
|
||
use std::path::Path;
|
||
use anyhow::Result;
|
||
use base64::Engine;
|
||
use spacesh_proto::codec::{read_frame, write_frame};
|
||
use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId};
|
||
use spacesh_pty::{PtyHandle, SpawnSpec};
|
||
use tokio::net::{UnixListener, UnixStream};
|
||
use tokio::sync::{mpsc, oneshot};
|
||
use crate::registry::Registry;
|
||
use crate::surface::{spawn_surface, SurfaceMsg};
|
||
|
||
/// Per-client outbound channel: the router pushes envelopes the client task writes out.
|
||
type ClientTx = mpsc::Sender<Envelope>;
|
||
|
||
/// Messages into the single router task.
|
||
enum ServerMsg {
|
||
/// A request from a client; reply routed to that client's `out`.
|
||
Request { id: u64, cmd: Cmd, client: ClientId, out: ClientTx },
|
||
/// Forward an output chunk to all subscribers of `surface_id`.
|
||
Output { surface_id: SurfaceId, bytes: Vec<u8> },
|
||
/// A surface process exited.
|
||
Exit { surface_id: SurfaceId, code: i32 },
|
||
/// Register a new client's event sink.
|
||
ClientConnected { client: ClientId, out: ClientTx },
|
||
/// Drop a client and all its subscriptions.
|
||
ClientDisconnected { client: ClientId },
|
||
}
|
||
|
||
type ClientId = u64;
|
||
|
||
pub async fn serve(socket: &Path) -> Result<()> {
|
||
let listener = UnixListener::bind(socket)?;
|
||
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
|
||
|
||
// Exit events from surfaces are funneled into the router.
|
||
let (exit_tx, mut exit_rx) = mpsc::unbounded_channel::<(SurfaceId, i32)>();
|
||
let router_for_exit = router_tx.clone();
|
||
tokio::spawn(async move {
|
||
while let Some((sid, code)) = exit_rx.recv().await {
|
||
let _ = router_for_exit.send(ServerMsg::Exit { surface_id: sid, code }).await;
|
||
}
|
||
});
|
||
|
||
let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx));
|
||
|
||
let mut next_client: ClientId = 0;
|
||
loop {
|
||
let (stream, _addr) = listener.accept().await?;
|
||
let client_id = next_client;
|
||
next_client += 1;
|
||
let router_tx = router_tx.clone();
|
||
tokio::spawn(handle_client(stream, client_id, router_tx));
|
||
if shutdown.is_finished() {
|
||
break;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn handle_client(stream: UnixStream, client_id: ClientId, router_tx: mpsc::Sender<ServerMsg>) {
|
||
let (mut read_half, mut write_half) = stream.into_split();
|
||
let (out_tx, mut out_rx) = mpsc::channel::<Envelope>(256);
|
||
|
||
let _ = router_tx
|
||
.send(ServerMsg::ClientConnected { client: client_id, out: out_tx.clone() })
|
||
.await;
|
||
|
||
// Writer task: drain outbound envelopes to the socket.
|
||
let writer = tokio::spawn(async move {
|
||
while let Some(env) = out_rx.recv().await {
|
||
if write_frame(&mut write_half, &env).await.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Reader loop: parse frames and forward requests to the router.
|
||
loop {
|
||
match read_frame(&mut read_half).await {
|
||
Ok(Some(Envelope::Req { id, cmd })) => {
|
||
let _ = router_tx
|
||
.send(ServerMsg::Request { id, cmd, client: client_id, out: out_tx.clone() })
|
||
.await;
|
||
}
|
||
Ok(Some(_)) => { /* clients don't send res/evt; ignore */ }
|
||
Ok(None) => break, // EOF
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
|
||
let _ = router_tx.send(ServerMsg::ClientDisconnected { client: client_id }).await;
|
||
writer.abort();
|
||
}
|
||
|
||
async fn router(
|
||
mut rx: mpsc::Receiver<ServerMsg>,
|
||
router_tx: mpsc::Sender<ServerMsg>,
|
||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||
) {
|
||
let mut reg = Registry::new();
|
||
let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
|
||
// surface_id → set of client ids subscribed (attached).
|
||
let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new();
|
||
|
||
while let Some(msg) = rx.recv().await {
|
||
match msg {
|
||
ServerMsg::ClientConnected { client, out } => {
|
||
clients.insert(client, out);
|
||
}
|
||
ServerMsg::ClientDisconnected { client } => {
|
||
clients.remove(&client);
|
||
for list in subs.values_mut() {
|
||
list.retain(|c| *c != client);
|
||
}
|
||
}
|
||
ServerMsg::Output { surface_id, bytes } => {
|
||
if let Some(list) = subs.get(&surface_id) {
|
||
let evt = Envelope::Evt(Evt::Output { surface_id: surface_id.clone(), bytes });
|
||
for c in list {
|
||
if let Some(out) = clients.get(c) {
|
||
let _ = out.try_send(evt.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ServerMsg::Exit { surface_id, code } => {
|
||
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
|
||
broadcast_evt(&clients, &evt);
|
||
}
|
||
ServerMsg::Request { id, cmd, client, out } => {
|
||
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx).await;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn broadcast_evt(clients: &HashMap<ClientId, ClientTx>, evt: &Envelope) {
|
||
for out in clients.values() {
|
||
let _ = out.try_send(evt.clone());
|
||
}
|
||
}
|
||
|
||
fn ok(id: u64, data: serde_json::Value) -> Envelope {
|
||
Envelope::Res { id, ok: true, data, error: None }
|
||
}
|
||
fn err(id: u64, code: &str, msg: &str) -> Envelope {
|
||
Envelope::Res { id, ok: false, data: serde_json::Value::Null,
|
||
error: Some(ErrorBody { code: code.into(), msg: msg.into() }) }
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn handle_request(
|
||
id: u64,
|
||
cmd: Cmd,
|
||
client: ClientId,
|
||
out: ClientTx,
|
||
reg: &mut Registry,
|
||
subs: &mut HashMap<SurfaceId, Vec<ClientId>>,
|
||
clients: &HashMap<ClientId, ClientTx>,
|
||
router_tx: &mpsc::Sender<ServerMsg>,
|
||
exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||
) {
|
||
match cmd {
|
||
Cmd::Open { path } => {
|
||
let meta = reg.open_workspace(path.into());
|
||
let _ = out.send(ok(id, serde_json::json!({ "workspace_id": meta.id.0 }))).await;
|
||
}
|
||
Cmd::NewSurface { workspace_id, command, args, cols, rows } => {
|
||
let Some(ws) = reg.workspace(&workspace_id).cloned() else {
|
||
let _ = out.send(err(id, "NOT_FOUND", "workspace")).await;
|
||
return;
|
||
};
|
||
let sid = reg.new_surface_id();
|
||
let shell = command.unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()));
|
||
let spec = SpawnSpec {
|
||
command: shell,
|
||
args,
|
||
cwd: ws.path.clone(),
|
||
cols,
|
||
rows,
|
||
env: vec![("SPACESH_SURFACE_ID".into(), sid.0.clone())],
|
||
};
|
||
match PtyHandle::spawn(spec) {
|
||
Ok(pty) => {
|
||
let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, exit_tx.clone());
|
||
// Bridge the surface's broadcast into the router as Output messages.
|
||
spawn_output_bridge(sid.clone(), &handle, router_tx.clone());
|
||
reg.insert_surface(handle);
|
||
let created = Envelope::Evt(Evt::SurfaceCreated {
|
||
surface_id: sid.clone(), workspace_id: workspace_id.clone(),
|
||
});
|
||
broadcast_evt(clients, &created);
|
||
let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await;
|
||
}
|
||
Err(e) => {
|
||
let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await;
|
||
}
|
||
}
|
||
}
|
||
Cmd::Input { surface_id, bytes } => {
|
||
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else {
|
||
let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await;
|
||
return;
|
||
};
|
||
if let Some(s) = reg.surface(&surface_id) {
|
||
let _ = s.tx.send(SurfaceMsg::Input(decoded)).await;
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
} else {
|
||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||
}
|
||
}
|
||
Cmd::Resize { surface_id, cols, rows } => {
|
||
if let Some(s) = reg.surface(&surface_id) {
|
||
let _ = s.tx.send(SurfaceMsg::Resize { cols, rows }).await;
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
} else {
|
||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||
}
|
||
}
|
||
Cmd::Attach { surface_id } => {
|
||
// M0 attach: register subscription, no snapshot yet (snapshot added in Task 13).
|
||
if reg.surface(&surface_id).is_some() {
|
||
subs.entry(surface_id.clone()).or_default().push(client);
|
||
let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0 }))).await;
|
||
} else {
|
||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||
}
|
||
}
|
||
Cmd::Detach { surface_id } => {
|
||
if let Some(list) = subs.get_mut(&surface_id) {
|
||
list.retain(|c| *c != client);
|
||
}
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
}
|
||
Cmd::Focus { surface_id: _ } => {
|
||
// Focus is a no-op in this slice (window raise is GUI-side; CLI parity later).
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
}
|
||
Cmd::Close { surface_id } => {
|
||
if let Some(handle) = reg.remove_surface(&surface_id) {
|
||
let _ = handle.tx.send(SurfaceMsg::Close).await;
|
||
subs.remove(&surface_id);
|
||
let closed = Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() });
|
||
broadcast_evt(clients, &closed);
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
} else {
|
||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||
}
|
||
}
|
||
Cmd::Status => {
|
||
let workspaces: Vec<_> = reg.status().into_iter().map(|(w, sids)| {
|
||
serde_json::json!({
|
||
"workspace_id": w.id.0,
|
||
"path": w.path.to_string_lossy(),
|
||
"surfaces": sids.iter().map(|s| s.0.clone()).collect::<Vec<_>>(),
|
||
})
|
||
}).collect();
|
||
let _ = out.send(ok(id, serde_json::json!({ "workspaces": workspaces }))).await;
|
||
}
|
||
Cmd::Shutdown => {
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
std::process::exit(0);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pump a surface's broadcast output into the router as `ServerMsg::Output`.
|
||
fn spawn_output_bridge(
|
||
surface_id: SurfaceId,
|
||
handle: &crate::surface::SurfaceHandle,
|
||
router_tx: mpsc::Sender<ServerMsg>,
|
||
) {
|
||
let tx = handle.tx.clone();
|
||
tokio::spawn(async move {
|
||
// Ask the actor for a subscription receiver.
|
||
let (reply_tx, reply_rx) = oneshot::channel();
|
||
if tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.is_err() {
|
||
return;
|
||
}
|
||
let Ok(mut sub) = reply_rx.await else { return };
|
||
loop {
|
||
match sub.recv().await {
|
||
Ok(bytes) => {
|
||
if router_tx.send(ServerMsg::Output { surface_id: surface_id.clone(), bytes }).await.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||
Err(_) => break, // surface closed
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use base64::Engine;
|
||
|
||
async fn req(stream: &mut UnixStream, id: u64, cmd: Cmd) -> Envelope {
|
||
write_frame(stream, &Envelope::Req { id, cmd }).await.unwrap();
|
||
// Read until we see the matching res (skip interleaved evts).
|
||
loop {
|
||
let env = read_frame(stream).await.unwrap().unwrap();
|
||
if let Envelope::Res { id: rid, .. } = &env {
|
||
if *rid == id { return env; }
|
||
}
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn open_new_surface_attach_streams_output() {
|
||
let dir = tempdir_path();
|
||
let sock = dir.join("sock");
|
||
let sock_for_task = sock.clone();
|
||
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
|
||
wait_for_socket(&sock).await;
|
||
|
||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||
|
||
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
|
||
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
|
||
|
||
let r = req(&mut s, 2, Cmd::NewSurface {
|
||
workspace_id: spacesh_proto::WorkspaceId(ws),
|
||
command: Some("/bin/sh".into()),
|
||
args: vec!["-c".into(), "printf STREAM_OK; sleep 0.5".into()],
|
||
cols: 80, rows: 24,
|
||
}).await;
|
||
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
|
||
let surface_id = spacesh_proto::SurfaceId(sid);
|
||
|
||
let _ = req(&mut s, 3, Cmd::Attach { surface_id: surface_id.clone() }).await;
|
||
|
||
// Now read frames looking for an Output evt containing STREAM_OK.
|
||
let mut got = String::new();
|
||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
|
||
while tokio::time::Instant::now() < deadline {
|
||
if let Ok(Ok(Some(Envelope::Evt(Evt::Output { bytes, .. })))) =
|
||
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut s)).await {
|
||
got.push_str(&String::from_utf8_lossy(&bytes));
|
||
if got.contains("STREAM_OK") { break; }
|
||
}
|
||
}
|
||
assert!(got.contains("STREAM_OK"), "got: {got:?}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn unknown_surface_returns_not_found() {
|
||
let dir = tempdir_path();
|
||
let sock = dir.join("sock");
|
||
let sock_for_task = sock.clone();
|
||
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
|
||
wait_for_socket(&sock).await;
|
||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||
let r = req(&mut s, 1, Cmd::Input {
|
||
surface_id: spacesh_proto::SurfaceId("s_nope".into()),
|
||
bytes: base64::engine::general_purpose::STANDARD.encode(b"x"),
|
||
}).await;
|
||
match r {
|
||
Envelope::Res { ok, error, .. } => {
|
||
assert!(!ok);
|
||
assert_eq!(error.unwrap().code, "NOT_FOUND");
|
||
}
|
||
_ => panic!(),
|
||
}
|
||
}
|
||
|
||
fn res_data(env: &Envelope) -> &serde_json::Value {
|
||
match env { Envelope::Res { data, .. } => data, _ => panic!("not a res") }
|
||
}
|
||
|
||
fn tempdir_path() -> std::path::PathBuf {
|
||
let mut p = std::env::temp_dir();
|
||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||
p.push(format!("spaceshd-test-{n}"));
|
||
std::fs::create_dir_all(&p).unwrap();
|
||
p
|
||
}
|
||
|
||
async fn wait_for_socket(sock: &Path) {
|
||
for _ in 0..100 {
|
||
if UnixStream::connect(sock).await.is_ok() { return; }
|
||
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
|
||
}
|
||
panic!("socket never came up");
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `tempfile`-free temp dirs as above (no new dependency). Note `Cmd::Shutdown` calls `std::process::exit` — do **not** exercise it in tests (it would kill the test runner). It is covered by manual verification.
|
||
|
||
- [ ] **Step 2: Wire the module**
|
||
|
||
`crates/spaceshd/src/main.rs`:
|
||
```rust
|
||
mod lifecycle;
|
||
mod registry;
|
||
mod server;
|
||
mod surface;
|
||
|
||
fn main() {
|
||
println!("spaceshd skeleton");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
Run: `cargo test -p spaceshd server`
|
||
Expected: PASS (2 tests). The streaming test depends on the output bridge subscribing before the child writes; `sleep 0.5` in the script gives margin.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/server.rs crates/spaceshd/src/main.rs
|
||
git commit -m "feat(daemon): socket server with router task, command dispatch, event fan-out (M0)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Daemon entrypoint with single-instance startup
|
||
|
||
**Files:**
|
||
- Modify: `crates/spaceshd/src/main.rs`
|
||
|
||
- [ ] **Step 1: Write the entrypoint**
|
||
|
||
`crates/spaceshd/src/main.rs`:
|
||
```rust
|
||
mod launchd;
|
||
mod lifecycle;
|
||
mod registry;
|
||
mod server;
|
||
mod surface;
|
||
|
||
use anyhow::Result;
|
||
|
||
#[tokio::main]
|
||
async fn main() -> Result<()> {
|
||
let arg = std::env::args().nth(1);
|
||
match arg.as_deref() {
|
||
Some("install-agent") => {
|
||
launchd::install_agent()?;
|
||
println!("launchd agent installed");
|
||
Ok(())
|
||
}
|
||
Some("--help") | Some("-h") => {
|
||
println!("spaceshd [install-agent]");
|
||
Ok(())
|
||
}
|
||
_ => run_daemon().await,
|
||
}
|
||
}
|
||
|
||
async fn run_daemon() -> Result<()> {
|
||
let Some(_lock) = lifecycle::acquire_instance_lock()? else {
|
||
eprintln!("another spaceshd is already running");
|
||
return Ok(());
|
||
};
|
||
lifecycle::clear_stale_socket()?;
|
||
let sock = lifecycle::socket_path()?;
|
||
eprintln!("spaceshd listening on {}", sock.display());
|
||
server::serve(&sock).await
|
||
}
|
||
```
|
||
|
||
(`launchd::install_agent` is created in Task 16; to keep Task 8 compiling on its own, add a temporary stub now and replace it in Task 16.)
|
||
|
||
Temporary stub — `crates/spaceshd/src/launchd.rs`:
|
||
```rust
|
||
use anyhow::Result;
|
||
pub fn install_agent() -> Result<()> {
|
||
anyhow::bail!("install-agent implemented in Task 16")
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build and smoke-run the daemon manually**
|
||
|
||
Run: `cargo build -p spaceshd`
|
||
Expected: PASS.
|
||
|
||
Manual smoke test (own terminal):
|
||
```bash
|
||
cargo run -p spaceshd &
|
||
sleep 1
|
||
ls -l ~/.spacesh/sock # socket exists
|
||
kill %1
|
||
rm -f ~/.spacesh/sock
|
||
```
|
||
Expected: socket file present while running.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/main.rs crates/spaceshd/src/launchd.rs
|
||
git commit -m "feat(daemon): entrypoint with single-instance lock and lazy socket bind"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — Tauri app (M0: bytes flying in the GUI)
|
||
|
||
### Task 9: Tauri scaffold + bridge state
|
||
|
||
**Files:**
|
||
- Create: `app/package.json`, `app/vite.config.ts`, `app/index.html`, `app/tsconfig.json`
|
||
- Create: `app/src-tauri/Cargo.toml`, `app/src-tauri/tauri.conf.json`, `app/src-tauri/build.rs`
|
||
- Create: `app/src-tauri/src/main.rs`, `app/src-tauri/src/lib.rs`
|
||
|
||
This task scaffolds the app and proves it launches. The bridge logic is Task 10's `bridge.rs`. Use the official scaffolder if preferred (`npm create tauri-app@latest`), but the files below are the minimum that integrates with our crates.
|
||
|
||
- [ ] **Step 1: Frontend manifest**
|
||
|
||
`app/package.json`:
|
||
```json
|
||
{
|
||
"name": "spacesh-app",
|
||
"private": true,
|
||
"version": "0.1.0",
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc && vite build",
|
||
"tauri": "tauri"
|
||
},
|
||
"dependencies": {
|
||
"@tauri-apps/api": "^2",
|
||
"@xterm/xterm": "^5.5.0",
|
||
"@xterm/addon-webgl": "^0.18.0",
|
||
"react": "^18.3.1",
|
||
"react-dom": "^18.3.1"
|
||
},
|
||
"devDependencies": {
|
||
"@tauri-apps/cli": "^2",
|
||
"@types/react": "^18.3.1",
|
||
"@types/react-dom": "^18.3.1",
|
||
"@vitejs/plugin-react": "^4.3.1",
|
||
"typescript": "^5.5.0",
|
||
"vite": "^5.4.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
`app/vite.config.ts`:
|
||
```ts
|
||
import { defineConfig } from "vite";
|
||
import react from "@vitejs/plugin-react";
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
clearScreen: false,
|
||
server: { port: 1420, strictPort: true },
|
||
});
|
||
```
|
||
|
||
`app/index.html`:
|
||
```html
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>spacesh</title>
|
||
<link rel="stylesheet" href="/node_modules/@xterm/xterm/css/xterm.css" />
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
`app/tsconfig.json`:
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2020",
|
||
"useDefineForClassFields": true,
|
||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext",
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"strict": true,
|
||
"jsx": "react-jsx",
|
||
"noEmit": true
|
||
},
|
||
"include": ["src"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tauri Rust side manifest and config**
|
||
|
||
`app/src-tauri/Cargo.toml`:
|
||
```toml
|
||
[package]
|
||
name = "spacesh-app"
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
|
||
[lib]
|
||
name = "spacesh_app_lib"
|
||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||
|
||
[build-dependencies]
|
||
tauri-build = { version = "2", features = [] }
|
||
|
||
[dependencies]
|
||
tauri = { version = "2", features = [] }
|
||
spacesh-proto = { path = "../../crates/spacesh-proto" }
|
||
tokio = { version = "1", features = ["full"] }
|
||
serde = { version = "1", features = ["derive"] }
|
||
serde_json = "1"
|
||
base64 = "0.22"
|
||
anyhow = "1"
|
||
dirs = "5"
|
||
```
|
||
|
||
`app/src-tauri/build.rs`:
|
||
```rust
|
||
fn main() {
|
||
tauri_build::build()
|
||
}
|
||
```
|
||
|
||
`app/src-tauri/tauri.conf.json`:
|
||
```json
|
||
{
|
||
"$schema": "https://schema.tauri.app/config/2",
|
||
"productName": "spacesh",
|
||
"version": "0.1.0",
|
||
"identifier": "xyz.spacesh.app",
|
||
"build": {
|
||
"frontendDist": "../dist",
|
||
"devUrl": "http://localhost:1420",
|
||
"beforeDevCommand": "npm run dev",
|
||
"beforeBuildCommand": "npm run build"
|
||
},
|
||
"app": {
|
||
"windows": [{ "title": "spacesh", "width": 1100, "height": 720 }],
|
||
"security": { "csp": null }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Minimal Rust entry that launches a window**
|
||
|
||
`app/src-tauri/src/main.rs`:
|
||
```rust
|
||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||
|
||
fn main() {
|
||
spacesh_app_lib::run();
|
||
}
|
||
```
|
||
|
||
`app/src-tauri/src/lib.rs` (Task 9 version — expanded in Task 10):
|
||
```rust
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
tauri::Builder::default()
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running spacesh");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Minimal React entry**
|
||
|
||
`app/src/main.tsx`:
|
||
```tsx
|
||
import React from "react";
|
||
import ReactDOM from "react-dom/client";
|
||
|
||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||
<React.StrictMode>
|
||
<div style={{ color: "#ddd", fontFamily: "monospace", padding: 16 }}>
|
||
spacesh — scaffold OK
|
||
</div>
|
||
</React.StrictMode>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 5: Install and launch**
|
||
|
||
Run:
|
||
```bash
|
||
cd app && npm install && npm run tauri dev
|
||
```
|
||
Expected: a window opens showing "spacesh — scaffold OK". Close it.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add app/
|
||
git commit -m "chore(app): scaffold tauri 2 + react + vite, window launches"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Bridge (UDS ↔ webview) + React terminal
|
||
|
||
**Files:**
|
||
- Create: `app/src-tauri/src/bridge.rs`
|
||
- Modify: `app/src-tauri/src/lib.rs`
|
||
- Create: `app/src/socketBridge.ts`, `app/src/TerminalView.tsx`, `app/src/SurfaceList.tsx`, `app/src/App.tsx`
|
||
- Modify: `app/src/main.tsx`
|
||
|
||
Bridge design (scheme B): one persistent UDS connection. A writer task owns the write half; a reader task demuxes frames — `Res` go to a pending-request map (oneshot per `id`), `Evt::Output` go to the registered per-surface `Channel`, other `Evt` are `emit`ted to the webview. The daemon must be auto-spawned if the socket is absent.
|
||
|
||
- [ ] **Step 1: Implement the bridge**
|
||
|
||
`app/src-tauri/src/bridge.rs`:
|
||
```rust
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
use anyhow::{Context, Result};
|
||
use base64::Engine;
|
||
use serde_json::Value;
|
||
use spacesh_proto::codec::{read_frame, write_frame};
|
||
use spacesh_proto::{Cmd, Envelope, Evt, SurfaceId};
|
||
use tauri::ipc::Channel;
|
||
use tauri::{AppHandle, Emitter};
|
||
use tokio::net::UnixStream;
|
||
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
|
||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||
|
||
pub struct Bridge {
|
||
next_id: AtomicU64,
|
||
/// Outbound frames to the daemon.
|
||
tx: mpsc::Sender<Envelope>,
|
||
/// Pending request id → reply slot.
|
||
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
||
/// surface id → output channel into the webview.
|
||
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
|
||
}
|
||
|
||
fn socket_path() -> Result<PathBuf> {
|
||
Ok(dirs::home_dir().context("no home")?.join(".spacesh").join("sock"))
|
||
}
|
||
|
||
async fn ensure_daemon(sock: &PathBuf) -> Result<UnixStream> {
|
||
if let Ok(s) = UnixStream::connect(sock).await {
|
||
return Ok(s);
|
||
}
|
||
// Lazy start: spawn the daemon binary, then poll for the socket.
|
||
let exe = std::env::current_exe()?;
|
||
let daemon = exe.with_file_name("spaceshd");
|
||
let _ = std::process::Command::new(daemon).spawn();
|
||
for _ in 0..100 {
|
||
if let Ok(s) = UnixStream::connect(sock).await {
|
||
return Ok(s);
|
||
}
|
||
tokio::time::sleep(tokio::time::Duration::from_millis(30)).await;
|
||
}
|
||
anyhow::bail!("daemon did not come up")
|
||
}
|
||
|
||
impl Bridge {
|
||
pub async fn connect(app: AppHandle) -> Result<Self> {
|
||
let sock = socket_path()?;
|
||
let stream = ensure_daemon(&sock).await?;
|
||
let (read_half, write_half) = stream.into_split();
|
||
|
||
let (tx, rx) = mpsc::channel::<Envelope>(256);
|
||
let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>> = Arc::default();
|
||
let out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>> = Arc::default();
|
||
|
||
spawn_writer(write_half, rx);
|
||
spawn_reader(read_half, app, pending.clone(), out_channels.clone());
|
||
|
||
Ok(Self { next_id: AtomicU64::new(1), tx, pending, out_channels })
|
||
}
|
||
|
||
pub async fn request(&self, cmd: Cmd) -> Result<Envelope> {
|
||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||
let (reply_tx, reply_rx) = oneshot::channel();
|
||
self.pending.lock().await.insert(id, reply_tx);
|
||
self.tx.send(Envelope::Req { id, cmd }).await?;
|
||
Ok(reply_rx.await?)
|
||
}
|
||
|
||
pub async fn register_output(&self, surface_id: String, channel: Channel<Vec<u8>>) {
|
||
self.out_channels.lock().await.insert(surface_id, channel);
|
||
}
|
||
|
||
pub async fn unregister_output(&self, surface_id: &str) {
|
||
self.out_channels.lock().await.remove(surface_id);
|
||
}
|
||
}
|
||
|
||
fn spawn_writer(mut write_half: OwnedWriteHalf, mut rx: mpsc::Receiver<Envelope>) {
|
||
tokio::spawn(async move {
|
||
while let Some(env) = rx.recv().await {
|
||
if write_frame(&mut write_half, &env).await.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
fn spawn_reader(
|
||
mut read_half: OwnedReadHalf,
|
||
app: AppHandle,
|
||
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Envelope>>>>,
|
||
out_channels: Arc<Mutex<HashMap<String, Channel<Vec<u8>>>>>,
|
||
) {
|
||
tokio::spawn(async move {
|
||
loop {
|
||
match read_frame(&mut read_half).await {
|
||
Ok(Some(env)) => match env {
|
||
Envelope::Res { id, .. } => {
|
||
if let Some(slot) = pending.lock().await.remove(&id) {
|
||
let _ = slot.send(env);
|
||
}
|
||
}
|
||
Envelope::Evt(Evt::Output { surface_id, bytes }) => {
|
||
if let Some(ch) = out_channels.lock().await.get(&surface_id.0) {
|
||
let _ = ch.send(bytes);
|
||
}
|
||
}
|
||
Envelope::Evt(other) => {
|
||
// exit / surface_created / surface_closed → emit to webview.
|
||
let _ = app.emit("spacesh:evt", &other);
|
||
}
|
||
Envelope::Req { .. } => {}
|
||
},
|
||
Ok(None) | Err(_) => break,
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- Tauri commands ----
|
||
|
||
type BridgeState<'a> = tauri::State<'a, Bridge>;
|
||
|
||
fn data_of(env: Envelope) -> Result<Value, String> {
|
||
match env {
|
||
Envelope::Res { ok: true, data, .. } => Ok(data),
|
||
Envelope::Res { ok: false, error, .. } => {
|
||
Err(error.map(|e| format!("{}: {}", e.code, e.msg)).unwrap_or_else(|| "error".into()))
|
||
}
|
||
_ => Err("unexpected reply".into()),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn open(state: BridgeState<'_>, path: String) -> Result<Value, String> {
|
||
data_of(state.request(Cmd::Open { path }).await.map_err(|e| e.to_string())?)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn new_surface(
|
||
state: BridgeState<'_>,
|
||
workspace_id: String,
|
||
command: Option<String>,
|
||
args: Vec<String>,
|
||
cols: u16,
|
||
rows: u16,
|
||
) -> Result<Value, String> {
|
||
let cmd = Cmd::NewSurface {
|
||
workspace_id: spacesh_proto::WorkspaceId(workspace_id),
|
||
command,
|
||
args,
|
||
cols,
|
||
rows,
|
||
};
|
||
data_of(state.request(cmd).await.map_err(|e| e.to_string())?)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn input(state: BridgeState<'_>, surface_id: String, data: Vec<u8>) -> Result<Value, String> {
|
||
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
|
||
data_of(state.request(Cmd::Input { surface_id: SurfaceId(surface_id), bytes: b64 }).await.map_err(|e| e.to_string())?)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn resize(state: BridgeState<'_>, surface_id: String, cols: u16, rows: u16) -> Result<Value, String> {
|
||
data_of(state.request(Cmd::Resize { surface_id: SurfaceId(surface_id), cols, rows }).await.map_err(|e| e.to_string())?)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn attach(state: BridgeState<'_>, surface_id: String, on_output: Channel<Vec<u8>>) -> Result<Value, String> {
|
||
state.register_output(surface_id.clone(), on_output).await;
|
||
data_of(state.request(Cmd::Attach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn detach(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
|
||
state.unregister_output(&surface_id).await;
|
||
data_of(state.request(Cmd::Detach { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn status(state: BridgeState<'_>) -> Result<Value, String> {
|
||
data_of(state.request(Cmd::Status).await.map_err(|e| e.to_string())?)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn close_surface(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
|
||
data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire the bridge into the builder**
|
||
|
||
`app/src-tauri/src/lib.rs`:
|
||
```rust
|
||
mod bridge;
|
||
|
||
use tauri::Manager;
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
tauri::Builder::default()
|
||
.setup(|app| {
|
||
let handle = app.handle().clone();
|
||
// Connect the bridge on a tokio runtime, then manage it.
|
||
tauri::async_runtime::block_on(async move {
|
||
let bridge = bridge::Bridge::connect(handle.clone())
|
||
.await
|
||
.expect("failed to connect to spaceshd");
|
||
handle.manage(bridge);
|
||
});
|
||
Ok(())
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
bridge::open,
|
||
bridge::new_surface,
|
||
bridge::input,
|
||
bridge::resize,
|
||
bridge::attach,
|
||
bridge::detach,
|
||
bridge::status,
|
||
bridge::close_surface,
|
||
])
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running spacesh");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Frontend socket bridge**
|
||
|
||
`app/src/socketBridge.ts`:
|
||
```ts
|
||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||
import { listen } from "@tauri-apps/api/event";
|
||
|
||
export interface WorkspaceStatus {
|
||
workspace_id: string;
|
||
path: string;
|
||
surfaces: string[];
|
||
}
|
||
|
||
export async function openWorkspace(path: string): Promise<string> {
|
||
const data = await invoke<{ workspace_id: string }>("open", { path });
|
||
return data.workspace_id;
|
||
}
|
||
|
||
export async function newSurface(
|
||
workspaceId: string,
|
||
cols: number,
|
||
rows: number,
|
||
command?: string,
|
||
args: string[] = []
|
||
): Promise<string> {
|
||
const data = await invoke<{ surface_id: string }>("new_surface", {
|
||
workspaceId,
|
||
command: command ?? null,
|
||
args,
|
||
cols,
|
||
rows,
|
||
});
|
||
return data.surface_id;
|
||
}
|
||
|
||
export async function sendInput(surfaceId: string, data: Uint8Array): Promise<void> {
|
||
await invoke("input", { surfaceId, data: Array.from(data) });
|
||
}
|
||
|
||
export async function resizeSurface(surfaceId: string, cols: number, rows: number): Promise<void> {
|
||
await invoke("resize", { surfaceId, cols, rows });
|
||
}
|
||
|
||
export interface AttachResult {
|
||
snapshot: string;
|
||
cols: number;
|
||
rows: number;
|
||
}
|
||
|
||
export async function attachSurface(
|
||
surfaceId: string,
|
||
onOutput: (bytes: Uint8Array) => void
|
||
): Promise<AttachResult> {
|
||
const channel = new Channel<number[]>();
|
||
channel.onmessage = (msg) => onOutput(new Uint8Array(msg));
|
||
return await invoke<AttachResult>("attach", { surfaceId, onOutput: channel });
|
||
}
|
||
|
||
export async function detachSurface(surfaceId: string): Promise<void> {
|
||
await invoke("detach", { surfaceId });
|
||
}
|
||
|
||
export async function getStatus(): Promise<WorkspaceStatus[]> {
|
||
const data = await invoke<{ workspaces: WorkspaceStatus[] }>("status");
|
||
return data.workspaces;
|
||
}
|
||
|
||
export type DaemonEvt =
|
||
| { evt: "exit"; data: { surface_id: string; code: number } }
|
||
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
||
| { evt: "surface_closed"; data: { surface_id: string } };
|
||
|
||
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
|
||
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: TerminalView**
|
||
|
||
`app/src/TerminalView.tsx`:
|
||
```tsx
|
||
import { useEffect, useRef } from "react";
|
||
import { Terminal } from "@xterm/xterm";
|
||
import { WebglAddon } from "@xterm/addon-webgl";
|
||
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
|
||
|
||
const decoder = new TextDecoder();
|
||
const encoder = new TextEncoder();
|
||
|
||
export function TerminalView({ surfaceId }: { surfaceId: string }) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!ref.current) return;
|
||
const term = new Terminal({ fontFamily: "monospace", fontSize: 13, convertEol: false });
|
||
try {
|
||
term.loadAddon(new WebglAddon());
|
||
} catch {
|
||
// webgl unavailable → fall back to canvas/dom renderer silently
|
||
}
|
||
term.open(ref.current);
|
||
|
||
// Input → daemon.
|
||
const inputDisposable = term.onData((data) => {
|
||
void sendInput(surfaceId, encoder.encode(data));
|
||
});
|
||
|
||
let disposed = false;
|
||
|
||
// Attach: fresh xterm instance, write snapshot, then stream live output.
|
||
void attachSurface(surfaceId, (bytes) => {
|
||
if (!disposed) term.write(decoder.decode(bytes));
|
||
}).then((res) => {
|
||
if (disposed) return;
|
||
if (res.snapshot) term.write(res.snapshot);
|
||
if (res.cols && res.rows) {
|
||
term.resize(res.cols, res.rows);
|
||
void resizeSurface(surfaceId, res.cols, res.rows);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
disposed = true;
|
||
inputDisposable.dispose();
|
||
void detachSurface(surfaceId);
|
||
term.dispose();
|
||
};
|
||
}, [surfaceId]);
|
||
|
||
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: SurfaceList**
|
||
|
||
`app/src/SurfaceList.tsx`:
|
||
```tsx
|
||
export function SurfaceList({
|
||
surfaces,
|
||
active,
|
||
onSelect,
|
||
}: {
|
||
surfaces: string[];
|
||
active: string | null;
|
||
onSelect: (id: string) => void;
|
||
}) {
|
||
return (
|
||
<div style={{ width: 160, background: "#1a1a1a", color: "#ccc", padding: 8 }}>
|
||
<div style={{ opacity: 0.6, fontSize: 11, marginBottom: 8 }}>SURFACES</div>
|
||
{surfaces.map((id) => (
|
||
<div
|
||
key={id}
|
||
onClick={() => onSelect(id)}
|
||
style={{
|
||
padding: "4px 6px",
|
||
cursor: "pointer",
|
||
borderRadius: 4,
|
||
background: id === active ? "#333" : "transparent",
|
||
fontFamily: "monospace",
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{id}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: App wiring**
|
||
|
||
`app/src/App.tsx`:
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { TerminalView } from "./TerminalView";
|
||
import { SurfaceList } from "./SurfaceList";
|
||
import { openWorkspace, newSurface, getStatus, onDaemonEvent } from "./socketBridge";
|
||
|
||
export function App() {
|
||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||
const [active, setActive] = useState<string | null>(null);
|
||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
void (async () => {
|
||
const ws = await getStatus();
|
||
const flat = ws.flatMap((w) => w.surfaces);
|
||
setSurfaces(flat);
|
||
if (flat.length) setActive(flat[0]);
|
||
})();
|
||
|
||
const unlisten = onDaemonEvent((evt) => {
|
||
if (evt.evt === "surface_created") {
|
||
setSurfaces((s) => [...s, evt.data.surface_id]);
|
||
} else if (evt.evt === "surface_closed" || evt.evt === "exit") {
|
||
// exit leaves the surface visible; surface_closed removes it.
|
||
if (evt.evt === "surface_closed") {
|
||
setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id));
|
||
}
|
||
}
|
||
});
|
||
return () => {
|
||
void unlisten.then((f) => f());
|
||
};
|
||
}, []);
|
||
|
||
async function handleNewSurface() {
|
||
let ws = workspaceId;
|
||
if (!ws) {
|
||
ws = await openWorkspace(".");
|
||
setWorkspaceId(ws);
|
||
}
|
||
const id = await newSurface(ws, 80, 24);
|
||
setActive(id);
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: "flex", height: "100vh", background: "#000" }}>
|
||
<div style={{ display: "flex", flexDirection: "column", width: 160 }}>
|
||
<button onClick={handleNewSurface} style={{ margin: 8 }}>
|
||
+ surface
|
||
</button>
|
||
<SurfaceList surfaces={surfaces} active={active} onSelect={setActive} />
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
{active ? <TerminalView key={active} surfaceId={active} /> : <div style={{ color: "#666", padding: 16 }}>no surface</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`app/src/main.tsx`:
|
||
```tsx
|
||
import React from "react";
|
||
import ReactDOM from "react-dom/client";
|
||
import { App } from "./App";
|
||
import "@xterm/xterm/css/xterm.css";
|
||
|
||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||
<React.StrictMode>
|
||
<App />
|
||
</React.StrictMode>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 7: Manual end-to-end verification (M0)**
|
||
|
||
Run:
|
||
```bash
|
||
cd app && npm run tauri dev
|
||
```
|
||
Steps: click "+ surface" → a shell appears in the terminal → type `echo hi` → see output. This proves bytes fly GUI↔daemon↔PTY.
|
||
Expected: interactive shell works.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/
|
||
git commit -m "feat(app): UDS bridge (channel/invoke/emit) + xterm.js terminal, M0 e2e works"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5 — spacesh-core (grid + snapshot)
|
||
|
||
### Task 11: GridSurface — feed bytes into an alacritty Term
|
||
|
||
**Files:**
|
||
- Create: `crates/spacesh-core/src/grid.rs`
|
||
- Modify: `crates/spacesh-core/src/lib.rs` (enable `mod grid;`)
|
||
- Test: inline `#[cfg(test)]` in `grid.rs`
|
||
|
||
> **API pin:** `alacritty_terminal = "0.25"`. The grid/parser API is version-sensitive. The code below targets 0.25 (`Term::new(config, &dims, listener)`, `vte::ansi::Processor::advance(&mut term, bytes)`, `term.grid().display_iter()`, `Cell { c, fg, bg, flags }`, `term.grid().cursor.point`). If `cargo build` reports a signature mismatch, run `cargo doc -p alacritty_terminal --open` and adjust the three call sites (constructor, `advance`, cell iteration) — the wrapper isolates them.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spacesh-core/src/grid.rs`:
|
||
```rust
|
||
use alacritty_terminal::event::VoidListener;
|
||
use alacritty_terminal::grid::Dimensions;
|
||
use alacritty_terminal::index::{Column, Line, Point};
|
||
use alacritty_terminal::term::{Config, Term};
|
||
use alacritty_terminal::vte::ansi::Processor;
|
||
|
||
/// Fixed-size terminal dimensions for the daemon-side grid.
|
||
#[derive(Clone, Copy)]
|
||
pub struct GridSize {
|
||
pub cols: usize,
|
||
pub lines: usize,
|
||
}
|
||
|
||
impl Dimensions for GridSize {
|
||
fn total_lines(&self) -> usize {
|
||
self.lines
|
||
}
|
||
fn screen_lines(&self) -> usize {
|
||
self.lines
|
||
}
|
||
fn columns(&self) -> usize {
|
||
self.cols
|
||
}
|
||
}
|
||
|
||
/// Owns an alacritty terminal model and feeds raw PTY bytes into it.
|
||
pub struct GridSurface {
|
||
term: Term<VoidListener>,
|
||
parser: Processor,
|
||
size: GridSize,
|
||
}
|
||
|
||
impl GridSurface {
|
||
pub fn new(cols: u16, rows: u16) -> Self {
|
||
let size = GridSize { cols: cols as usize, lines: rows as usize };
|
||
let term = Term::new(Config::default(), &size, VoidListener);
|
||
Self { term, parser: Processor::new(), size }
|
||
}
|
||
|
||
pub fn feed(&mut self, bytes: &[u8]) {
|
||
self.parser.advance(&mut self.term, bytes);
|
||
}
|
||
|
||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||
self.size = GridSize { cols: cols as usize, lines: rows as usize };
|
||
self.term.resize(self.size);
|
||
}
|
||
|
||
pub fn size(&self) -> GridSize {
|
||
self.size
|
||
}
|
||
|
||
/// Read the visible character at (line, col) — used by tests and the snapshot writer.
|
||
pub fn char_at(&self, line: usize, col: usize) -> char {
|
||
let point = Point::new(Line(line as i32), Column(col));
|
||
self.term.grid()[point].c
|
||
}
|
||
|
||
pub fn term(&self) -> &Term<VoidListener> {
|
||
&self.term
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn feeding_plain_text_lands_in_the_grid() {
|
||
let mut g = GridSurface::new(20, 5);
|
||
g.feed(b"hello");
|
||
assert_eq!(g.char_at(0, 0), 'h');
|
||
assert_eq!(g.char_at(0, 4), 'o');
|
||
}
|
||
|
||
#[test]
|
||
fn carriage_return_and_newline_move_the_cursor() {
|
||
let mut g = GridSurface::new(20, 5);
|
||
g.feed(b"ab\r\ncd");
|
||
assert_eq!(g.char_at(0, 0), 'a');
|
||
assert_eq!(g.char_at(1, 0), 'c');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Enable the module**
|
||
|
||
`crates/spacesh-core/src/lib.rs`:
|
||
```rust
|
||
pub mod grid;
|
||
pub mod snapshot;
|
||
|
||
pub use grid::GridSurface;
|
||
pub use snapshot::Snapshot;
|
||
```
|
||
|
||
(`snapshot` is added in Task 12; if building Task 11 alone, temporarily comment the `snapshot` lines and restore them in Task 12.)
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
Run: `cargo test -p spacesh-core grid`
|
||
Expected: PASS (2 tests). Fix the three pinned call sites if the API differs (see API pin note).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spacesh-core/src/grid.rs crates/spacesh-core/src/lib.rs
|
||
git commit -m "feat(core): GridSurface feeding PTY bytes into alacritty term"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: snapshot_ansi — serialize the grid to an ANSI dump
|
||
|
||
**Files:**
|
||
- Create: `crates/spacesh-core/src/snapshot.rs`
|
||
- Test: inline `#[cfg(test)]` in `snapshot.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spacesh-core/src/snapshot.rs`:
|
||
```rust
|
||
use serde::Serialize;
|
||
use alacritty_terminal::index::Point;
|
||
use alacritty_terminal::term::cell::Flags;
|
||
use alacritty_terminal::vte::ansi::Color;
|
||
use crate::grid::GridSurface;
|
||
|
||
/// Serializable snapshot returned by `attach`.
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Snapshot {
|
||
/// ANSI byte dump suitable for `xterm.write()`.
|
||
pub ansi: String,
|
||
pub cols: u16,
|
||
pub rows: u16,
|
||
/// 1-based cursor position.
|
||
pub cursor_row: u16,
|
||
pub cursor_col: u16,
|
||
}
|
||
|
||
fn sgr_for_color(c: Color, foreground: bool) -> String {
|
||
let base = if foreground { 38 } else { 48 };
|
||
match c {
|
||
Color::Named(named) => {
|
||
// Map common named colors to SGR; default fg/bg reset for the rest.
|
||
use alacritty_terminal::vte::ansi::NamedColor;
|
||
let code = match named {
|
||
NamedColor::Black => Some(if foreground { 30 } else { 40 }),
|
||
NamedColor::Red => Some(if foreground { 31 } else { 41 }),
|
||
NamedColor::Green => Some(if foreground { 32 } else { 42 }),
|
||
NamedColor::Yellow => Some(if foreground { 33 } else { 43 }),
|
||
NamedColor::Blue => Some(if foreground { 34 } else { 44 }),
|
||
NamedColor::Magenta => Some(if foreground { 35 } else { 45 }),
|
||
NamedColor::Cyan => Some(if foreground { 36 } else { 46 }),
|
||
NamedColor::White => Some(if foreground { 37 } else { 47 }),
|
||
NamedColor::BrightBlack => Some(if foreground { 90 } else { 100 }),
|
||
NamedColor::BrightRed => Some(if foreground { 91 } else { 101 }),
|
||
NamedColor::BrightGreen => Some(if foreground { 92 } else { 102 }),
|
||
NamedColor::BrightYellow => Some(if foreground { 93 } else { 103 }),
|
||
NamedColor::BrightBlue => Some(if foreground { 94 } else { 104 }),
|
||
NamedColor::BrightMagenta => Some(if foreground { 95 } else { 105 }),
|
||
NamedColor::BrightCyan => Some(if foreground { 96 } else { 106 }),
|
||
NamedColor::BrightWhite => Some(if foreground { 97 } else { 107 }),
|
||
_ => None, // Foreground/Background/Cursor etc. → use reset.
|
||
};
|
||
match code {
|
||
Some(n) => format!("{n}"),
|
||
None => format!("{}", if foreground { 39 } else { 49 }),
|
||
}
|
||
}
|
||
Color::Indexed(i) => format!("{base};5;{i}"),
|
||
Color::Spec(rgb) => format!("{base};2;{};{};{}", rgb.r, rgb.g, rgb.b),
|
||
}
|
||
}
|
||
|
||
/// Serialize the visible grid into an ANSI dump.
|
||
pub fn snapshot_ansi(g: &GridSurface) -> Snapshot {
|
||
let size = g.size();
|
||
let term = g.term();
|
||
let grid = term.grid();
|
||
|
||
let mut out = String::new();
|
||
out.push_str("\x1b[2J\x1b[H"); // clear + home
|
||
|
||
let cols = size.cols;
|
||
let lines = size.lines;
|
||
|
||
// Track the last emitted attributes to avoid redundant SGR sequences.
|
||
let mut last: Option<(Color, Color, Flags)> = None;
|
||
|
||
for line in 0..lines {
|
||
for col in 0..cols {
|
||
let point = Point::new(alacritty_terminal::index::Line(line as i32), alacritty_terminal::index::Column(col));
|
||
let cell = &grid[point];
|
||
let cur = (cell.fg, cell.bg, cell.flags);
|
||
if last != Some(cur) {
|
||
let mut codes: Vec<String> = vec!["0".into()]; // reset, then re-apply
|
||
if cell.flags.contains(Flags::BOLD) { codes.push("1".into()); }
|
||
if cell.flags.contains(Flags::DIM) { codes.push("2".into()); }
|
||
if cell.flags.contains(Flags::ITALIC) { codes.push("3".into()); }
|
||
if cell.flags.contains(Flags::UNDERLINE) { codes.push("4".into()); }
|
||
if cell.flags.contains(Flags::INVERSE) { codes.push("7".into()); }
|
||
codes.push(sgr_for_color(cell.fg, true));
|
||
codes.push(sgr_for_color(cell.bg, false));
|
||
out.push_str(&format!("\x1b[{}m", codes.join(";")));
|
||
last = Some(cur);
|
||
}
|
||
out.push(cell.c);
|
||
}
|
||
out.push_str("\r\n");
|
||
}
|
||
out.push_str("\x1b[0m"); // reset attributes at end
|
||
|
||
let cursor = grid.cursor.point;
|
||
let cursor_row = (cursor.line.0 as i64 + 1).clamp(1, lines as i64) as u16;
|
||
let cursor_col = (cursor.column.0 as i64 + 1).clamp(1, cols as i64) as u16;
|
||
out.push_str(&format!("\x1b[{cursor_row};{cursor_col}H"));
|
||
|
||
Snapshot {
|
||
ansi: out,
|
||
cols: cols as u16,
|
||
rows: lines as u16,
|
||
cursor_row,
|
||
cursor_col,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn snapshot_contains_fed_text_and_is_deterministic() {
|
||
let mut g = GridSurface::new(10, 3);
|
||
g.feed(b"hi");
|
||
let a = snapshot_ansi(&g);
|
||
let b = snapshot_ansi(&g);
|
||
assert_eq!(a.ansi, b.ansi, "snapshot must be deterministic");
|
||
assert!(a.ansi.contains("hi"));
|
||
assert!(a.ansi.starts_with("\x1b[2J\x1b[H"));
|
||
assert_eq!(a.cols, 10);
|
||
assert_eq!(a.rows, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn cursor_is_one_based_after_input() {
|
||
let mut g = GridSurface::new(10, 3);
|
||
g.feed(b"abc");
|
||
let s = snapshot_ansi(&g);
|
||
// After 'abc' the cursor sits at column 4 (1-based) on row 1.
|
||
assert_eq!(s.cursor_row, 1);
|
||
assert_eq!(s.cursor_col, 4);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
Run: `cargo test -p spacesh-core`
|
||
Expected: PASS (grid + snapshot tests). If `Flags`, `Color`, or `NamedColor` paths differ in 0.25, adjust the imports per `cargo doc`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add crates/spacesh-core/src/snapshot.rs crates/spacesh-core/src/lib.rs
|
||
git commit -m "feat(core): deterministic ANSI snapshot of the grid for reattach repaint"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 6 — spaceshd (M1: grid + attach snapshot)
|
||
|
||
### Task 13: Integrate grid + coalescing into the surface actor; snapshot on attach
|
||
|
||
**Files:**
|
||
- Modify: `crates/spaceshd/src/surface.rs`
|
||
- Test: inline `#[cfg(test)]` additions in `surface.rs`
|
||
|
||
Changes: the actor now owns a `GridSurface`, feeds every output chunk into it, coalesces output before broadcasting (flush every ~6 ms or at 16 KiB), and answers a new `Snapshot` request by serializing the grid **in the same actor turn** as the subscription — guaranteeing the snapshot/stream ordering from the spec.
|
||
|
||
- [ ] **Step 1: Extend the message enum and actor**
|
||
|
||
Replace `crates/spaceshd/src/surface.rs` actor section with this (the test module from Task 5 stays; new tests appended in Step 2):
|
||
```rust
|
||
use spacesh_core::{snapshot::snapshot_ansi, GridSurface};
|
||
use spacesh_core::snapshot::Snapshot;
|
||
use spacesh_proto::{SurfaceId, WorkspaceId};
|
||
use spacesh_pty::PtyHandle;
|
||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||
use tokio::time::{Duration, Instant};
|
||
|
||
const BROADCAST_CAP: usize = 1024;
|
||
const FLUSH_INTERVAL: Duration = Duration::from_millis(6);
|
||
const FLUSH_BYTES: usize = 16 * 1024;
|
||
|
||
pub enum SurfaceMsg {
|
||
Input(Vec<u8>),
|
||
Resize { cols: u16, rows: u16 },
|
||
Attach { reply: oneshot::Sender<broadcast::Receiver<Vec<u8>>> },
|
||
/// Attach with snapshot: subscribe AND capture the grid in one actor turn.
|
||
AttachSnapshot { reply: oneshot::Sender<(Snapshot, broadcast::Receiver<Vec<u8>>)> },
|
||
Close,
|
||
}
|
||
|
||
pub struct SurfaceHandle {
|
||
pub id: SurfaceId,
|
||
pub workspace_id: WorkspaceId,
|
||
pub tx: mpsc::Sender<SurfaceMsg>,
|
||
}
|
||
|
||
pub fn spawn_surface(
|
||
id: SurfaceId,
|
||
workspace_id: WorkspaceId,
|
||
mut pty: PtyHandle,
|
||
cols: u16,
|
||
rows: u16,
|
||
exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>,
|
||
) -> SurfaceHandle {
|
||
let (tx, mut rx) = mpsc::channel::<SurfaceMsg>(64);
|
||
let (bcast, _) = broadcast::channel::<Vec<u8>>(BROADCAST_CAP);
|
||
let actor_id = id.clone();
|
||
|
||
tokio::spawn(async move {
|
||
let mut grid = GridSurface::new(cols, rows);
|
||
let mut pending: Vec<u8> = Vec::with_capacity(FLUSH_BYTES);
|
||
let mut flush_deadline: Option<Instant> = None;
|
||
|
||
// Helper closure can't borrow across awaits cleanly; inline the flush logic.
|
||
loop {
|
||
// Copy the deadline into an owned local so the timer future doesn't
|
||
// hold a borrow of `flush_deadline` across the select! (other arms mutate it).
|
||
let next_flush = flush_deadline;
|
||
let timer = async move {
|
||
match next_flush {
|
||
Some(d) => tokio::time::sleep_until(d).await,
|
||
None => std::future::pending::<()>().await,
|
||
}
|
||
};
|
||
|
||
tokio::select! {
|
||
msg = rx.recv() => {
|
||
match msg {
|
||
Some(SurfaceMsg::Input(bytes)) => { let _ = pty.write_input(&bytes); }
|
||
Some(SurfaceMsg::Resize { cols, rows }) => {
|
||
grid.resize(cols, rows);
|
||
let _ = pty.resize(cols, rows);
|
||
}
|
||
Some(SurfaceMsg::Attach { reply }) => { let _ = reply.send(bcast.subscribe()); }
|
||
Some(SurfaceMsg::AttachSnapshot { reply }) => {
|
||
// Flush pending into the grid first so the snapshot is current,
|
||
// but DO NOT broadcast here; subscribe before any further output.
|
||
if !pending.is_empty() {
|
||
grid.feed(&pending);
|
||
let _ = bcast.send(std::mem::take(&mut pending));
|
||
flush_deadline = None;
|
||
}
|
||
let sub = bcast.subscribe();
|
||
let snap = snapshot_ansi(&grid);
|
||
let _ = reply.send((snap, sub));
|
||
}
|
||
Some(SurfaceMsg::Close) | None => { pty.kill(); break; }
|
||
}
|
||
}
|
||
chunk = pty.output.recv() => {
|
||
match chunk {
|
||
Some(bytes) => {
|
||
pending.extend_from_slice(&bytes);
|
||
if flush_deadline.is_none() {
|
||
flush_deadline = Some(Instant::now() + FLUSH_INTERVAL);
|
||
}
|
||
if pending.len() >= FLUSH_BYTES {
|
||
grid.feed(&pending);
|
||
let _ = bcast.send(std::mem::take(&mut pending));
|
||
flush_deadline = None;
|
||
}
|
||
}
|
||
None => {
|
||
// Final flush on EOF.
|
||
if !pending.is_empty() {
|
||
grid.feed(&pending);
|
||
let _ = bcast.send(std::mem::take(&mut pending));
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
_ = timer => {
|
||
if !pending.is_empty() {
|
||
grid.feed(&pending);
|
||
let _ = bcast.send(std::mem::take(&mut pending));
|
||
}
|
||
flush_deadline = None;
|
||
}
|
||
}
|
||
}
|
||
let code = pty.wait();
|
||
let _ = exit_tx.send((actor_id, code));
|
||
});
|
||
|
||
SurfaceHandle { id, workspace_id, tx }
|
||
}
|
||
```
|
||
|
||
**Caller update:** `spawn_surface` now takes `cols, rows`. Update `crates/spaceshd/src/server.rs` `Cmd::NewSurface` to pass them:
|
||
```rust
|
||
let handle = spawn_surface(sid.clone(), workspace_id.clone(), pty, cols, rows, exit_tx.clone());
|
||
```
|
||
|
||
**Attach update (server):** replace the M0 `Cmd::Attach` arm in `server.rs` with a snapshot-backed one:
|
||
```rust
|
||
Cmd::Attach { surface_id } => {
|
||
if let Some(s) = reg.surface(&surface_id) {
|
||
let (reply_tx, reply_rx) = oneshot::channel();
|
||
if s.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() {
|
||
if let Ok((snap, _sub)) = reply_rx.await {
|
||
subs.entry(surface_id.clone()).or_default().push(client);
|
||
let _ = out.send(ok(id, serde_json::json!({
|
||
"snapshot": snap.ansi,
|
||
"cols": snap.cols,
|
||
"rows": snap.rows,
|
||
"cursor_row": snap.cursor_row,
|
||
"cursor_col": snap.cursor_col,
|
||
}))).await;
|
||
return;
|
||
}
|
||
}
|
||
let _ = out.send(err(id, "INTERNAL", "attach failed")).await;
|
||
} else {
|
||
let _ = out.send(err(id, "NOT_FOUND", "surface")).await;
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: the output bridge (Task 7's `spawn_output_bridge`) still subscribes via `SurfaceMsg::Attach` and pumps **all** broadcast traffic into the router; the router fans only to clients in `subs`. The `AttachSnapshot` subscription receiver `_sub` is dropped because the client receives output through the router/bridge path, not directly — the snapshot's role is the one-shot repaint, and the bridge guarantees subsequent output flows. This keeps a single fan-out path while preserving the ordering guarantee (snapshot taken in the same actor turn the bridge's broadcast continues from).
|
||
|
||
- [ ] **Step 2: Update Task 5 tests for the new signature and add the snapshot test**
|
||
|
||
In `surface.rs` tests, update both `spawn_surface(...)` calls to pass `80, 24`:
|
||
```rust
|
||
let handle = spawn_surface(SurfaceId("s_1".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);
|
||
```
|
||
```rust
|
||
let _handle = spawn_surface(SurfaceId("s_2".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);
|
||
```
|
||
|
||
Append a new test:
|
||
```rust
|
||
#[tokio::test]
|
||
async fn attach_snapshot_reflects_prior_output() {
|
||
let pty = PtyHandle::spawn(spec("printf SNAPME; sleep 0.5")).unwrap();
|
||
let (exit_tx, _exit_rx) = mpsc::unbounded_channel();
|
||
let handle = spawn_surface(SurfaceId("s_s".into()), WorkspaceId("w_1".into()), pty, 80, 24, exit_tx);
|
||
|
||
// Give the child time to write and the actor time to flush into the grid.
|
||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||
|
||
let (reply_tx, reply_rx) = oneshot::channel();
|
||
handle.tx.send(SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.unwrap();
|
||
let (snap, _sub) = reply_rx.await.unwrap();
|
||
assert!(snap.ansi.contains("SNAPME"), "snapshot: {:?}", snap.ansi);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
Run: `cargo test -p spaceshd`
|
||
Expected: PASS (surface + registry + lifecycle + server tests).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/surface.rs crates/spaceshd/src/server.rs
|
||
git commit -m "feat(daemon): grid feed + output coalescing + snapshot-on-attach (M1)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Reattach integration test (same screen after reconnect)
|
||
|
||
**Files:**
|
||
- Modify: `crates/spaceshd/src/server.rs` (append a test)
|
||
|
||
- [ ] **Step 1: Write the test**
|
||
|
||
Append to the `tests` module in `server.rs`:
|
||
```rust
|
||
#[tokio::test]
|
||
async fn reattach_returns_snapshot_with_prior_output() {
|
||
let dir = tempdir_path();
|
||
let sock = dir.join("sock");
|
||
let sock_for_task = sock.clone();
|
||
tokio::spawn(async move { let _ = serve(&sock_for_task).await; });
|
||
wait_for_socket(&sock).await;
|
||
|
||
// First client: open, new surface that prints a marker, attach, then disconnect.
|
||
let surface_id;
|
||
{
|
||
let mut s = UnixStream::connect(&sock).await.unwrap();
|
||
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
|
||
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
|
||
let r = req(&mut s, 2, Cmd::NewSurface {
|
||
workspace_id: spacesh_proto::WorkspaceId(ws),
|
||
command: Some("/bin/sh".into()),
|
||
args: vec!["-c".into(), "printf REPAINT_ME; sleep 2".into()],
|
||
cols: 80, rows: 24,
|
||
}).await;
|
||
surface_id = spacesh_proto::SurfaceId(res_data(&r)["surface_id"].as_str().unwrap().to_string());
|
||
// Give the actor time to flush output into the grid.
|
||
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||
// disconnect by dropping `s`
|
||
}
|
||
|
||
// Second client: attach to the same surface, expect snapshot to contain the marker.
|
||
let mut s2 = UnixStream::connect(&sock).await.unwrap();
|
||
let r = req(&mut s2, 1, Cmd::Attach { surface_id: surface_id.clone() }).await;
|
||
let snap = res_data(&r)["snapshot"].as_str().unwrap();
|
||
assert!(snap.contains("REPAINT_ME"), "snapshot was: {snap:?}");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
Run: `cargo test -p spaceshd server`
|
||
Expected: PASS (3 server tests including reattach).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/server.rs
|
||
git commit -m "test(daemon): reattach after disconnect repaints prior output from snapshot"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 7 — app (M1 reattach in the GUI)
|
||
|
||
### Task 15: Reattach flow + reconnect handling in the bridge
|
||
|
||
**Files:**
|
||
- Modify: `app/src-tauri/src/bridge.rs` (reconnect on EOF)
|
||
- Modify: `app/src/TerminalView.tsx` (already writes snapshot — verify cursor handling)
|
||
|
||
The TerminalView from Task 10 already: creates a fresh xterm per surface, writes `res.snapshot`, then streams. The remaining M1 piece is making the **bridge survive a daemon restart** is out of scope (daemon outlives GUI, not vice-versa) — but the GUI must survive its own reload, which it does because attach re-runs on mount. This task hardens reattach and adds a reconnect guard so a transient socket drop doesn't wedge the bridge.
|
||
|
||
- [ ] **Step 1: Add a reconnect note + guard (no behavior change to the happy path)**
|
||
|
||
In `app/src-tauri/src/bridge.rs`, change `spawn_reader`'s terminal branch to emit a disconnect event so the UI can re-attach:
|
||
```rust
|
||
Ok(None) | Err(_) => {
|
||
let _ = app.emit("spacesh:disconnected", ());
|
||
break;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Re-attach the active surface on reconnect in the front**
|
||
|
||
In `app/src/App.tsx`, add inside the `useEffect`:
|
||
```tsx
|
||
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => {
|
||
// Force a remount of the active TerminalView by toggling the key.
|
||
setActive((cur) => cur);
|
||
void getStatus().then((ws) => {
|
||
const flat = ws.flatMap((w) => w.surfaces);
|
||
setSurfaces(flat);
|
||
});
|
||
});
|
||
return () => {
|
||
void unlisten.then((f) => f());
|
||
void reconnect.then((f) => f());
|
||
};
|
||
```
|
||
|
||
Add to `app/src/socketBridge.ts`:
|
||
```ts
|
||
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
|
||
return listen(name, () => handler());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Manual verification (M1 killer feature)**
|
||
|
||
Run `cd app && npm run tauri dev`. Steps:
|
||
1. Click "+ surface", run a long command like `top`.
|
||
2. Close the app window (the daemon keeps running — verify `ls ~/.spacesh/sock` still works and `pgrep spaceshd` shows it alive).
|
||
3. Relaunch `npm run tauri dev`.
|
||
4. The surface is still listed; selecting it repaints the screen from the snapshot and `top` is still updating.
|
||
|
||
Expected: agent (process) survived the GUI; screen restored on reattach.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add app/
|
||
git commit -m "feat(app): reattach repaint + disconnect guard (M1)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 8 — launchd
|
||
|
||
### Task 16: launchd user-agent install
|
||
|
||
**Files:**
|
||
- Modify: `crates/spaceshd/src/launchd.rs` (replace the Task 8 stub)
|
||
- Test: inline `#[cfg(test)]` in `launchd.rs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`crates/spaceshd/src/launchd.rs`:
|
||
```rust
|
||
use anyhow::{Context, Result};
|
||
use std::path::PathBuf;
|
||
|
||
const LABEL: &str = "xyz.spacesh.daemon";
|
||
|
||
fn plist_path() -> Result<PathBuf> {
|
||
let home = dirs::home_dir().context("no home")?;
|
||
Ok(home.join("Library").join("LaunchAgents").join(format!("{LABEL}.plist")))
|
||
}
|
||
|
||
/// Render the launchd plist. `run_at_load` defaults to false in this slice.
|
||
pub fn render_plist(exe: &str, run_at_load: bool) -> String {
|
||
format!(
|
||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>Label</key>
|
||
<string>{LABEL}</string>
|
||
<key>ProgramArguments</key>
|
||
<array>
|
||
<string>{exe}</string>
|
||
</array>
|
||
<key>KeepAlive</key>
|
||
<true/>
|
||
<key>RunAtLoad</key>
|
||
<{run_at_load}/>
|
||
</dict>
|
||
</plist>
|
||
"#,
|
||
run_at_load = if run_at_load { "true" } else { "false" }
|
||
)
|
||
}
|
||
|
||
pub fn install_agent() -> Result<()> {
|
||
let exe = std::env::current_exe()?;
|
||
let plist = render_plist(&exe.to_string_lossy(), false);
|
||
let path = plist_path()?;
|
||
if let Some(parent) = path.parent() {
|
||
std::fs::create_dir_all(parent)?;
|
||
}
|
||
std::fs::write(&path, plist)?;
|
||
// Load it (best-effort; ignore "already loaded").
|
||
let _ = std::process::Command::new("launchctl")
|
||
.arg("load")
|
||
.arg(&path)
|
||
.status();
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn plist_has_label_keepalive_and_exe() {
|
||
let p = render_plist("/usr/local/bin/spaceshd", false);
|
||
assert!(p.contains("xyz.spacesh.daemon"));
|
||
assert!(p.contains("/usr/local/bin/spaceshd"));
|
||
assert!(p.contains("<key>KeepAlive</key>\n <true/>"));
|
||
assert!(p.contains("<key>RunAtLoad</key>\n <false/>"));
|
||
}
|
||
|
||
#[test]
|
||
fn run_at_load_toggles() {
|
||
assert!(render_plist("x", true).contains("<key>RunAtLoad</key>\n <true/>"));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests**
|
||
|
||
Run: `cargo test -p spaceshd launchd`
|
||
Expected: PASS (2 tests). (`install_agent` itself touches the real filesystem/launchctl — exercised manually, not in tests.)
|
||
|
||
- [ ] **Step 3: Manual verification**
|
||
|
||
Run:
|
||
```bash
|
||
cargo build -p spaceshd
|
||
./target/debug/spaceshd install-agent
|
||
launchctl list | grep spacesh # shows the agent
|
||
```
|
||
Expected: the agent is registered; killing `spaceshd` (`pkill spaceshd`) results in launchd respawning it.
|
||
|
||
Cleanup:
|
||
```bash
|
||
launchctl unload ~/Library/LaunchAgents/xyz.spacesh.daemon.plist
|
||
rm ~/Library/LaunchAgents/xyz.spacesh.daemon.plist
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/launchd.rs
|
||
git commit -m "feat(daemon): launchd user-agent install with KeepAlive"
|
||
```
|
||
|
||
---
|
||
|
||
## Definition of Done (slice acceptance)
|
||
|
||
Run the full suite and the manual checks:
|
||
|
||
- [ ] `cargo test` — all crate tests pass.
|
||
- [ ] `cargo build` — workspace + app build clean.
|
||
- [ ] **M0:** in the app, create a surface, type into it, see live output. Bytes fly GUI↔daemon↔PTY.
|
||
- [ ] **M1:** start a long-running process in a surface, kill the GUI, confirm `spaceshd` (and the child) stay alive, relaunch the GUI, reselect the surface, confirm the screen repaints from the snapshot and the process is still live.
|
||
- [ ] **launchd:** `spaceshd install-agent` registers the KeepAlive agent; `pkill spaceshd` triggers a respawn.
|
||
|
||
---
|
||
|
||
## Notes for the implementer
|
||
|
||
- **alacritty_terminal API drift:** Tasks 11–12 are the only place the crate's internal API is touched. If 0.25's signatures differ from the pinned code, fix the three isolated call sites (constructor, `Processor::advance`, cell/cursor access) — everything else depends on the `GridSurface`/`Snapshot` interface, not the crate.
|
||
- **Single fan-out path:** all output to clients flows daemon broadcast → `spawn_output_bridge` → router → per-client socket. The `AttachSnapshot` receiver is intentionally dropped; the snapshot is a one-shot repaint and the bridge carries the live stream. Do not add a second direct path or you will double-render.
|
||
- **Coalescing budget:** `FLUSH_INTERVAL = 6ms`, `FLUSH_BYTES = 16KiB` match the spec's §6.2 budget. Tune only with a profiler against a real build log.
|
||
- **Out of this slice:** statuses/hooks (M3), split tree + disk persistence (M2), CLI (M4), notifications/zoom/diff (M5), remote (M6). Do not pull them in.
|
||
```
|