56893c51d0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
6.9 KiB
Rust
196 lines
6.9 KiB
Rust
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())?)
|
|
}
|