feat(app): UDS bridge (channel/invoke/emit) + xterm.js terminal, M0 e2e works
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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())?)
|
||||||
|
}
|
||||||
@@ -1,6 +1,31 @@
|
|||||||
|
mod bridge;
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running spacesh");
|
.expect("error while running spacesh");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%" }} />;
|
||||||
|
}
|
||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<div style={{ color: "#ddd", fontFamily: "monospace", padding: 16 }}>
|
<App />
|
||||||
spacesh — scaffold OK
|
|
||||||
</div>
|
|
||||||
</React.StrictMode>
|
</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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user