diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs new file mode 100644 index 0000000..f131ff7 --- /dev/null +++ b/app/src-tauri/src/bridge.rs @@ -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, + /// Pending request id → reply slot. + pending: Arc>>>, + /// surface id → output channel into the webview. + out_channels: Arc>>>>, +} + +fn socket_path() -> Result { + Ok(dirs::home_dir().context("no home")?.join(".spacesh").join("sock")) +} + +async fn ensure_daemon(sock: &PathBuf) -> Result { + 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 { + let sock = socket_path()?; + let stream = ensure_daemon(&sock).await?; + let (read_half, write_half) = stream.into_split(); + + let (tx, rx) = mpsc::channel::(256); + let pending: Arc>>> = Arc::default(); + let out_channels: Arc>>>> = 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 { + 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>) { + 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) { + 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>>>, + out_channels: Arc>>>>, +) { + 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 { + 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 { + 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, + args: Vec, + cols: u16, + rows: u16, +) -> Result { + 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) -> Result { + 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 { + 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>) -> Result { + 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 { + 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 { + 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 { + data_of(state.request(Cmd::Close { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 15efadd..b99c245 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,6 +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"); } diff --git a/app/src/App.tsx b/app/src/App.tsx new file mode 100644 index 0000000..2a0bccc --- /dev/null +++ b/app/src/App.tsx @@ -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([]); + const [active, setActive] = useState(null); + const [workspaceId, setWorkspaceId] = useState(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 ( +
+
+ + +
+
+ {active ? :
no surface
} +
+
+ ); +} diff --git a/app/src/SurfaceList.tsx b/app/src/SurfaceList.tsx new file mode 100644 index 0000000..2bbe689 --- /dev/null +++ b/app/src/SurfaceList.tsx @@ -0,0 +1,31 @@ +export function SurfaceList({ + surfaces, + active, + onSelect, +}: { + surfaces: string[]; + active: string | null; + onSelect: (id: string) => void; +}) { + return ( +
+
SURFACES
+ {surfaces.map((id) => ( +
onSelect(id)} + style={{ + padding: "4px 6px", + cursor: "pointer", + borderRadius: 4, + background: id === active ? "#333" : "transparent", + fontFamily: "monospace", + fontSize: 12, + }} + > + {id} +
+ ))} +
+ ); +} diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx new file mode 100644 index 0000000..f6172f8 --- /dev/null +++ b/app/src/TerminalView.tsx @@ -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(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
; +} diff --git a/app/src/main.tsx b/app/src/main.tsx index 2084e49..5d115fc 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -1,10 +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( -
- spacesh — scaffold OK -
+
); diff --git a/app/src/socketBridge.ts b/app/src/socketBridge.ts new file mode 100644 index 0000000..9688ee6 --- /dev/null +++ b/app/src/socketBridge.ts @@ -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 { + 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 { + 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 { + await invoke("input", { surfaceId, data: Array.from(data) }); +} + +export async function resizeSurface(surfaceId: string, cols: number, rows: number): Promise { + 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 { + const channel = new Channel(); + channel.onmessage = (msg) => onOutput(new Uint8Array(msg)); + return await invoke("attach", { surfaceId, onOutput: channel }); +} + +export async function detachSurface(surfaceId: string): Promise { + await invoke("detach", { surfaceId }); +} + +export async function getStatus(): Promise { + 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("spacesh:evt", (e) => handler(e.payload)); +} + +export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> { + return listen(name, () => handler()); +}