tiletopia/src-tauri/src/commands.rs

220 lines
6.7 KiB
Rust

//! 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<Vec<String>, String> {
list_wsl_distros().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn spawn_pane(
app: AppHandle,
manager: tauri::State<'_, Arc<PtyManager>>,
spec: SpawnSpec,
cols: u16,
rows: u16,
) -> Result<PaneId, String> {
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<PtyManager>>,
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<PtyManager>>,
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<PtyManager>>,
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<Option<String>, 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<Vec<SshHostView>, 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<SshHost>) -> 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<bool, String> {
Ok(creds::has(&host_id))
}
// ---- MCP server lifecycle --------------------------------------------------
#[derive(serde::Serialize)]
pub struct McpStatus {
pub running: bool,
pub url: Option<String>,
pub token: Option<String>,
}
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<PtyManager>>,
state: tauri::State<'_, Arc<RwLock<McpState>>>,
handle: tauri::State<'_, McpServerHandle>,
) -> Result<McpStatus, String> {
{
let g = handle.0.lock();
if g.is_some() {
return Ok(server_status(&handle));
}
}
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
let state_arc: Arc<RwLock<McpState>> = (*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<McpStatus, String> {
mcp::stop_server(&handle);
Ok(server_status(&handle))
}
#[tauri::command]
pub async fn mcp_status(
handle: tauri::State<'_, McpServerHandle>,
) -> Result<McpStatus, String> {
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<RwLock<McpState>>>,
mirror: McpMirror,
) -> Result<(), String> {
let mut g = state.write().await;
g.mirror = mirror;
Ok(())
}