diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7cc41fe..812c005 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -24,6 +24,7 @@ base64 = "0.22" png = "0.17" futures-util = "0.3" colored = "2" +terminal_size = "0.3" [build-dependencies] winres = "0.1" diff --git a/crates/client/src/display.rs b/crates/client/src/display.rs new file mode 100644 index 0000000..c94d0cc --- /dev/null +++ b/crates/client/src/display.rs @@ -0,0 +1,105 @@ +/// Terminal display helpers โ€” table-style command rows with live spinner. +/// +/// Layout (no borders, aligned columns): +/// {2 spaces}{action_emoji}{2 spaces}{name: usize { + terminal_size::terminal_size() + .map(|(w, _)| w.0 as usize) + .unwrap_or(120) + .max(60) +} + +/// Split remaining space between payload and result columns. +/// payload gets ~55%, result gets the rest; both have minimums. +fn col_widths() -> (usize, usize) { + let tw = terminal_width(); + let remaining = tw.saturating_sub(FIXED_OVERHEAD).max(MIN_PAYLOAD_W + MIN_RESULT_W); + let payload_w = (remaining * 55 / 100).max(MIN_PAYLOAD_W); + let result_w = remaining.saturating_sub(payload_w).max(MIN_RESULT_W); + (payload_w, result_w) +} + +/// Truncate a string to at most `max` Unicode chars, appending `โ€ฆ` if cut. +pub fn trunc(s: &str, max: usize) -> String { + if max == 0 { + return String::new(); + } + let mut chars = s.chars(); + let truncated: String = chars.by_ref().take(max).collect(); + if chars.next().is_some() { + // Replace last char with ellipsis + let mut t: String = truncated.chars().take(max.saturating_sub(1)).collect(); + t.push('โ€ฆ'); + t + } else { + truncated + } +} + +/// Format one table row into a String (no trailing newline). +fn format_row(action: &str, name: &str, payload: &str, status: &str, result: &str) -> String { + let (payload_w, result_w) = col_widths(); + let p = trunc(payload, payload_w); + let r = trunc(result, result_w); + format!( + " {} {: String { - let mut chars = s.chars(); - let truncated: String = chars.by_ref().take(max_chars).collect(); - if chars.next().is_some() { - format!("{}โ€ฆ", truncated) - } else { - truncated - } -} +// Re-export trunc for use in this file +use display::trunc; fn banner() { println!(); - println!(" {}\tHELIOS REMOTE ({})", "โ˜€".yellow().bold(), env!("GIT_COMMIT")); + println!(" {} HELIOS REMOTE ({})", "โ˜€".yellow().bold(), env!("GIT_COMMIT")); #[cfg(windows)] { let admin = is_admin(); @@ -43,7 +34,7 @@ fn banner() { } else { ("๐Ÿ‘ค", "user (no admin)".yellow().to_string()) }; - println!(" {}\t{}", icon, admin_str); + println!(" {} {}", icon, admin_str); } println!(); } @@ -70,36 +61,6 @@ fn enable_ansi() { } } -macro_rules! log_status { - ($($arg:tt)*) => { - println!(" {}", format!($($arg)*)); - }; -} - -macro_rules! log_ok { - ($($arg:tt)*) => {{ - let msg = format!($($arg)*); - println!(" {}\t{}", "โœ…", msg); - logger::write_line("OK", &msg); - }}; -} - -macro_rules! log_err { - ($($arg:tt)*) => {{ - let msg = format!($($arg)*); - println!(" {}\t{}", "โŒ", msg); - logger::write_line("ERROR", &msg); - }}; -} - -macro_rules! log_cmd { - ($emoji:expr, $($arg:tt)*) => {{ - let msg = format!($($arg)*); - println!(" {}\t{}", $emoji, msg); - logger::write_line("CMD", &msg); - }}; -} - // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ #[derive(Debug, Clone, Serialize, Deserialize)] @@ -193,14 +154,14 @@ async fn main() { let config = match Config::load() { Some(c) => c, None => { - log_status!("No config found โ€” first-time setup"); + display::status("โ„น", "No config found โ€” first-time setup"); println!(); let c = prompt_config(); println!(); if let Err(e) = c.save() { - log_err!("Failed to save config: {e}"); + display::err("โŒ", &format!("Failed to save config: {e}")); } else { - log_ok!("Config saved"); + display::ok("โœ…", "Config saved"); } c } @@ -214,7 +175,7 @@ async fn main() { let mut cfg = config.clone(); cfg.session_id = Some(id.to_string()); if let Err(e) = cfg.save() { - log_err!("Failed to save session_id: {e}"); + display::err("โŒ", &format!("Failed to save session_id: {e}")); } id } @@ -235,7 +196,7 @@ async fn main() { .next() .unwrap_or(&config.relay_url); - log_status!("๐ŸŒ Connecting to {}...", host); + display::status("๐ŸŒ", &format!("Connecting to {}...", host)); let tls_connector = TlsConnector::builder() .danger_accept_invalid_certs(true) @@ -246,13 +207,13 @@ async fn main() { match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await { Ok((ws_stream, _)) => { let label = config.label.clone().unwrap_or_else(|| hostname()); - log_ok!( + display::ok("โœ…", &format!( "Connected {} {} {} Session {}", "ยท".dimmed(), label.bold(), "ยท".dimmed(), sid.to_string().dimmed() - ); + )); println!(); backoff = Duration::from_secs(1); @@ -264,7 +225,7 @@ async fn main() { }; let hello_json = serde_json::to_string(&hello).unwrap(); if let Err(e) = write.send(Message::Text(hello_json)).await { - log_err!("Failed to send Hello: {e}"); + display::err("โŒ", &format!("Failed to send Hello: {e}")); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(MAX_BACKOFF); continue; @@ -278,7 +239,7 @@ async fn main() { let server_msg: ServerMessage = match serde_json::from_str(&text) { Ok(m) => m, Err(e) => { - log_err!("Failed to parse server message: {e}"); + display::err("โŒ", &format!("Failed to parse server message: {e}")); continue; } }; @@ -294,13 +255,13 @@ async fn main() { let json = match serde_json::to_string(&response) { Ok(j) => j, Err(e) => { - log_err!("Failed to serialize response: {e}"); + display::err("โŒ", &format!("Failed to serialize response: {e}")); return; } }; let mut w = write_clone.lock().await; if let Err(e) = w.send(Message::Text(json)).await { - log_err!("Failed to send response: {e}"); + display::err("โŒ", &format!("Failed to send response: {e}")); } }); } @@ -309,11 +270,11 @@ async fn main() { let _ = w.send(Message::Pong(data)).await; } Ok(Message::Close(_)) => { - log_err!("Connection lost โ€” reconnecting..."); + display::err("โŒ", "Connection lost โ€” reconnecting..."); break; } Err(e) => { - log_err!("Connection lost: {e} โ€” reconnecting..."); + display::err("โŒ", &format!("Connection lost: {e} โ€” reconnecting...")); break; } _ => {} @@ -321,7 +282,7 @@ async fn main() { } } Err(e) => { - log_err!("Connection failed: {e}"); + display::err("โŒ", &format!("Connection failed: {e}")); } } @@ -359,37 +320,30 @@ async fn handle_message( ) -> ClientMessage { match msg { ServerMessage::WindowScreenshotRequest { request_id, window_id } => { - log_cmd!("๐Ÿ“ท", "screenshot window {}", window_id); + let payload = format!("window {window_id}"); + display::cmd_start("๐Ÿ“ท", "screenshot", &payload); match screenshot::take_window_screenshot(window_id) { Ok((image_base64, width, height)) => { - log_ok!("Done {} {}ร—{}", "ยท".dimmed(), width, height); + display::cmd_done("๐Ÿ“ท", "screenshot", &payload, true, &format!("{width}ร—{height}")); ClientMessage::ScreenshotResponse { request_id, image_base64, width, height } } Err(e) => { - log_err!("Window screenshot failed: {e}"); + display::cmd_done("๐Ÿ“ท", "screenshot", &payload, false, &format!("{e}")); ClientMessage::Error { request_id, message: format!("Window screenshot failed: {e}") } } } } ServerMessage::ScreenshotRequest { request_id } => { - log_cmd!("๐Ÿ“ท", "screenshot"); + display::cmd_start("๐Ÿ“ท", "screenshot", ""); match screenshot::take_screenshot() { Ok((image_base64, width, height)) => { - log_ok!("Done {} {}ร—{}", "ยท".dimmed(), width, height); - ClientMessage::ScreenshotResponse { - request_id, - image_base64, - width, - height, - } + display::cmd_done("๐Ÿ“ท", "screenshot", "", true, &format!("{width}ร—{height}")); + ClientMessage::ScreenshotResponse { request_id, image_base64, width, height } } Err(e) => { - log_err!("Screenshot failed: {e}"); - ClientMessage::Error { - request_id, - message: format!("Screenshot failed: {e}"), - } + display::cmd_done("๐Ÿ“ท", "screenshot", "", false, &format!("{e}")); + ClientMessage::Error { request_id, message: format!("Screenshot failed: {e}") } } } } @@ -398,15 +352,14 @@ async fn handle_message( let _title = title.unwrap_or_else(|| "Helios Remote".to_string()); #[cfg(windows)] let title = _title.clone(); - log_cmd!("๐Ÿ’ฌ", "prompt โ€บ {}", trunc(&message, 60)); + let payload = trunc(&message, 60); + display::cmd_start("๐Ÿ’ฌ", "prompt", &payload); #[cfg(windows)] { use windows::core::PCWSTR; use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONINFORMATION, HWND_DESKTOP}; let msg_wide: Vec = message.encode_utf16().chain(std::iter::once(0)).collect(); let title_wide: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); - // Run blocking MessageBox in a thread so we don't block the async runtime - let msg_clone = message.clone(); tokio::task::spawn_blocking(move || { unsafe { MessageBoxW( @@ -417,164 +370,153 @@ async fn handle_message( ); } }).await.ok(); - log_ok!("User confirmed: {}", trunc(&msg_clone, 40)); + display::cmd_done("๐Ÿ’ฌ", "prompt", &payload, true, "confirmed"); } #[cfg(not(windows))] { - // On non-Windows just log it println!(" [PROMPT] {}", message); + display::cmd_done("๐Ÿ’ฌ", "prompt", &payload, true, "shown"); } ClientMessage::Ack { request_id } } ServerMessage::ExecRequest { request_id, command, timeout_ms } => { - let cmd_display = trunc(&command, 60); - log_cmd!("โšก", "exec โ€บ {}", cmd_display); + let payload = trunc(&command, 80); + display::cmd_start("โšก", "exec", &payload); let mut sh = shell.lock().await; match sh.run(&command, timeout_ms).await { Ok((stdout, stderr, exit_code)) => { - let out = stdout.trim().lines().next().unwrap_or("").to_string(); - let out_display = trunc(&out, 60); - if exit_code != 0 { - if out_display.is_empty() { - log_err!("exit {}", exit_code); + let first_line = stdout.trim().lines().next().unwrap_or("").to_string(); + let result = if exit_code != 0 { + if first_line.is_empty() { + format!("exit {exit_code}") } else { - log_err!("{} {} exit {}", out_display, "ยท".dimmed(), exit_code); + format!("{first_line} ยท exit {exit_code}") } - } else if out_display.is_empty() { - log_ok!("exit 0"); + } else if first_line.is_empty() { + "exit 0".to_string() } else { - log_ok!("{} {} exit 0", out_display, "ยท".dimmed()); - } + format!("{first_line} ยท exit 0") + }; + display::cmd_done("โšก", "exec", &payload, exit_code == 0, &result); let _ = stderr; - ClientMessage::ExecResponse { - request_id, - stdout, - stderr, - exit_code, - } + ClientMessage::ExecResponse { request_id, stdout, stderr, exit_code } } Err(e) => { - log_err!("exec failed: {e}"); - ClientMessage::Error { - request_id, - message: format!( - "Exec failed for command {:?}.\nError: {e}", - command - ), - } + display::cmd_done("โšก", "exec", &payload, false, &format!("exec failed: {e}")); + ClientMessage::Error { request_id, message: format!("Exec failed for command {:?}.\nError: {e}", command) } } } } ServerMessage::ClickRequest { request_id, x, y, button } => { - log_cmd!("๐Ÿ–ฑ ", "click ({x}, {y}) {:?}", button); + let payload = format!("({x}, {y}) {button:?}"); + display::cmd_start("๐Ÿ–ฑ", "click", &payload); match input::click(x, y, &button) { Ok(()) => { - log_ok!("Done"); + display::cmd_done("๐Ÿ–ฑ", "click", &payload, true, "done"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("click failed: {e}"); - ClientMessage::Error { - request_id, - message: format!("Click at ({x},{y}) failed: {e}"), - } + display::cmd_done("๐Ÿ–ฑ", "click", &payload, false, &format!("{e}")); + ClientMessage::Error { request_id, message: format!("Click at ({x},{y}) failed: {e}") } } } } ServerMessage::TypeRequest { request_id, text } => { - log_cmd!("โŒจ ", "type {} chars", text.len()); + let payload = format!("{} chars", text.len()); + display::cmd_start("โŒจ", "type", &payload); match input::type_text(&text) { Ok(()) => { - log_ok!("Done"); + display::cmd_done("โŒจ", "type", &payload, true, "done"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("type failed: {e}"); - ClientMessage::Error { - request_id, - message: format!("Type failed: {e}"), - } + display::cmd_done("โŒจ", "type", &payload, false, &format!("{e}")); + ClientMessage::Error { request_id, message: format!("Type failed: {e}") } } } } ServerMessage::ListWindowsRequest { request_id } => { - log_cmd!("๐ŸชŸ", "list-windows"); + display::cmd_start("๐ŸชŸ", "list-windows", ""); match windows_mgmt::list_windows() { Ok(windows) => { - log_ok!("{} windows", windows.len()); + display::cmd_done("๐ŸชŸ", "list-windows", "", true, &format!("{} windows", windows.len())); ClientMessage::ListWindowsResponse { request_id, windows } } Err(e) => { - log_err!("list-windows failed: {e}"); + display::cmd_done("๐ŸชŸ", "list-windows", "", false, &e); ClientMessage::Error { request_id, message: e } } } } ServerMessage::MinimizeAllRequest { request_id } => { - log_cmd!("๐ŸชŸ", "minimize-all"); + display::cmd_start("๐ŸชŸ", "minimize-all", ""); match windows_mgmt::minimize_all() { Ok(()) => { - log_ok!("Done"); + display::cmd_done("๐ŸชŸ", "minimize-all", "", true, "done"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("minimize-all failed: {e}"); + display::cmd_done("๐ŸชŸ", "minimize-all", "", false, &e); ClientMessage::Error { request_id, message: e } } } } ServerMessage::FocusWindowRequest { request_id, window_id } => { - log_cmd!("๐ŸชŸ", "focus-window {window_id}"); + let payload = format!("{window_id}"); + display::cmd_start("๐ŸชŸ", "focus-window", &payload); match windows_mgmt::focus_window(window_id) { Ok(()) => { - log_ok!("Done"); + display::cmd_done("๐ŸชŸ", "focus-window", &payload, true, "done"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("focus-window failed: {e}"); + display::cmd_done("๐ŸชŸ", "focus-window", &payload, false, &e); ClientMessage::Error { request_id, message: e } } } } ServerMessage::MaximizeAndFocusRequest { request_id, window_id } => { - log_cmd!("๐ŸชŸ", "maximize-and-focus {window_id}"); + let payload = format!("{window_id}"); + display::cmd_start("๐ŸชŸ", "maximize", &payload); match windows_mgmt::maximize_and_focus(window_id) { Ok(()) => { - log_ok!("Done"); + display::cmd_done("๐ŸชŸ", "maximize", &payload, true, "done"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("maximize-and-focus failed: {e}"); + display::cmd_done("๐ŸชŸ", "maximize", &payload, false, &e); ClientMessage::Error { request_id, message: e } } } } ServerMessage::VersionRequest { request_id } => { - log_cmd!("โ„น ", "version"); - ClientMessage::VersionResponse { - request_id, - version: env!("CARGO_PKG_VERSION").to_string(), - commit: env!("GIT_COMMIT").to_string(), - } + display::cmd_start("โ„น", "version", ""); + let version = env!("CARGO_PKG_VERSION").to_string(); + let commit = env!("GIT_COMMIT").to_string(); + display::cmd_done("โ„น", "version", "", true, &format!("v{version} ({commit})")); + ClientMessage::VersionResponse { request_id, version, commit } } ServerMessage::LogsRequest { request_id, lines } => { - log_cmd!("๐Ÿ“œ", "logs (last {lines} lines)"); + let payload = format!("last {lines} lines"); + display::cmd_start("๐Ÿ“œ", "logs", &payload); let content = logger::tail(lines); let log_path = logger::get_log_path(); + display::cmd_done("๐Ÿ“œ", "logs", &payload, true, &log_path); ClientMessage::LogsResponse { request_id, content, log_path } } ServerMessage::UploadRequest { request_id, path, content_base64 } => { - log_cmd!("โฌ† ", "upload โ†’ {}", path); + let payload = trunc(&path, 60); + display::cmd_start("โฌ†", "upload", &payload); match (|| -> Result<(), String> { let bytes = base64::engine::general_purpose::STANDARD .decode(&content_base64) @@ -586,74 +528,84 @@ async fn handle_message( Ok(()) })() { Ok(()) => { - log_ok!("Saved {}", path); + display::cmd_done("โฌ†", "upload", &payload, true, "saved"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("upload failed: {e}"); + display::cmd_done("โฌ†", "upload", &payload, false, &e); ClientMessage::Error { request_id, message: e } } } } ServerMessage::DownloadRequest { request_id, path } => { - log_cmd!("โฌ‡ ", "download โ† {}", path); + let payload = trunc(&path, 60); + display::cmd_start("โฌ‡", "download", &payload); match std::fs::read(&path) { Ok(bytes) => { let size = bytes.len() as u64; let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - log_ok!("Sent {} bytes", size); + display::cmd_done("โฌ‡", "download", &payload, true, &format!("{size} bytes")); ClientMessage::DownloadResponse { request_id, content_base64, size } } Err(e) => { - log_err!("download failed: {e}"); + display::cmd_done("โฌ‡", "download", &payload, false, &format!("read failed: {e}")); ClientMessage::Error { request_id, message: format!("Read failed: {e}") } } } } ServerMessage::RunRequest { request_id, program, args } => { - log_cmd!("๐Ÿš€", "run โ€บ {}", program); + let payload = if args.is_empty() { program.clone() } else { format!("{program} {}", args.join(" ")) }; + let payload = trunc(&payload, 60); + display::cmd_start("๐Ÿš€", "run", &payload); use std::process::Command as StdCommand; match StdCommand::new(&program).args(&args).spawn() { Ok(_) => { - log_ok!("Started {}", program); + display::cmd_done("๐Ÿš€", "run", &payload, true, "started"); ClientMessage::Ack { request_id } } Err(e) => { - log_err!("run failed: {e}"); + display::cmd_done("๐Ÿš€", "run", &payload, false, &format!("{e}")); ClientMessage::Error { request_id, message: format!("Failed to start '{}': {e}", program) } } } } ServerMessage::ClipboardGetRequest { request_id } => { - log_cmd!("๐Ÿ“‹", "clipboard-get"); + display::cmd_start("๐Ÿ“‹", "clipboard-get", ""); let out = tokio::process::Command::new("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard"]) .output().await; match out { Ok(o) => { let text = String::from_utf8_lossy(&o.stdout).trim().to_string(); - log_ok!("Got {} chars", text.len()); + display::cmd_done("๐Ÿ“‹", "clipboard-get", "", true, &format!("{} chars", text.len())); ClientMessage::ClipboardGetResponse { request_id, text } } - Err(e) => ClientMessage::Error { request_id, message: format!("Clipboard get failed: {e}") } + Err(e) => { + display::cmd_done("๐Ÿ“‹", "clipboard-get", "", false, &format!("{e}")); + ClientMessage::Error { request_id, message: format!("Clipboard get failed: {e}") } + } } } ServerMessage::ClipboardSetRequest { request_id, text } => { - log_cmd!("๐Ÿ“‹", "clipboard-set โ€บ {} chars", text.len()); + let payload = format!("{} chars", text.len()); + display::cmd_start("๐Ÿ“‹", "clipboard-set", &payload); let cmd = format!("Set-Clipboard -Value '{}'", text.replace('\'', "''")); let out = tokio::process::Command::new("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", &cmd]) .output().await; match out { Ok(_) => { - log_ok!("Set clipboard"); + display::cmd_done("๐Ÿ“‹", "clipboard-set", &payload, true, "done"); ClientMessage::Ack { request_id } } - Err(e) => ClientMessage::Error { request_id, message: format!("Clipboard set failed: {e}") } + Err(e) => { + display::cmd_done("๐Ÿ“‹", "clipboard-set", &payload, false, &format!("{e}")); + ClientMessage::Error { request_id, message: format!("Clipboard set failed: {e}") } + } } } @@ -662,7 +614,7 @@ async fn handle_message( } ServerMessage::Error { request_id, message } => { - log_err!("server error: {message}"); + display::err("โŒ", &format!("server error: {message}")); if let Some(rid) = request_id { ClientMessage::Ack { request_id: rid } } else {