diff --git a/crates/client/src/logger.rs b/crates/client/src/logger.rs new file mode 100644 index 0000000..21aa988 --- /dev/null +++ b/crates/client/src/logger.rs @@ -0,0 +1,59 @@ +/// File logger — writes structured log lines alongside the pretty terminal output. +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::sync::{Mutex, OnceLock}; + +static LOG_FILE: OnceLock> = OnceLock::new(); +static LOG_PATH: OnceLock = OnceLock::new(); + +pub fn init() { + let path = log_path(); + // Create parent dir if needed + if let Some(parent) = std::path::Path::new(&path).parent() { + let _ = std::fs::create_dir_all(parent); + } + match OpenOptions::new().create(true).append(true).open(&path) { + Ok(f) => { + LOG_PATH.set(path.clone()).ok(); + LOG_FILE.set(Mutex::new(f)).ok(); + write_line("INFO", "helios-remote started"); + } + Err(e) => eprintln!("[logger] Failed to open log file {path}: {e}"), + } +} + +fn log_path() -> String { + #[cfg(windows)] + { + let base = std::env::var("LOCALAPPDATA") + .unwrap_or_else(|_| "C:\\Temp".to_string()); + format!("{base}\\helios-remote\\helios-remote.log") + } + #[cfg(not(windows))] + { + "/tmp/helios-remote.log".to_string() + } +} + +pub fn get_log_path() -> String { + LOG_PATH.get().cloned().unwrap_or_else(log_path) +} + +pub fn write_line(level: &str, msg: &str) { + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + let line = format!("{now} [{level:<5}] {msg}\n"); + if let Some(mutex) = LOG_FILE.get() { + if let Ok(mut f) = mutex.lock() { + let _ = f.write_all(line.as_bytes()); + } + } +} + +/// Read the last `n` lines from the log file. +pub fn tail(n: u32) -> String { + let path = get_log_path(); + let content = std::fs::read_to_string(&path).unwrap_or_default(); + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(n as usize); + lines[start..].join("\n") +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index d271105..81bfd9f 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -13,6 +13,7 @@ use base64::Engine; use helios_common::{ClientMessage, ServerMessage}; use uuid::Uuid; +mod logger; mod shell; mod screenshot; mod input; @@ -76,21 +77,27 @@ macro_rules! log_status { } macro_rules! log_ok { - ($($arg:tt)*) => { - println!(" {}\t{}", "✅", format!($($arg)*)); - }; + ($($arg:tt)*) => {{ + let msg = format!($($arg)*); + println!(" {}\t{}", "✅", msg); + logger::write_line("OK", &msg); + }}; } macro_rules! log_err { - ($($arg:tt)*) => { - println!(" {}\t{}", "❌", format!($($arg)*)); - }; + ($($arg:tt)*) => {{ + let msg = format!($($arg)*); + println!(" {}\t{}", "❌", msg); + logger::write_line("ERROR", &msg); + }}; } macro_rules! log_cmd { - ($emoji:expr, $($arg:tt)*) => { - println!(" {}\t{}", $emoji, format!($($arg)*)); - }; + ($emoji:expr, $($arg:tt)*) => {{ + let msg = format!($($arg)*); + println!(" {}\t{}", $emoji, msg); + logger::write_line("CMD", &msg); + }}; } // ──────────────────────────────────────────────────────────────────────────── @@ -173,6 +180,7 @@ async fn main() { // Enable ANSI color codes on Windows (required when running as admin) #[cfg(windows)] enable_ansi(); + logger::init(); // Suppress tracing output by default if std::env::var("RUST_LOG").is_err() { @@ -558,6 +566,13 @@ async fn handle_message( } } + ServerMessage::LogsRequest { request_id, lines } => { + log_cmd!("📜", "logs (last {lines} lines)"); + let content = logger::tail(lines); + let log_path = logger::get_log_path(); + ClientMessage::LogsResponse { request_id, content, log_path } + } + ServerMessage::UploadRequest { request_id, path, content_base64 } => { log_cmd!("⬆ ", "upload → {}", path); match (|| -> Result<(), String> { diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs index 368c710..1d558a2 100644 --- a/crates/common/src/protocol.rs +++ b/crates/common/src/protocol.rs @@ -17,6 +17,8 @@ pub enum ServerMessage { ScreenshotRequest { request_id: Uuid }, /// Capture a specific window by its HWND (works even if behind other windows) WindowScreenshotRequest { request_id: Uuid, window_id: u64 }, + /// Fetch the last N lines of the client log file + LogsRequest { request_id: Uuid, lines: u32 }, /// Show a MessageBox on the client asking the user to do something. /// Blocks until the user clicks OK — use this when you need the user /// to perform a manual action before continuing. @@ -122,6 +124,11 @@ pub enum ClientMessage { version: String, commit: String, }, + LogsResponse { + request_id: Uuid, + content: String, + log_path: String, + }, /// Response to a download request DownloadResponse { request_id: Uuid, diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs index f7d4a02..0f5fb94 100644 --- a/crates/server/src/api.rs +++ b/crates/server/src/api.rs @@ -135,6 +135,29 @@ pub async fn window_screenshot( } } +/// GET /sessions/:id/logs?lines=100 +pub async fn logs( + Path(session_id): Path, + Query(params): Query>, + State(state): State, +) -> impl IntoResponse { + let lines: u32 = params.get("lines").and_then(|v| v.parse().ok()).unwrap_or(100); + match dispatch(&state, &session_id, "logs", |rid| { + ServerMessage::LogsRequest { request_id: rid, lines } + }).await { + Ok(ClientMessage::LogsResponse { content, log_path, .. }) => ( + StatusCode::OK, + Json(serde_json::json!({ "content": content, "log_path": log_path, "lines": lines })), + ).into_response(), + Ok(ClientMessage::Error { message, .. }) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": message })), + ).into_response(), + Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(), + Err(e) => e.into_response(), + } +} + /// POST /sessions/:id/screenshot pub async fn request_screenshot( Path(session_id): Path, diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 3ac2e45..bb90733 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -54,6 +54,7 @@ async fn main() -> anyhow::Result<()> { .route("/sessions/:id/prompt", post(api::prompt_user)) .route("/sessions/:id/windows", get(api::list_windows)) .route("/sessions/:id/windows/minimize-all", post(api::minimize_all)) + .route("/sessions/:id/logs", get(api::logs)) .route("/sessions/:id/windows/:window_id/screenshot", post(api::window_screenshot)) .route("/sessions/:id/windows/:window_id/focus", post(api::focus_window)) .route("/sessions/:id/windows/:window_id/maximize", post(api::maximize_and_focus)) diff --git a/skills/remote.py b/skills/remote.py index fbab462..c8ddfab 100644 --- a/skills/remote.py +++ b/skills/remote.py @@ -236,6 +236,17 @@ def cmd_maximize(args): print(f"Window {wid} maximized on session {sid!r}.") +def cmd_logs(args): + """Fetch the last N lines of the client log file.""" + sid = resolve_session(args.session_id) + resp = _req("GET", f"/sessions/{sid}/logs", params={"lines": args.lines}) + data = resp.json() + if "error" in data: + sys.exit(f"[helios-remote] {data['error']}") + print(f"# {data.get('log_path', '?')} (last {args.lines} lines)") + print(data.get("content", "")) + + def cmd_screenshot_window(args): """Capture a specific window by ID or title substring → /tmp/helios-remote-screenshot.png""" sid = resolve_session(args.session_id) @@ -484,6 +495,10 @@ def build_parser() -> argparse.ArgumentParser: stp = sub.add_parser("status", help="Show relay + client commit and sync status") stp.add_argument("session_id") + lgp = sub.add_parser("logs", help="Fetch last N lines of client log file") + lgp.add_argument("session_id") + lgp.add_argument("--lines", type=int, default=100, metavar="N", help="Number of lines (default: 100)") + sub.add_parser("server-version", help="Get server version (no auth required)") vp = sub.add_parser("version", help="Get client version for a session") @@ -550,6 +565,7 @@ def main(): "upload": cmd_upload, "download": cmd_download, "status": cmd_status, + "logs": cmd_logs, "screenshot-window": cmd_screenshot_window, "find-window": cmd_find_window, "wait-for-window": cmd_wait_for_window,