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:
Generated
+1068
File diff suppressed because it is too large
Load Diff
+27
@@ -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
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
Generated
+1978
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+4717
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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 |
@@ -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())?)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
spacesh_app_lib::run();
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%" }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "spacesh-core"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
alacritty_terminal.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod grid;
|
||||
pub mod snapshot;
|
||||
|
||||
pub use grid::GridSurface;
|
||||
pub use snapshot::Snapshot;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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/>"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user