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::message::{SplitDir, Edge, PresetSlot}; use spacesh_proto::ids::{GroupId, WorkspaceId}; 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())?) } // ---- M2 commands ---- #[tauri::command] pub async fn split_surface(state: BridgeState<'_>, surface_id: String, dir: String, command: Option, args: Vec) -> Result { let dir = if dir == "down" { SplitDir::Down } else { SplitDir::Right }; data_of(state.request(Cmd::SplitSurface { surface_id: SurfaceId(surface_id), dir, command, args }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn set_ratios(state: BridgeState<'_>, workspace_id: String, node_path: Vec, ratios: Vec) -> Result { data_of(state.request(Cmd::SetRatios { workspace_id: WorkspaceId(workspace_id), node_path, ratios }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn move_surface(state: BridgeState<'_>, surface_id: String, target_surface_id: String, edge: String) -> Result { let edge = match edge.as_str() { "left" => Edge::Left, "top" => Edge::Top, "bottom" => Edge::Bottom, _ => Edge::Right }; data_of(state.request(Cmd::MoveSurface { surface_id: SurfaceId(surface_id), target_surface_id: SurfaceId(target_surface_id), edge }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_id: String, slots: Vec) -> Result { data_of(state.request(Cmd::ApplyPreset { workspace_id: WorkspaceId(workspace_id), preset_id, slots }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn restart_surface(state: BridgeState<'_>, surface_id: String) -> Result { data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn close_workspace(state: BridgeState<'_>, workspace_id: String) -> Result { data_of(state.request(Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option, group_id: Option, unread: Option, order: Option) -> Result { // group_id: None from JS means "no change"; an explicit null is sent as Some("") to mean "ungroup". let gid = match group_id { None => None, Some(s) if s.is_empty() => Some(None), Some(s) => Some(Some(GroupId(s))), }; data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn create_group(state: BridgeState<'_>, name: String, color: String) -> Result { data_of(state.request(Cmd::CreateGroup { name, color }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn set_group(state: BridgeState<'_>, group_id: String, name: Option, color: Option, order: Option) -> Result { data_of(state.request(Cmd::SetGroup { group_id: GroupId(group_id), name, color, order }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result { data_of(state.request(Cmd::DeleteGroup { group_id: GroupId(group_id) }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn focus(state: BridgeState<'_>, surface_id: String) -> Result { data_of(state.request(Cmd::Focus { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) }