//! Tauri command surface. Every JS-callable function lives here. use std::sync::Arc; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use tauri::{AppHandle, Manager}; use tokio::sync::RwLock; use crate::creds; use crate::hosts::{self, SshHost, SshHostView}; use crate::mcp::{self, McpMirror, McpServerHandle, McpState, RunningServer}; use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec}; const WORKSPACE_FILE: &str = "workspace.json"; #[tauri::command] pub async fn list_distros() -> Result, String> { list_wsl_distros().map_err(|e| e.to_string()) } #[tauri::command] pub async fn spawn_pane( app: AppHandle, manager: tauri::State<'_, Arc>, spec: SpawnSpec, cols: u16, rows: u16, ) -> Result { manager.spawn(app, spec, cols, rows).map_err(|e| e.to_string()) } /// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits /// strings; the frontend encodes before sending). #[tauri::command] pub async fn write_to_pane( manager: tauri::State<'_, Arc>, id: PaneId, data_b64: String, ) -> Result<(), String> { let bytes = B64 .decode(data_b64.as_bytes()) .map_err(|e| format!("base64 decode: {e}"))?; manager.write(id, &bytes).map_err(|e| e.to_string()) } #[tauri::command] pub async fn resize_pane( manager: tauri::State<'_, Arc>, id: PaneId, cols: u16, rows: u16, ) -> Result<(), String> { manager.resize(id, cols, rows).map_err(|e| e.to_string()) } #[tauri::command] pub async fn kill_pane( manager: tauri::State<'_, Arc>, id: PaneId, ) -> Result<(), String> { manager.kill(id).map_err(|e| e.to_string()) } /// Write the workspace JSON to `%APPDATA%\com.megaproxy.tiletopia\workspace.json`. /// Writes to a `.tmp` and renames over the real file so a crash mid-write /// can't leave a partial file readable. #[tauri::command] pub async fn save_workspace(app: AppHandle, json: String) -> Result<(), String> { let dir = app .path() .app_config_dir() .map_err(|e| format!("app_config_dir: {e}"))?; std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all: {e}"))?; let path = dir.join(WORKSPACE_FILE); let tmp = dir.join(format!("{WORKSPACE_FILE}.tmp")); std::fs::write(&tmp, json.as_bytes()).map_err(|e| format!("write tmp: {e}"))?; // `std::fs::rename` is atomic on Unix and uses MoveFileEx with // REPLACE_EXISTING on Windows (>= Rust 1.50). std::fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?; Ok(()) } /// Read the workspace JSON. Returns `None` if the file doesn't exist yet /// (first launch). #[tauri::command] pub async fn load_workspace(app: AppHandle) -> Result, String> { let dir = app .path() .app_config_dir() .map_err(|e| format!("app_config_dir: {e}"))?; let path = dir.join(WORKSPACE_FILE); if !path.exists() { return Ok(None); } let s = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?; Ok(Some(s)) } #[tauri::command] pub async fn list_ssh_hosts(app: AppHandle) -> Result, String> { let raw = hosts::load(&app).map_err(|e| e.to_string())?; Ok(raw .into_iter() .map(|h| { let has_password = creds::has(&h.id); SshHostView { host: h, has_password } }) .collect()) } #[tauri::command] pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec) -> Result<(), String> { // Sweep orphaned credentials: any host id that existed before this call // but isn't in the new list gets its keyring entry deleted. Saves the // frontend from having to diff and call delete_host_password itself. if let Ok(prior) = crate::hosts::load(&app) { let new_ids: std::collections::HashSet<&str> = hosts.iter().map(|h| h.id.as_str()).collect(); for old in &prior { if !new_ids.contains(old.id.as_str()) { if let Err(e) = creds::delete(&old.id) { tracing::warn!("orphan credential cleanup failed for {}: {e}", old.id); } } } } crate::hosts::save(&app, &hosts).map_err(|e| e.to_string()) } #[tauri::command] pub async fn set_host_password(host_id: String, password: String) -> Result<(), String> { creds::set(&host_id, &password).map_err(|e| e.to_string()) } #[tauri::command] pub async fn delete_host_password(host_id: String) -> Result<(), String> { creds::delete(&host_id).map_err(|e| e.to_string()) } #[tauri::command] pub async fn has_host_password(host_id: String) -> Result { Ok(creds::has(&host_id)) } // ---- MCP server lifecycle -------------------------------------------------- #[derive(serde::Serialize)] pub struct McpStatus { pub running: bool, pub url: Option, pub token: Option, } fn server_status(handle: &McpServerHandle) -> McpStatus { let g = handle.0.lock(); match g.as_ref() { Some(srv) => McpStatus { running: true, url: Some(format!("http://{}/mcp", srv.addr)), token: Some(srv.token.clone()), }, None => McpStatus { running: false, url: None, token: None, }, } } #[tauri::command] pub async fn mcp_start( ptys: tauri::State<'_, Arc>, state: tauri::State<'_, Arc>>, handle: tauri::State<'_, McpServerHandle>, ) -> Result { { let g = handle.0.lock(); if g.is_some() { return Ok(server_status(&handle)); } } let ptys_arc: Arc = (*ptys).clone(); let state_arc: Arc> = (*state).clone(); let running: RunningServer = mcp::start_server(ptys_arc, state_arc) .await .map_err(|e| e.to_string())?; { let mut g = handle.0.lock(); *g = Some(running); } Ok(server_status(&handle)) } #[tauri::command] pub async fn mcp_stop( handle: tauri::State<'_, McpServerHandle>, ) -> Result { mcp::stop_server(&handle); Ok(server_status(&handle)) } #[tauri::command] pub async fn mcp_status( handle: tauri::State<'_, McpServerHandle>, ) -> Result { Ok(server_status(&handle)) } /// Frontend pushes the gated mirror after every tree/host change. Backend /// caches it for MCP responses — the MCP server only ever sees what the /// frontend chose to mirror (default-deny per-leaf gate). #[tauri::command] pub async fn mcp_update_state( state: tauri::State<'_, Arc>>, mirror: McpMirror, ) -> Result<(), String> { let mut g = state.write().await; g.mirror = mirror; Ok(()) }