diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 967d8cc..5503df2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,10 +5,13 @@ use std::{fs, process::Command, time::{SystemTime, UNIX_EPOCH}}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use tauri::State; + use crate::config_io::{ensure_dirs, read_openclaw_config, write_json, write_text}; use crate::doctor::{apply_auto_fixes, run_doctor, DoctorReport}; use crate::history::{add_snapshot, list_snapshots, read_snapshot}; use crate::models::resolve_paths; +use crate::ssh::{SshConnectionPool, SshHostConfig, SshExecResult, SftpEntry}; use crate::recipe::{ load_recipes_with_fallback, collect_change_paths, @@ -3923,3 +3926,172 @@ fn resolve_model_provider_base_url(cfg: &Value, provider: &str) -> Option PathBuf { + resolve_paths().clawpal_dir.join("remote-instances.json") +} + +fn read_hosts_from_disk() -> Result, String> { + let path = remote_instances_path(); + if !path.exists() { + return Ok(Vec::new()); + } + let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read remote-instances.json: {e}"))?; + serde_json::from_str(&data).map_err(|e| format!("Failed to parse remote-instances.json: {e}")) +} + +fn write_hosts_to_disk(hosts: &[SshHostConfig]) -> Result<(), String> { + let path = remote_instances_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create dir: {e}"))?; + } + let json = serde_json::to_string_pretty(hosts).map_err(|e| format!("Failed to serialize hosts: {e}"))?; + fs::write(&path, json).map_err(|e| format!("Failed to write remote-instances.json: {e}")) +} + +#[tauri::command] +pub fn list_ssh_hosts() -> Result, String> { + read_hosts_from_disk() +} + +#[tauri::command] +pub fn upsert_ssh_host(host: SshHostConfig) -> Result { + let mut hosts = read_hosts_from_disk()?; + if let Some(existing) = hosts.iter_mut().find(|h| h.id == host.id) { + *existing = host.clone(); + } else { + hosts.push(host.clone()); + } + write_hosts_to_disk(&hosts)?; + Ok(host) +} + +#[tauri::command] +pub fn delete_ssh_host(host_id: String) -> Result { + let mut hosts = read_hosts_from_disk()?; + let before = hosts.len(); + hosts.retain(|h| h.id != host_id); + let removed = hosts.len() < before; + write_hosts_to_disk(&hosts)?; + Ok(removed) +} + +// --------------------------------------------------------------------------- +// Task 4: SSH connect / disconnect / status +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn ssh_connect(pool: State<'_, SshConnectionPool>, host_id: String) -> Result { + let hosts = read_hosts_from_disk()?; + let host = hosts.into_iter().find(|h| h.id == host_id) + .ok_or_else(|| format!("No SSH host config with id: {host_id}"))?; + pool.connect(&host).await?; + Ok(true) +} + +#[tauri::command] +pub async fn ssh_disconnect(pool: State<'_, SshConnectionPool>, host_id: String) -> Result { + pool.disconnect(&host_id).await?; + Ok(true) +} + +#[tauri::command] +pub async fn ssh_status(pool: State<'_, SshConnectionPool>, host_id: String) -> Result { + if pool.is_connected(&host_id).await { + Ok("connected".to_string()) + } else { + Ok("disconnected".to_string()) + } +} + +// --------------------------------------------------------------------------- +// Task 5: SSH exec and SFTP Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn ssh_exec(pool: State<'_, SshConnectionPool>, host_id: String, command: String) -> Result { + pool.exec(&host_id, &command).await +} + +#[tauri::command] +pub async fn sftp_read_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result { + pool.sftp_read(&host_id, &path).await +} + +#[tauri::command] +pub async fn sftp_write_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String, content: String) -> Result { + pool.sftp_write(&host_id, &path, &content).await?; + Ok(true) +} + +#[tauri::command] +pub async fn sftp_list_dir(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result, String> { + pool.sftp_list(&host_id, &path).await +} + +#[tauri::command] +pub async fn sftp_remove_file(pool: State<'_, SshConnectionPool>, host_id: String, path: String) -> Result { + pool.sftp_remove(&host_id, &path).await?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// Task 6: Remote business commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn remote_read_raw_config(pool: State<'_, SshConnectionPool>, host_id: String) -> Result { + let raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await?; + serde_json::from_str(&raw).map_err(|e| format!("Failed to parse remote config: {e}")) +} + +#[tauri::command] +pub async fn remote_get_system_status(pool: State<'_, SshConnectionPool>, host_id: String) -> Result { + // 1. Get openclaw version + let version_result = pool.exec(&host_id, "openclaw --version").await?; + let openclaw_version = version_result.stdout.trim().to_string(); + + // 2. Read remote config + let config_raw = pool.sftp_read(&host_id, "~/.openclaw/openclaw.json").await; + let (active_agents, global_default_model, gateway_port) = match &config_raw { + Ok(raw) => { + let cfg: Value = serde_json::from_str(raw).unwrap_or(Value::Null); + let agents = cfg.pointer("/agents") + .and_then(Value::as_object) + .map(|a| a.len() as u32) + .unwrap_or(0); + let model = cfg.pointer("/models/default") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let port = cfg.pointer("/gateway/port") + .and_then(Value::as_u64) + .unwrap_or(5337); + (agents, model, port) + } + Err(_) => (0, String::new(), 5337), + }; + + // 3. Check gateway health + let health_cmd = format!("curl -sf http://localhost:{gateway_port}/health"); + let health_result = pool.exec(&host_id, &health_cmd).await; + let healthy = match health_result { + Ok(r) => r.exit_code == 0, + Err(_) => false, + }; + + let status = serde_json::json!({ + "healthy": healthy, + "openclawVersion": openclaw_version, + "activeAgents": active_agents, + "globalDefaultModel": global_default_model, + "configPath": "~/.openclaw/openclaw.json", + "openclawDir": "~/.openclaw", + }); + + Ok(status) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69b1151..46b8d84 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,7 +16,12 @@ use crate::commands::{ list_bindings, assign_channel_agent, save_config_baseline, check_config_dirty, discard_config_changes, apply_pending_changes, + list_ssh_hosts, upsert_ssh_host, delete_ssh_host, + ssh_connect, ssh_disconnect, ssh_status, + ssh_exec, sftp_read_file, sftp_write_file, sftp_list_dir, sftp_remove_file, + remote_read_raw_config, remote_get_system_status, }; +use crate::ssh::SshConnectionPool; pub mod commands; pub mod config_io; @@ -28,6 +33,7 @@ pub mod ssh; pub fn run() { tauri::Builder::default() + .manage(SshConnectionPool::new()) .invoke_handler(tauri::generate_handler![ get_system_status, get_status_light, @@ -82,6 +88,19 @@ pub fn run() { check_config_dirty, discard_config_changes, apply_pending_changes, + list_ssh_hosts, + upsert_ssh_host, + delete_ssh_host, + ssh_connect, + ssh_disconnect, + ssh_status, + ssh_exec, + sftp_read_file, + sftp_write_file, + sftp_list_dir, + sftp_remove_file, + remote_read_raw_config, + remote_get_system_status, ]) .run(tauri::generate_context!()) .expect("failed to run app");