Merge spacesh M0+M1: daemon, PTY, grid/snapshot, Tauri app

Vertical slice (bytes GUI↔daemon↔PTY over UDS) + persistence/reattach
(daemon outlives GUI; snapshot repaint). 4 crates + Tauri 2 app.
26 tests green and non-flaky.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:41:23 +07:00
48 changed files with 14532 additions and 13 deletions
Generated
+1068
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[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"
+6 -6
View File
@@ -6630,8 +6630,8 @@
{
"type": "frame",
"id": "hij6M",
"x": 4560,
"y": 0,
"x": 0,
"y": -1169,
"name": "spacesh — Sign In",
"clip": true,
"width": 1440,
@@ -7109,8 +7109,8 @@
{
"type": "frame",
"id": "yZwzo",
"x": 6080,
"y": 0,
"x": 1520,
"y": -1169,
"name": "spacesh — Sign Up",
"clip": true,
"width": 1440,
@@ -7634,8 +7634,8 @@
{
"type": "frame",
"id": "uIvRx",
"x": 7600,
"y": 0,
"x": 3040,
"y": -1169,
"name": "spacesh — Account Settings",
"clip": true,
"width": 1440,
@@ -2702,13 +2702,15 @@ pub fn spawn_surface(
}
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;
}
// Subscribe + snapshot ONLY — do not touch `pending`.
// This arm is atomic within the single actor (no await, no flush
// can interleave), so subscribing before snapshotting guarantees the
// new receiver gets exactly the output emitted AFTER this snapshot.
// Any accumulated `pending` (not yet fed to the grid) is left alone:
// the normal 6ms/16KiB flush path delivers it to ALL subscribers —
// including this new one — exactly once, and it is NOT in the snapshot.
// No gap, no double-render. (Broadcasting pending here would re-send
// already-snapshotted bytes to the new client via the bridge path.)
let sub = bcast.subscribe();
let snap = snapshot_ansi(&grid);
let _ = reply.send((snap, sub));
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>spacesh</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1978
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"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"
}
}
+4717
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[workspace]
[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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema/capability.json",
"identifier": "default",
"description": "Default capability for spacesh app",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:default",
"core:app:default",
"core:resources:default",
"core:menu:default",
"core:tray:default"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capability for spacesh app","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

+195
View File
@@ -0,0 +1,195 @@
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(_) => {
let _ = app.emit("spacesh:disconnected", ());
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())?)
}
+31
View File
@@ -0,0 +1,31 @@
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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
spacesh_app_lib::run();
}
+16
View File
@@ -0,0 +1,16 @@
{
"$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 }
}
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { TerminalView } from "./TerminalView";
import { SurfaceList } from "./SurfaceList";
import { openWorkspace, newSurface, getStatus, onDaemonEvent, onDaemonRawEvent } 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));
}
}
});
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());
};
}, []);
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>
);
}
+31
View File
@@ -0,0 +1,31 @@
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>
);
}
+50
View File
@@ -0,0 +1,50 @@
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%" }} />;
}
+10
View File
@@ -0,0 +1,10 @@
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>
);
+75
View File
@@ -0,0 +1,75 @@
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));
}
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
return listen(name, () => handler());
}
+14
View File
@@ -0,0 +1,14 @@
{
"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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: { port: 1420, strictPort: true },
});
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "spacesh-core"
edition.workspace = true
version.workspace = true
[dependencies]
alacritty_terminal.workspace = true
serde.workspace = true
+83
View File
@@ -0,0 +1,83 @@
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');
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod grid;
pub mod snapshot;
pub use grid::GridSurface;
pub use snapshot::Snapshot;
+132
View File
@@ -0,0 +1,132 @@
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);
}
}
+12
View File
@@ -0,0 +1,12 @@
[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
+80
View File
@@ -0,0 +1,80 @@
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());
}
}
+18
View File
@@ -0,0 +1,18 @@
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)
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod codec;
pub mod ids;
pub mod message;
pub use ids::{SurfaceId, WorkspaceId};
pub use message::{Cmd, Envelope, ErrorBody, Evt};
+115
View File
@@ -0,0 +1,115 @@
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 },
}
#[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"),
}
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "spacesh-pty"
edition.workspace = true
version.workspace = true
[dependencies]
portable-pty.workspace = true
tokio.workspace = true
bytes.workspace = true
anyhow.workspace = true
+148
View File
@@ -0,0 +1,148 @@
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:?}");
}
}
+24
View File
@@ -0,0 +1,24 @@
[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
+68
View File
@@ -0,0 +1,68 @@
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/>"));
}
}
+70
View File
@@ -0,0 +1,70 @@
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);
}
}
+53
View File
@@ -0,0 +1,53 @@
mod launchd;
mod lifecycle;
mod registry;
mod server;
mod surface;
use anyhow::Result;
/// Test-only support shared across the crate's test modules.
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, MutexGuard};
/// Process-wide serialization lock for the heavy socket/PTY integration tests.
/// These bind sockets and spawn real PTYs/processes; running several at once on a
/// many-core box starves each other's tasks and trips timing assumptions. Unit
/// tests stay parallel; only guarded integration tests serialize on this lock.
static SERIAL: Mutex<()> = Mutex::new(());
/// Acquire the serial lock for the duration of a test. Poison-tolerant so one
/// panicking test does not cascade-fail the rest.
pub(crate) fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
}
}
#[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
}
+105
View File
@@ -0,0 +1,105 @@
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);
}
}
+439
View File
@@ -0,0 +1,439 @@
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, cols, rows, 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 } => {
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;
}
}
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(flavor = "multi_thread", worker_threads = 2)]
async fn open_new_surface_attach_streams_output() {
let _serial = crate::test_support::serial();
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(6);
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(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_surface_returns_not_found() {
let _serial = crate::test_support::serial();
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..300 {
if UnixStream::connect(sock).await.is_ok() { return; }
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
}
panic!("socket never came up");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reattach_returns_snapshot_with_prior_output() {
let _serial = crate::test_support::serial();
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.
// Re-verify the socket is still up before connecting (handles any scheduling jitter).
wait_for_socket(&sock).await;
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:?}");
}
}
+184
View File
@@ -0,0 +1,184 @@
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 }) => {
// Subscribe-then-snapshot is atomic within this actor turn (no await,
// no flush can interleave). Any not-yet-flushed `pending` stays in
// `pending` and is delivered to ALL subscribers — including this new
// one — exactly once by the normal 6ms/16KiB flush path. It is NOT in
// this snapshot. Broadcasting here would double-render on reattach.
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 }
}
#[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(flavor = "multi_thread", worker_threads = 2)]
async fn attach_receives_output() {
let _serial = crate::test_support::serial();
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, 80, 24, 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(flavor = "multi_thread", worker_threads = 2)]
async fn exit_is_reported() {
let _serial = crate::test_support::serial();
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, 80, 24, 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);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attach_snapshot_reflects_prior_output() {
let _serial = crate::test_support::serial();
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);
}
}