diff --git a/src-tauri/src/commands/docker_commands.rs b/src-tauri/src/commands/docker_commands.rs index b860765..beb8c46 100644 --- a/src-tauri/src/commands/docker_commands.rs +++ b/src-tauri/src/commands/docker_commands.rs @@ -3,6 +3,7 @@ use tauri::State; use serde::Serialize; use crate::AppState; +use crate::ssh::exec::exec_on_session; use crate::utils::shell_escape; #[derive(Debug, Serialize)] @@ -38,7 +39,7 @@ pub struct DockerVolume { pub async fn docker_list_containers(session_id: String, all: Option, state: State<'_, AppState>) -> Result, String> { let session = state.ssh.get_session(&session_id).ok_or("Session not found")?; let flag = if all.unwrap_or(true) { "-a" } else { "" }; - let output = exec(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?; + let output = exec_on_session(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?; Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| { let p: Vec<&str> = line.splitn(6, '|').collect(); DockerContainer { @@ -55,7 +56,7 @@ pub async fn docker_list_containers(session_id: String, all: Option, state #[tauri::command] pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result, String> { let session = state.ssh.get_session(&session_id).ok_or("Session not found")?; - let output = exec(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?; + let output = exec_on_session(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?; Ok(output.lines().filter(|l| !l.is_empty()).map(|line| { let p: Vec<&str> = line.splitn(5, '|').collect(); DockerImage { @@ -71,7 +72,7 @@ pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) #[tauri::command] pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result, String> { let session = state.ssh.get_session(&session_id).ok_or("Session not found")?; - let output = exec(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?; + let output = exec_on_session(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?; Ok(output.lines().filter(|l| !l.is_empty()).map(|line| { let p: Vec<&str> = line.splitn(3, '|').collect(); DockerVolume { @@ -99,19 +100,6 @@ pub async fn docker_action(session_id: String, action: String, target: String, s "system-prune-all" => "docker system prune -a -f 2>&1".to_string(), _ => return Err(format!("Unknown docker action: {}", action)), }; - exec(&session.handle, &cmd).await + exec_on_session(&session.handle, &cmd).await } -async fn exec(handle: &std::sync::Arc>>, cmd: &str) -> Result { - let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? }; - channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?; - let mut output = String::new(); - loop { - match channel.wait().await { - Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } } - Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, - _ => {} - } - } - Ok(output) -} diff --git a/src-tauri/src/commands/rdp_commands.rs b/src-tauri/src/commands/rdp_commands.rs index 67ebffb..1a4dca2 100644 --- a/src-tauri/src/commands/rdp_commands.rs +++ b/src-tauri/src/commands/rdp_commands.rs @@ -45,7 +45,7 @@ pub async fn rdp_get_frame( /// - 0x0100 = negative wheel direction /// - 0x0400 = horizontal wheel #[tauri::command] -pub async fn rdp_send_mouse( +pub fn rdp_send_mouse( session_id: String, x: u16, y: u16, @@ -63,7 +63,7 @@ pub async fn rdp_send_mouse( /// /// `pressed` is `true` for key-down, `false` for key-up. #[tauri::command] -pub async fn rdp_send_key( +pub fn rdp_send_key( session_id: String, scancode: u16, pressed: bool, @@ -74,7 +74,7 @@ pub async fn rdp_send_key( /// Send clipboard text to an RDP session by simulating keystrokes. #[tauri::command] -pub async fn rdp_send_clipboard( +pub fn rdp_send_clipboard( session_id: String, text: String, state: State<'_, AppState>, @@ -86,7 +86,7 @@ pub async fn rdp_send_clipboard( /// /// Sends a graceful shutdown to the RDP server and removes the session. #[tauri::command] -pub async fn disconnect_rdp( +pub fn disconnect_rdp( session_id: String, state: State<'_, AppState>, ) -> Result<(), String> { @@ -95,7 +95,7 @@ pub async fn disconnect_rdp( /// List all active RDP sessions (metadata only). #[tauri::command] -pub async fn list_rdp_sessions( +pub fn list_rdp_sessions( state: State<'_, AppState>, ) -> Result, String> { Ok(state.rdp.list_sessions()) diff --git a/src-tauri/src/commands/tools_commands.rs b/src-tauri/src/commands/tools_commands.rs index 7a0c1cb..de32cdc 100644 --- a/src-tauri/src/commands/tools_commands.rs +++ b/src-tauri/src/commands/tools_commands.rs @@ -4,6 +4,7 @@ use tauri::State; use serde::Serialize; use crate::AppState; +use crate::ssh::exec::exec_on_session; use crate::utils::shell_escape; // ── Ping ───────────────────────────────────────────────────────────────────── @@ -185,32 +186,3 @@ pub fn tool_generate_password_inner( Ok(password) } -// ── Helper ─────────────────────────────────────────────────────────────────── - -async fn exec_on_session( - handle: &std::sync::Arc>>, - cmd: &str, -) -> Result { - let mut channel = { - let h = handle.lock().await; - h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))? - }; - - channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?; - - let mut output = String::new(); - loop { - match channel.wait().await { - Some(russh::ChannelMsg::Data { ref data }) => { - if let Ok(text) = std::str::from_utf8(data.as_ref()) { - output.push_str(text); - } - } - Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, - Some(russh::ChannelMsg::ExitStatus { .. }) => {} - _ => {} - } - } - - Ok(output) -} diff --git a/src-tauri/src/commands/tools_commands_r2.rs b/src-tauri/src/commands/tools_commands_r2.rs index e487f9a..04dd2aa 100644 --- a/src-tauri/src/commands/tools_commands_r2.rs +++ b/src-tauri/src/commands/tools_commands_r2.rs @@ -4,6 +4,7 @@ use tauri::State; use serde::Serialize; use crate::AppState; +use crate::ssh::exec::exec_on_session; use crate::utils::shell_escape; // ── DNS Lookup ─────────────────────────────────────────────────────────────── @@ -181,27 +182,3 @@ fn to_ip(val: u32) -> String { format!("{}.{}.{}.{}", val >> 24, (val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF) } -// ── Helper ─────────────────────────────────────────────────────────────────── - -async fn exec_on_session( - handle: &std::sync::Arc>>, - cmd: &str, -) -> Result { - let mut channel = { - let h = handle.lock().await; - h.channel_open_session().await.map_err(|e| format!("Exec channel failed: {}", e))? - }; - channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?; - let mut output = String::new(); - loop { - match channel.wait().await { - Some(russh::ChannelMsg::Data { ref data }) => { - if let Ok(text) = std::str::from_utf8(data.as_ref()) { output.push_str(text); } - } - Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, - Some(russh::ChannelMsg::ExitStatus { .. }) => {} - _ => {} - } - } - Ok(output) -} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index faa2a2a..8370f6d 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -36,7 +36,7 @@ impl ScrollbackRegistry { /// Get the scrollback buffer for a session. pub fn get(&self, session_id: &str) -> Option> { - self.buffers.get(session_id).map(|entry| entry.clone()) + self.buffers.get(session_id).map(|r| r.value().clone()) } /// Remove a session's scrollback buffer. diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 112a5f8..c0940c3 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -19,6 +19,7 @@ use tokio::net::TcpListener; use crate::mcp::ScrollbackRegistry; use crate::rdp::RdpService; use crate::sftp::SftpService; +use crate::ssh::exec::exec_on_session; use crate::ssh::session::SshService; use crate::utils::shell_escape; @@ -308,32 +309,32 @@ struct ToolPassgenRequest { length: Option, uppercase: Option, lowe async fn handle_tool_ping(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; - match tool_exec(&session.handle, &format!("ping -c 4 {} 2>&1", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("ping -c 4 {} 2>&1", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_tool_traceroute(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let t = shell_escape(&req.target); - match tool_exec(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("traceroute {} 2>&1 || tracert {} 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_tool_dns(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let rt = shell_escape(&req.record_type.unwrap_or_else(|| "A".to_string())); let d = shell_escape(&req.domain); - match tool_exec(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", d, rt, rt, d, rt, d)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("dig {} {} +short 2>/dev/null || nslookup -type={} {} 2>/dev/null || host -t {} {} 2>/dev/null", d, rt, rt, d, rt, d)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_tool_whois(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; - match tool_exec(&session.handle, &format!("whois {} 2>&1 | head -80", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("whois {} 2>&1 | head -80", shell_escape(&req.target))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_tool_wol(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let mac_clean = req.mac_address.replace([':', '-'], ""); let cmd = format!(r#"python3 -c "import socket;mac=bytes.fromhex({});pkt=b'\xff'*6+mac*16;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1);s.sendto(pkt,('255.255.255.255',9));s.close();print('WoL sent to {}')" 2>&1"#, shell_escape(&mac_clean), shell_escape(&req.mac_address)); - match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_tool_scan_network(AxumState(state): AxumState>, Json(req): Json) -> Json> { @@ -364,7 +365,7 @@ async fn handle_tool_subnet(_state: AxumState>, Json(req): J async fn handle_tool_bandwidth(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let cmd = r#"if command -v speedtest-cli >/dev/null 2>&1; then speedtest-cli --simple 2>&1; elif command -v curl >/dev/null 2>&1; then curl -o /dev/null -w "Download: %{speed_download} bytes/sec\n" https://speed.cloudflare.com/__down?bytes=25000000 2>/dev/null; else echo "No speedtest tool found"; fi"#; - match tool_exec(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_tool_keygen(_state: AxumState>, Json(req): Json) -> Json> { @@ -381,20 +382,6 @@ async fn handle_tool_passgen(_state: AxumState>, Json(req): } } -async fn tool_exec(handle: &std::sync::Arc>>, cmd: &str) -> Result { - let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? }; - channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?; - let mut output = String::new(); - loop { - match channel.wait().await { - Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } } - Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break, - _ => {} - } - } - Ok(output) -} - // ── Docker handlers ────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -408,7 +395,7 @@ struct DockerExecRequest { session_id: String, container: String, command: Strin async fn handle_docker_ps(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; - match tool_exec(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>&1").await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_docker_action(AxumState(state): AxumState>, Json(req): Json) -> Json> { @@ -424,13 +411,13 @@ async fn handle_docker_action(AxumState(state): AxumState>, "system-prune" => "docker system prune -f 2>&1".to_string(), _ => return err_response(format!("Unknown action: {}", req.action)), }; - match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_docker_exec(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let cmd = format!("docker exec {} {} 2>&1", shell_escape(&req.container), shell_escape(&req.command)); - match tool_exec(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &cmd).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } // ── Service/process handlers ───────────────────────────────────────────────── @@ -438,13 +425,13 @@ async fn handle_docker_exec(AxumState(state): AxumState>, Js async fn handle_service_status(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let t = shell_escape(&req.target); - match tool_exec(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("systemctl status {} --no-pager 2>&1 || service {} status 2>&1", t, t)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_process_list(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; let filter = if req.target.is_empty() { "aux --sort=-%cpu | head -30".to_string() } else { format!("aux | grep -i {} | grep -v grep", shell_escape(&req.target)) }; - match tool_exec(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("ps {}", filter)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } // ── Git handlers ───────────────────────────────────────────────────────────── @@ -454,17 +441,17 @@ struct GitRequest { session_id: String, path: String } async fn handle_git_status(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; - match tool_exec(&session.handle, &format!("cd {} && git status --short --branch 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("cd {} && git status --short --branch 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_git_pull(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; - match tool_exec(&session.handle, &format!("cd {} && git pull 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("cd {} && git pull 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } async fn handle_git_log(AxumState(state): AxumState>, Json(req): Json) -> Json> { let session = match state.ssh.get_session(&req.session_id) { Some(s) => s, None => return err_response(format!("Session {} not found", req.session_id)) }; - match tool_exec(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } + match exec_on_session(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", shell_escape(&req.path))).await { Ok(o) => ok_response(o), Err(e) => err_response(e) } } // ── Session creation handlers ──────────────────────────────────────────────── diff --git a/src-tauri/src/sftp/mod.rs b/src-tauri/src/sftp/mod.rs index d844668..80b6623 100644 --- a/src-tauri/src/sftp/mod.rs +++ b/src-tauri/src/sftp/mod.rs @@ -5,7 +5,6 @@ //! provides all file operations needed by the frontend. use std::sync::Arc; -use std::time::{Duration, UNIX_EPOCH}; use dashmap::DashMap; use log::{debug, info}; @@ -35,9 +34,6 @@ pub struct FileEntry { /// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM". fn format_mtime(unix_secs: u32) -> String { - // Build a SystemTime from the raw epoch value. - let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64); - // Convert to seconds-since-epoch for manual formatting. We avoid pulling // in chrono just for this; a simple manual decomposition is sufficient for // the "Mar 17 14:30" display format expected by the frontend. @@ -54,12 +50,10 @@ fn format_mtime(unix_secs: u32) -> String { let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let _y = if m <= 2 { y + 1 } else { y }; let month = match m { 1 => "Jan", @@ -77,9 +71,6 @@ fn format_mtime(unix_secs: u32) -> String { _ => "???", }; - // Suppress unused variable warning — st is only used as a sanity anchor. - let _ = st; - format!("{} {:2} {:02}:{:02}", month, d, hours, minutes) } @@ -319,7 +310,7 @@ impl SftpService { ) -> Result>, String> { self.clients .get(session_id) - .map(|r| r.clone()) + .map(|r| r.value().clone()) .ok_or_else(|| format!("No SFTP client for session {}", session_id)) } } diff --git a/src-tauri/src/ssh/exec.rs b/src-tauri/src/ssh/exec.rs new file mode 100644 index 0000000..16e3b72 --- /dev/null +++ b/src-tauri/src/ssh/exec.rs @@ -0,0 +1,51 @@ +//! Shared SSH exec-channel helper used by commands, MCP handlers, and tools. +//! +//! Opens a one-shot exec channel on an existing SSH handle, runs `cmd`, collects +//! all stdout/stderr, and returns it as a `String`. The caller is responsible +//! for ensuring the session is still alive. + +use std::sync::Arc; + +use tokio::sync::Mutex as TokioMutex; + +use crate::ssh::session::SshClient; + +/// Execute `cmd` on a separate exec channel and return all output as a `String`. +/// +/// Locks the handle for only as long as it takes to open the channel, then +/// releases it before reading — this avoids holding the lock while waiting on +/// remote I/O. +pub async fn exec_on_session( + handle: &Arc>>, + cmd: &str, +) -> Result { + let mut channel = { + let h = handle.lock().await; + h.channel_open_session() + .await + .map_err(|e| format!("Exec channel failed: {}", e))? + }; + + channel + .exec(true, cmd) + .await + .map_err(|e| format!("Exec failed: {}", e))?; + + let mut output = String::new(); + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Data { ref data }) => { + if let Ok(text) = std::str::from_utf8(data.as_ref()) { + output.push_str(text); + } + } + Some(russh::ChannelMsg::Eof) + | Some(russh::ChannelMsg::Close) + | None => break, + Some(russh::ChannelMsg::ExitStatus { .. }) => {} + _ => {} + } + } + + Ok(output) +} diff --git a/src-tauri/src/ssh/mod.rs b/src-tauri/src/ssh/mod.rs index adf144c..bb9685b 100644 --- a/src-tauri/src/ssh/mod.rs +++ b/src-tauri/src/ssh/mod.rs @@ -2,3 +2,4 @@ pub mod session; pub mod host_key; pub mod cwd; pub mod monitor; +pub mod exec; diff --git a/src-tauri/src/ssh/session.rs b/src-tauri/src/ssh/session.rs index 8626fb1..7aa50f1 100644 --- a/src-tauri/src/ssh/session.rs +++ b/src-tauri/src/ssh/session.rs @@ -258,7 +258,7 @@ impl SshService { } pub fn get_session(&self, session_id: &str) -> Option> { - self.sessions.get(session_id).map(|entry| entry.clone()) + self.sessions.get(session_id).map(|r| r.value().clone()) } pub fn list_sessions(&self) -> Vec { @@ -405,22 +405,23 @@ fn extract_osc7_cwd(data: &[u8]) -> Option { } fn percent_decode(input: &str) -> String { - let mut output = String::with_capacity(input.len()); + let mut bytes: Vec = Vec::with_capacity(input.len()); let mut chars = input.chars(); while let Some(ch) = chars.next() { if ch == '%' { let hex: String = chars.by_ref().take(2).collect(); if let Ok(byte) = u8::from_str_radix(&hex, 16) { - output.push(byte as char); + bytes.push(byte); } else { - output.push('%'); - output.push_str(&hex); + bytes.extend_from_slice(b"%"); + bytes.extend_from_slice(hex.as_bytes()); } } else { - output.push(ch); + let mut buf = [0u8; 4]; + bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); } } - output + String::from_utf8_lossy(&bytes).into_owned() } /// Resolve a private key string — if it looks like PEM content, return as-is. diff --git a/src-tauri/src/theme/mod.rs b/src-tauri/src/theme/mod.rs index cc5fc58..d4bc228 100644 --- a/src-tauri/src/theme/mod.rs +++ b/src-tauri/src/theme/mod.rs @@ -59,6 +59,7 @@ struct BuiltinTheme { // ── service ─────────────────────────────────────────────────────────────────── +#[derive(Clone)] pub struct ThemeService { db: Database, } @@ -253,7 +254,7 @@ impl ThemeService { t.bright_blue, t.bright_magenta, t.bright_cyan, t.bright_white, ], ) { - eprintln!("theme::seed_builtins: failed to seed '{}': {}", t.name, e); + wraith_log!("theme::seed_builtins: failed to seed '{}': {}", t.name, e); } } } @@ -272,7 +273,7 @@ impl ThemeService { ) { Ok(s) => s, Err(e) => { - eprintln!("theme::list: failed to prepare query: {}", e); + wraith_log!("theme::list: failed to prepare query: {}", e); return vec![]; } }; @@ -280,12 +281,12 @@ impl ThemeService { match stmt.query_map([], map_theme_row) { Ok(rows) => rows .filter_map(|r| { - r.map_err(|e| eprintln!("theme::list: row error: {}", e)) + r.map_err(|e| wraith_log!("theme::list: row error: {}", e)) .ok() }) .collect(), Err(e) => { - eprintln!("theme::list: query failed: {}", e); + wraith_log!("theme::list: query failed: {}", e); vec![] } } diff --git a/src-tauri/src/workspace/mod.rs b/src-tauri/src/workspace/mod.rs index 900d03a..51fb991 100644 --- a/src-tauri/src/workspace/mod.rs +++ b/src-tauri/src/workspace/mod.rs @@ -24,6 +24,7 @@ pub struct WorkspaceSnapshot { const SNAPSHOT_KEY: &str = "workspace_snapshot"; const CLEAN_SHUTDOWN_KEY: &str = "clean_shutdown"; +#[derive(Clone)] pub struct WorkspaceService { settings: SettingsService, } @@ -47,7 +48,7 @@ impl WorkspaceService { pub fn load(&self) -> Option { let json = self.settings.get(SNAPSHOT_KEY)?; serde_json::from_str(&json) - .map_err(|e| eprintln!("workspace::load: failed to deserialize snapshot: {e}")) + .map_err(|e| wraith_log!("workspace::load: failed to deserialize snapshot: {e}")) .ok() }