diff --git a/README.md b/README.md index 9d79b5397..447421002 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local - Worktree and clone agents for isolated work; worktrees live under the app data directory (legacy `.codex-worktrees` supported). - Thread management: pin/rename/archive/copy, per-thread drafts, and stop/interrupt in-flight turns. - Optional remote backend (daemon) mode for running Codex on another machine. -- Remote setup helpers for self-hosted connectivity (Tailscale detection/host bootstrap for TCP mode). +- Remote setup helpers for self-hosted connectivity (Tailscale host bootstrap for TCP mode and Cloudflare quick tunnel for WSS mode). ### Composer & Agent Controls @@ -101,6 +101,44 @@ Notes: - The desktop daemon must stay running while iOS is connected. - If the test fails, confirm both devices are online in Tailscale and that host/token match desktop settings. +### iOS + Cloudflare Tunnel Setup (WSS) + +Use this when connecting over public internet without requiring Tailscale on iOS. + +Recommended (desktop one-click): + +1. On desktop CodexMonitor, open `Settings > Server`. +2. Click `One-click WSS setup`. +3. CodexMonitor will: + - generate/save a remote password if missing, + - start the desktop daemon with TCP + WS listeners, + - start a Cloudflare quick tunnel, + - auto-fill the active remote host with the discovered `wss://...` URL. +4. On iOS CodexMonitor, open `Settings > Server`. +5. Enter the same password/token shown on desktop. +6. Tap `Connect & test`. + +Manual fallback: + +1. Start daemon with both TCP and WebSocket listeners: + +```bash +cd src-tauri +cargo run --bin codex_monitor_daemon -- \ + --listen 127.0.0.1:4732 \ + --ws-listen 127.0.0.1:4733 \ + --token "" +``` + +2. Run `cloudflared tunnel --url http://127.0.0.1:4733`. +3. Use the generated `https://...` endpoint as `wss://...` in iOS settings. + +Notes: + +- Keep desktop daemon/tunnel processes running while iOS is connected. +- Prefer Cloudflare Access / origin restrictions when exposing this endpoint. +- Rotate the remote token if endpoint scope changes. + ### Headless Daemon Management (No Desktop UI) Use the standalone daemon control CLI when you want iOS remote mode without keeping the desktop app open. @@ -126,6 +164,9 @@ Examples: # Print equivalent daemon start command ./target/debug/codex_monitor_daemonctl command-preview + +# Start daemon with optional WebSocket listener (for WSS reverse proxies/tunnels) +./target/debug/codex_monitor_daemon --listen 127.0.0.1:4732 --ws-listen 127.0.0.1:4733 --token "$TOKEN" ``` Useful overrides: @@ -313,4 +354,4 @@ Frontend calls live in `src/services/tauri.ts` and map to commands in `src-tauri - Git/GitHub: `get_git_status`, `list_git_roots`, `get_git_diffs`, `get_git_log`, `get_git_commit_diff`, `get_git_remote`, `stage_git_file`, `stage_git_all`, `unstage_git_file`, `revert_git_file`, `revert_git_all`, `commit_git`, `push_git`, `pull_git`, `fetch_git`, `sync_git`, `list_git_branches`, `checkout_git_branch`, `create_git_branch`, `get_github_issues`, `get_github_pull_requests`, `get_github_pull_request_diff`, `get_github_pull_request_comments`. - Prompts: `prompts_list`, `prompts_create`, `prompts_update`, `prompts_delete`, `prompts_move`, `prompts_workspace_dir`, `prompts_global_dir`. - Terminal/dictation/notifications/usage: `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close`, `dictation_model_status`, `dictation_download_model`, `dictation_cancel_download`, `dictation_remove_model`, `dictation_request_permission`, `dictation_start`, `dictation_stop`, `dictation_cancel`, `send_notification_fallback`, `is_macos_debug_build`, `local_usage_snapshot`. -- Remote backend helpers: `tailscale_status`, `tailscale_daemon_command_preview`, `tailscale_daemon_start`, `tailscale_daemon_stop`, `tailscale_daemon_status`. +- Remote backend helpers: `tailscale_status`, `tailscale_daemon_command_preview`, `tailscale_daemon_start`, `tailscale_daemon_stop`, `tailscale_daemon_status`, `cloudflare_tunnel_start`, `cloudflare_tunnel_stop`, `cloudflare_tunnel_status`, `cloudflare_tunnel_install`. diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 3a2cd0ce3..3b68744e2 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -74,6 +74,7 @@ use ignore::WalkBuilder; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, mpsc, Mutex, Semaphore}; +use tokio_tungstenite::accept_async; use backend::app_server::{spawn_workspace_session, WorkspaceSession}; use backend::events::{AppServerEvent, EventSink, TerminalExit, TerminalOutput}; @@ -93,6 +94,7 @@ use types::{ use workspace_settings::apply_workspace_settings_update; const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:4732"; +const DEFAULT_WS_LISTEN_ADDR: &str = "127.0.0.1:4733"; const MAX_IN_FLIGHT_RPC_PER_CONNECTION: usize = 32; const DAEMON_NAME: &str = "codex-monitor-daemon"; @@ -144,6 +146,7 @@ impl EventSink for DaemonEventSink { struct DaemonConfig { listen: SocketAddr, + ws_listen: Option, token: Option, data_dir: PathBuf, } @@ -757,8 +760,7 @@ impl DaemonState { limit: Option, sort_key: Option, ) -> Result { - codex_core::list_threads_core(&self.sessions, workspace_id, cursor, limit, sort_key) - .await + codex_core::list_threads_core(&self.sessions, workspace_id, cursor, limit, sort_key).await } async fn list_mcp_server_status( @@ -1487,8 +1489,8 @@ fn default_data_dir() -> PathBuf { fn usage() -> String { format!( "\ -USAGE:\n codex-monitor-daemon [--listen ] [--data-dir ] [--token | --insecure-no-auth]\n\n\ -OPTIONS:\n --listen Bind address (default: {DEFAULT_LISTEN_ADDR})\n --data-dir Data dir holding workspaces.json/settings.json\n --token Shared token required by TCP clients\n --insecure-no-auth Disable TCP auth (dev only)\n -h, --help Show this help\n" +USAGE:\n codex-monitor-daemon [--listen ] [--ws-listen ] [--data-dir ] [--token | --insecure-no-auth]\n\n\ +OPTIONS:\n --listen Bind address for TCP JSON-RPC (default: {DEFAULT_LISTEN_ADDR})\n --ws-listen Optional bind address for WebSocket JSON-RPC (example: {DEFAULT_WS_LISTEN_ADDR})\n --data-dir Data dir holding workspaces.json/settings.json\n --token Shared token required by clients\n --insecure-no-auth Disable auth (dev only)\n -h, --help Show this help\n" ) } @@ -1496,6 +1498,7 @@ fn parse_args() -> Result { let mut listen = DEFAULT_LISTEN_ADDR .parse::() .map_err(|err| err.to_string())?; + let mut ws_listen: Option = None; let mut token = env::var("CODEX_MONITOR_DAEMON_TOKEN") .ok() .map(|value| value.trim().to_string()) @@ -1514,6 +1517,10 @@ fn parse_args() -> Result { let value = args.next().ok_or("--listen requires a value")?; listen = value.parse::().map_err(|err| err.to_string())?; } + "--ws-listen" => { + let value = args.next().ok_or("--ws-listen requires a value")?; + ws_listen = Some(value.parse::().map_err(|err| err.to_string())?); + } "--token" => { let value = args.next().ok_or("--token requires a value")?; let trimmed = value.trim(); @@ -1547,6 +1554,7 @@ fn parse_args() -> Result { Ok(DaemonConfig { listen, + ws_listen, token, data_dir: data_dir.unwrap_or_else(default_data_dir), }) @@ -1926,9 +1934,24 @@ fn main() { std::process::exit(2); } }; + let ws_listener = if let Some(ws_addr) = config.ws_listen { + match TcpListener::bind(ws_addr).await { + Ok(listener) => Some(listener), + Err(err) => { + eprintln!("failed to bind websocket listener on {}: {err}", ws_addr); + std::process::exit(2); + } + } + } else { + None + }; eprintln!( - "codex-monitor-daemon listening on {} (data dir: {})", + "codex-monitor-daemon listening on {}{} (data dir: {})", config.listen, + config + .ws_listen + .map(|addr| format!(", ws {}", addr)) + .unwrap_or_default(), state .storage_path .parent() @@ -1936,18 +1959,53 @@ fn main() { .display() ); - loop { - match listener.accept().await { - Ok((socket, _addr)) => { - let config = Arc::clone(&config); - let state = Arc::clone(&state); - let events = events_tx.clone(); - tokio::spawn(async move { - transport::handle_client(socket, config, state, events).await; - }); + let config_for_tcp = Arc::clone(&config); + let state_for_tcp = Arc::clone(&state); + let events_for_tcp = events_tx.clone(); + let tcp_task = tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((socket, _addr)) => { + let config = Arc::clone(&config_for_tcp); + let state = Arc::clone(&state_for_tcp); + let events = events_for_tcp.clone(); + tokio::spawn(async move { + transport::handle_client(socket, config, state, events).await; + }); + } + Err(_) => continue, } - Err(_) => continue, } + }); + + if let Some(ws_listener) = ws_listener { + let config_for_ws = Arc::clone(&config); + let state_for_ws = Arc::clone(&state); + let events_for_ws = events_tx.clone(); + let ws_task = tokio::spawn(async move { + loop { + match ws_listener.accept().await { + Ok((socket, _addr)) => { + let config = Arc::clone(&config_for_ws); + let state = Arc::clone(&state_for_ws); + let events = events_for_ws.clone(); + tokio::spawn(async move { + if let Ok(ws_stream) = accept_async(socket).await { + transport::handle_websocket_client( + ws_stream, config, state, events, + ) + .await; + } + }); + } + Err(_) => continue, + } + } + }); + + let _ = futures_util::future::join(tcp_task, ws_task).await; + } else { + let _ = tcp_task.await; } }); } diff --git a/src-tauri/src/bin/codex_monitor_daemon/transport.rs b/src-tauri/src/bin/codex_monitor_daemon/transport.rs index ca4cb2769..6368b35d1 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/transport.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/transport.rs @@ -3,6 +3,82 @@ use super::rpc::{ spawn_rpc_response_task, }; use super::*; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use tokio_tungstenite::WebSocketStream; + +fn parse_message(line: &str) -> Option<(Option, String, Value)> { + let message: Value = serde_json::from_str(line).ok()?; + let id = message.get("id").and_then(|value| value.as_u64()); + let method = message + .get("method") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + if method.is_empty() { + return None; + } + let params = message.get("params").cloned().unwrap_or(Value::Null); + Some((id, method, params)) +} + +async fn process_rpc_line( + line: &str, + config: &Arc, + state: &Arc, + events: &broadcast::Sender, + out_tx: &mpsc::UnboundedSender, + authenticated: &mut bool, + events_task: &mut Option>, + request_limiter: &Arc, + client_version: &str, +) { + let line = line.trim(); + if line.is_empty() { + return; + } + let Some((id, method, params)) = parse_message(line) else { + return; + }; + + if !*authenticated { + if method != "auth" { + if let Some(response) = build_error_response(id, "unauthorized") { + let _ = out_tx.send(response); + } + return; + } + + let expected = config.token.clone().unwrap_or_default(); + let provided = parse_auth_token(¶ms).unwrap_or_default(); + if expected != provided { + if let Some(response) = build_error_response(id, "invalid token") { + let _ = out_tx.send(response); + } + return; + } + + *authenticated = true; + if let Some(response) = build_result_response(id, json!({ "ok": true })) { + let _ = out_tx.send(response); + } + + let rx = events.subscribe(); + let out_tx_events = out_tx.clone(); + *events_task = Some(tokio::spawn(forward_events(rx, out_tx_events))); + return; + } + + spawn_rpc_response_task( + Arc::clone(state), + out_tx.clone(), + id, + method, + params, + client_version.to_string(), + Arc::clone(request_limiter), + ); +} pub(super) async fn handle_client( socket: TcpStream, @@ -37,62 +113,95 @@ pub(super) async fn handle_client( } while let Ok(Some(line)) = lines.next_line().await { - let line = line.trim(); - if line.is_empty() { - continue; - } + process_rpc_line( + &line, + &config, + &state, + &events, + &out_tx, + &mut authenticated, + &mut events_task, + &request_limiter, + &client_version, + ) + .await; + } - let message: Value = match serde_json::from_str(line) { - Ok(value) => value, - Err(_) => continue, - }; + drop(out_tx); + if let Some(task) = events_task { + task.abort(); + } + write_task.abort(); +} - let id = message.get("id").and_then(|value| value.as_u64()); - let method = message - .get("method") - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string(); - let params = message.get("params").cloned().unwrap_or(Value::Null); - - if !authenticated { - if method != "auth" { - if let Some(response) = build_error_response(id, "unauthorized") { - let _ = out_tx.send(response); - } - continue; - } +pub(super) async fn handle_websocket_client( + socket: WebSocketStream, + config: Arc, + state: Arc, + events: broadcast::Sender, +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let (mut writer, mut reader) = socket.split(); - let expected = config.token.clone().unwrap_or_default(); - let provided = parse_auth_token(¶ms).unwrap_or_default(); - if expected != provided { - if let Some(response) = build_error_response(id, "invalid token") { - let _ = out_tx.send(response); - } - continue; + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let write_task = tokio::spawn(async move { + while let Some(message) = out_rx.recv().await { + if writer.send(WsMessage::Text(message)).await.is_err() { + break; } + } + }); - authenticated = true; - if let Some(response) = build_result_response(id, json!({ "ok": true })) { - let _ = out_tx.send(response); - } + let mut authenticated = config.token.is_none(); + let mut events_task: Option> = None; + let request_limiter = Arc::new(Semaphore::new(MAX_IN_FLIGHT_RPC_PER_CONNECTION)); + let client_version = format!("daemon-{}", env!("CARGO_PKG_VERSION")); - let rx = events.subscribe(); - let out_tx_events = out_tx.clone(); - events_task = Some(tokio::spawn(forward_events(rx, out_tx_events))); + if authenticated { + let rx = events.subscribe(); + let out_tx_events = out_tx.clone(); + events_task = Some(tokio::spawn(forward_events(rx, out_tx_events))); + } - continue; + while let Some(frame) = reader.next().await { + let Ok(frame) = frame else { + break; + }; + match frame { + WsMessage::Text(line) => { + process_rpc_line( + line.as_str(), + &config, + &state, + &events, + &out_tx, + &mut authenticated, + &mut events_task, + &request_limiter, + &client_version, + ) + .await; + } + WsMessage::Binary(bytes) => { + if let Ok(line) = String::from_utf8(bytes.to_vec()) { + process_rpc_line( + &line, + &config, + &state, + &events, + &out_tx, + &mut authenticated, + &mut events_task, + &request_limiter, + &client_version, + ) + .await; + } + } + WsMessage::Close(_) => break, + _ => {} } - - spawn_rpc_response_task( - Arc::clone(&state), - out_tx.clone(), - id, - method, - params, - client_version.clone(), - Arc::clone(&request_limiter), - ); } drop(out_tx); diff --git a/src-tauri/src/bin/codex_monitor_daemonctl.rs b/src-tauri/src/bin/codex_monitor_daemonctl.rs index f31c6aef9..6dd656767 100644 --- a/src-tauri/src/bin/codex_monitor_daemonctl.rs +++ b/src-tauri/src/bin/codex_monitor_daemonctl.rs @@ -91,6 +91,7 @@ async fn run() -> Result<(), String> { let settings = load_settings(&data_dir); let listen_addr = resolve_listen_addr(args.listen.as_deref(), settings.as_ref())?; + let ws_listen_addr = daemon_ws_listen_addr(&listen_addr); let token = if args.insecure_no_auth { None } else { @@ -105,6 +106,7 @@ async fn run() -> Result<(), String> { &data_dir, token.is_some(), &listen_addr, + &ws_listen_addr, args.insecure_no_auth, ); if args.json { @@ -133,6 +135,7 @@ async fn run() -> Result<(), String> { let daemon_path = resolve_daemon_path(args.daemon_path.as_deref())?; let status = daemon_start( &listen_addr, + &ws_listen_addr, token.as_deref(), args.insecure_no_auth, &data_dir, @@ -346,6 +349,12 @@ fn daemon_listen_addr(remote_host: &str) -> String { format!("0.0.0.0:{port}") } +fn daemon_ws_listen_addr(listen_addr: &str) -> String { + let port = parse_port_from_remote_host(listen_addr).unwrap_or(4732); + let ws_port = if port < u16::MAX { port + 1 } else { 4733 }; + format!("127.0.0.1:{ws_port}") +} + fn parse_port_from_remote_host(remote_host: &str) -> Option { let trimmed = remote_host.trim(); if trimmed.is_empty() { @@ -384,6 +393,7 @@ fn daemon_command_preview( data_dir: &Path, token_configured: bool, listen_addr: &str, + ws_listen_addr: &str, insecure_no_auth: bool, ) -> TailscaleDaemonCommandPreview { let daemon_path_str = daemon_path.to_string_lossy().to_string(); @@ -393,6 +403,8 @@ fn daemon_command_preview( vec![ "--listen".to_string(), listen_addr.to_string(), + "--ws-listen".to_string(), + ws_listen_addr.to_string(), "--data-dir".to_string(), data_dir_str.clone(), "--insecure-no-auth".to_string(), @@ -401,6 +413,8 @@ fn daemon_command_preview( vec![ "--listen".to_string(), listen_addr.to_string(), + "--ws-listen".to_string(), + ws_listen_addr.to_string(), "--data-dir".to_string(), data_dir_str.clone(), "--token".to_string(), @@ -992,6 +1006,7 @@ async fn resolve_daemon_pid(listen_addr: &str, expected_pid: Option) -> Opt async fn daemon_start( listen_addr: &str, + ws_listen_addr: &str, token: Option<&str>, insecure_no_auth: bool, data_dir: &Path, @@ -1099,6 +1114,8 @@ async fn daemon_start( command .arg("--listen") .arg(listen_addr) + .arg("--ws-listen") + .arg(ws_listen_addr) .arg("--data-dir") .arg(data_dir) .stdin(Stdio::null()) @@ -1278,10 +1295,11 @@ fn print_status(status: &TcpDaemonStatus, as_json: bool) -> Result<(), String> { #[cfg(test)] mod tests { use super::{ - daemon_connect_addr, daemon_listen_addr, local_listener_port, parse_netstat_listener_pid, - parse_port_from_remote_host, parse_ss_listener_pid, resolve_listen_addr, safe_force_stop_pid, - shell_quote, + daemon_command_preview, daemon_connect_addr, daemon_listen_addr, daemon_ws_listen_addr, + local_listener_port, parse_netstat_listener_pid, parse_port_from_remote_host, + parse_ss_listener_pid, resolve_listen_addr, safe_force_stop_pid, shell_quote, }; + use std::path::Path; #[test] fn parses_listen_port_from_host() { @@ -1307,6 +1325,32 @@ mod tests { assert_eq!(daemon_listen_addr("mac.example.ts.net"), "0.0.0.0:4732"); } + #[test] + fn builds_ws_listen_addr_with_port_offset() { + assert_eq!(daemon_ws_listen_addr("0.0.0.0:8888"), "127.0.0.1:8889"); + assert_eq!(daemon_ws_listen_addr("0.0.0.0:4732"), "127.0.0.1:4733"); + } + + #[test] + fn preview_includes_ws_listen_args() { + let preview = daemon_command_preview( + Path::new("/tmp/codex-monitor-daemon"), + Path::new("/tmp/data"), + true, + "0.0.0.0:4732", + "127.0.0.1:4733", + false, + ); + assert!(preview.command.contains("--ws-listen")); + assert!(preview.command.contains("127.0.0.1:4733")); + assert!( + preview + .args + .windows(2) + .any(|pair| pair[0] == "--ws-listen" && pair[1] == "127.0.0.1:4733") + ); + } + #[test] fn shell_quote_handles_single_quotes() { let rendered = shell_quote("abc'def"); diff --git a/src-tauri/src/cloudflare.rs b/src-tauri/src/cloudflare.rs new file mode 100644 index 000000000..95886a47f --- /dev/null +++ b/src-tauri/src/cloudflare.rs @@ -0,0 +1,958 @@ +use std::ffi::{OsStr, OsString}; +use std::io::ErrorKind; +use std::path::PathBuf; +use std::process::{Output, Stdio}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use tauri::State; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::time::sleep; + +use crate::shared::process_core::{kill_child_process_tree, tokio_command}; +use crate::state::{AppState, CloudflareTunnelRuntime}; +use crate::types::{CloudflareTunnelStatus, TcpDaemonState}; + +#[cfg(any(target_os = "android", target_os = "ios"))] +const UNSUPPORTED_MESSAGE: &str = "Cloudflare tunnel integration is only available on desktop."; +const DEFAULT_WS_PORT: u16 = 4733; +const URL_WAIT_TIMEOUT_MS: u64 = 8_000; +const URL_WAIT_INTERVAL_MS: u64 = 150; +const MANAGED_TUNNEL_LABEL_PREFIX: &str = "codexmonitor-managed-ws"; + +fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +fn parse_port_from_remote_host(remote_host: &str) -> Option { + if remote_host.trim().is_empty() { + return None; + } + if let Ok(addr) = remote_host.trim().parse::() { + return Some(addr.port()); + } + remote_host + .trim() + .rsplit_once(':') + .and_then(|(_, port)| port.parse::().ok()) +} + +fn ws_port_from_remote_host(remote_host: &str) -> u16 { + match parse_port_from_remote_host(remote_host) { + Some(port) if port < u16::MAX => port + 1, + _ => DEFAULT_WS_PORT, + } +} + +fn local_ws_target_url(settings: &crate::types::AppSettings) -> String { + let port = ws_port_from_remote_host(&settings.remote_backend_host); + format!("http://127.0.0.1:{port}") +} + +fn local_ws_port(local_url: &str) -> Option { + local_url + .strip_prefix("http://127.0.0.1:") + .and_then(|raw| raw.trim().parse::().ok()) +} + +fn managed_tunnel_label(local_url: &str) -> String { + let suffix = local_ws_port(local_url).unwrap_or(DEFAULT_WS_PORT); + format!("{MANAGED_TUNNEL_LABEL_PREFIX}-{suffix}") +} + +fn cloudflare_tunnel_pidfile_path(state: &AppState, local_url: &str) -> Option { + let parent = state.settings_path.parent()?; + let suffix = local_ws_port(local_url).unwrap_or(DEFAULT_WS_PORT); + Some(parent.join(format!("cloudflare_tunnel_{suffix}.pid"))) +} + +fn remove_pidfile_if_exists(path: &PathBuf) { + if let Err(err) = std::fs::remove_file(path) { + if err.kind() != ErrorKind::NotFound { + eprintln!( + "cloudflare: failed to remove pidfile {}: {err}", + path.display() + ); + } + } +} + +fn command_matches_legacy_managed_tunnel(command: &str, local_url: &str) -> bool { + let normalized = command.trim(); + if normalized.is_empty() { + return false; + } + if !normalized.contains("cloudflared") { + return false; + } + // Legacy CodexMonitor launcher signature (before label/pidfile support). + normalized.contains("cloudflared tunnel") + && normalized.contains("--url") + && normalized.contains(local_url) + && normalized.contains("--no-autoupdate") + && normalized.contains("--protocol") + && normalized.contains("http2") + && normalized.contains("--loglevel") + && normalized.contains("info") + && !normalized.contains("tunnel run") +} + +fn command_matches_managed_tunnel(command: &str, label: &str, local_url: &str) -> bool { + let normalized = command.trim(); + if normalized.is_empty() { + return false; + } + (normalized.contains("cloudflared") + && normalized.contains(label) + && normalized.contains(local_url) + && normalized.contains("--url")) + || command_matches_legacy_managed_tunnel(normalized, local_url) +} + +#[cfg(unix)] +fn is_pid_running(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return true; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(code) => code != libc::ESRCH, + None => false, + } +} + +#[cfg(unix)] +async fn command_line_for_pid(pid: u32) -> Option { + let output = tokio_command("ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + None + } else { + Some(value) + } +} + +#[cfg(unix)] +async fn managed_tunnel_pids_by_scan(label: &str, local_url: &str) -> Vec { + let output = match tokio_command("ps").args(["-axo", "pid=,command="]).output().await { + Ok(value) => value, + Err(_) => return Vec::new(), + }; + if !output.status.success() { + return Vec::new(); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let separator = trimmed.find(char::is_whitespace)?; + let (pid_raw, command_raw) = trimmed.split_at(separator); + let pid = pid_raw.trim().parse::().ok()?; + let command = command_raw.trim(); + if command_matches_managed_tunnel(command, label, local_url) { + Some(pid) + } else { + None + } + }) + .collect() +} + +#[cfg(unix)] +fn read_pid_from_file(path: &PathBuf) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + raw.trim().parse::().ok() +} + +#[cfg(unix)] +async fn managed_tunnel_pid_from_file( + pidfile_path: &PathBuf, + label: &str, + local_url: &str, +) -> Option { + let pid = read_pid_from_file(pidfile_path)?; + let command = command_line_for_pid(pid).await?; + if command_matches_managed_tunnel(&command, label, local_url) { + Some(pid) + } else { + None + } +} + +#[cfg(unix)] +async fn kill_pid_gracefully(pid: u32) -> Result<(), String> { + let term_result = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + if term_result != 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::ESRCH) { + return Err(format!("Failed to stop cloudflared process {pid}: {err}")); + } + return Ok(()); + } + + for _ in 0..12 { + if !is_pid_running(pid) { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + + let kill_result = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + if kill_result != 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::ESRCH) { + return Err(format!("Failed to force-stop cloudflared process {pid}: {err}")); + } + } + + for _ in 0..8 { + if !is_pid_running(pid) { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + Err(format!("cloudflared process {pid} is still running.")) +} + +#[cfg(unix)] +async fn collect_managed_tunnel_pids( + local_url: &str, + label: &str, + pidfile_path: Option<&PathBuf>, +) -> Vec { + let mut pids = managed_tunnel_pids_by_scan(label, local_url).await; + if let Some(path) = pidfile_path { + if let Some(pid) = managed_tunnel_pid_from_file(path, label, local_url).await { + if !pids.contains(&pid) { + pids.push(pid); + } + } + } + pids.sort_unstable(); + pids.dedup(); + pids +} + +#[cfg(unix)] +async fn stop_managed_tunnels(local_url: &str, label: &str, pidfile_path: Option<&PathBuf>) { + let pids = collect_managed_tunnel_pids(local_url, label, pidfile_path).await; + for pid in pids { + if let Err(err) = kill_pid_gracefully(pid).await { + eprintln!("cloudflare: failed to stop managed tunnel pid {pid}: {err}"); + } + } +} + +#[cfg(unix)] +async fn stop_selected_pids(pids: &[u32]) { + for pid in pids { + if let Err(err) = kill_pid_gracefully(*pid).await { + eprintln!("cloudflare: failed to stop managed tunnel pid {pid}: {err}"); + } + } +} + +#[cfg(not(unix))] +async fn collect_managed_tunnel_pids( + _local_url: &str, + _label: &str, + _pidfile_path: Option<&PathBuf>, +) -> Vec { + Vec::new() +} + +#[cfg(not(unix))] +async fn stop_managed_tunnels(_local_url: &str, _label: &str, _pidfile_path: Option<&PathBuf>) {} + +#[cfg(not(unix))] +async fn stop_selected_pids(_pids: &[u32]) {} + +fn cloudflared_binary_candidates() -> Vec { + let mut candidates = vec![OsString::from("cloudflared")]; + + #[cfg(target_os = "macos")] + { + candidates.push(OsString::from("/opt/homebrew/bin/cloudflared")); + candidates.push(OsString::from("/usr/local/bin/cloudflared")); + } + + #[cfg(target_os = "linux")] + { + candidates.push(OsString::from("/usr/bin/cloudflared")); + candidates.push(OsString::from("/usr/local/bin/cloudflared")); + candidates.push(OsString::from("/run/current-system/sw/bin/cloudflared")); + } + + #[cfg(target_os = "windows")] + { + candidates.push(OsString::from( + "C:\\Program Files\\Cloudflare\\Cloudflared\\cloudflared.exe", + )); + } + + candidates +} + +fn trim_to_non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) +} + +fn looks_like_cloudflared_version(stdout: &str) -> bool { + let lower = stdout.to_ascii_lowercase(); + lower.contains("cloudflared") + && lower.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '.').any(|token| { + let parts: Vec<&str> = token.split('.').collect(); + parts.len() >= 2 + && parts + .iter() + .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) + }) +} + +fn cloudflared_version_from_output(output: &Output) -> Option { + trim_to_non_empty(std::str::from_utf8(&output.stdout).ok()) + .and_then(|raw| raw.lines().next().map(str::trim).map(str::to_string)) +} + +async fn resolve_cloudflared_binary() -> Result, String> { + let mut failures: Vec = Vec::new(); + for binary in cloudflared_binary_candidates() { + let output = tokio_command(binary.as_os_str()) + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + match output { + Ok(version_output) => { + let stdout = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()); + let stderr = trim_to_non_empty(std::str::from_utf8(&version_output.stderr).ok()); + if version_output.status.success() + && stdout.as_deref().is_some_and(looks_like_cloudflared_version) + { + return Ok(Some((binary, version_output))); + } + let detail = match (stdout, stderr) { + (Some(out), Some(err)) => format!("stdout: {out}; stderr: {err}"), + (Some(out), None) => format!("stdout: {out}"), + (None, Some(err)) => format!("stderr: {err}"), + (None, None) => "no output".to_string(), + }; + failures.push(format!( + "{}: cloudflared --version failed or returned unexpected output ({detail})", + OsStr::new(&binary).to_string_lossy() + )); + } + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => failures.push(format!("{}: {err}", OsStr::new(&binary).to_string_lossy())), + } + } + + if failures.is_empty() { + Ok(None) + } else { + Err(format!( + "Failed to run cloudflared --version from candidate paths: {}", + failures.join(" | ") + )) + } +} + +fn missing_cloudflared_message() -> String { + "cloudflared CLI not found. Install it first (for macOS: `brew install cloudflared`)." + .to_string() +} + +fn summarize_command_output(output: &Output) -> String { + let stdout = std::str::from_utf8(&output.stdout).ok().map(str::trim).unwrap_or(""); + let stderr = std::str::from_utf8(&output.stderr).ok().map(str::trim).unwrap_or(""); + let mut sections: Vec = Vec::new(); + if !stdout.is_empty() { + sections.push(format!("stdout: {stdout}")); + } + if !stderr.is_empty() { + sections.push(format!("stderr: {stderr}")); + } + let combined = if sections.is_empty() { + "no output".to_string() + } else { + sections.join("; ") + }; + const MAX_LEN: usize = 700; + if combined.len() <= MAX_LEN { + combined + } else { + format!("{}…", &combined[..MAX_LEN]) + } +} + +async fn install_cloudflared_cli() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let brew_check = tokio_command("brew") + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + "Homebrew is not installed. Install Homebrew first, then retry cloudflared install." + .to_string() + } else { + format!("Failed to run `brew --version`: {err}") + } + })?; + if !brew_check.status.success() { + return Err(format!( + "`brew --version` failed: {}", + summarize_command_output(&brew_check) + )); + } + + let install = tokio_command("brew") + .arg("install") + .arg("cloudflared") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|err| format!("Failed to run `brew install cloudflared`: {err}"))?; + if !install.status.success() { + return Err(format!( + "`brew install cloudflared` failed: {}", + summarize_command_output(&install) + )); + } + return Ok(()); + } + + #[cfg(target_os = "windows")] + { + return Err( + "One-click cloudflared install is not available yet on Windows. Install cloudflared manually and retry." + .to_string(), + ); + } + + #[cfg(target_os = "linux")] + { + return Err( + "One-click cloudflared install is not available yet on Linux. Install cloudflared manually and retry." + .to_string(), + ); + } + + #[allow(unreachable_code)] + Err("One-click cloudflared install is not supported on this platform.".to_string()) +} + +fn extract_https_host(candidate: &str) -> Option { + let rest = candidate.strip_prefix("https://")?; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let host_port = authority.rsplit('@').next().unwrap_or(authority); + if host_port.is_empty() { + return None; + } + + if let Some(stripped) = host_port.strip_prefix('[') { + let end = stripped.find(']')?; + let host = &stripped[..end]; + if host.is_empty() { + return None; + } + return Some(host.to_ascii_lowercase()); + } + + let host = host_port.split(':').next().unwrap_or_default(); + if host.is_empty() { + return None; + } + Some(host.to_ascii_lowercase()) +} + +fn extract_public_url_from_line(line: &str) -> Option { + for token in line.split_whitespace() { + let trimmed = token + .trim() + .trim_matches('"') + .trim_matches('\'') + .trim_matches('(') + .trim_matches(')') + .trim_matches('[') + .trim_matches(']') + .trim_end_matches(',') + .trim_end_matches(';') + .trim_end_matches('.'); + if !trimmed.starts_with("https://") { + continue; + } + let Some(host_lower) = extract_https_host(trimmed) else { + continue; + }; + if host_lower == "trycloudflare.com" || host_lower.ends_with(".trycloudflare.com") { + return Some(trimmed.to_string()); + } + } + None +} + +fn to_suggested_wss_url(public_url: &str) -> Option { + let trimmed = public_url.trim(); + if let Some(rest) = trimmed.strip_prefix("https://") { + return Some(format!("wss://{rest}")); + } + if trimmed.starts_with("wss://") { + return Some(trimmed.to_string()); + } + None +} + +fn spawn_log_reader_task( + reader: R, + discovered_public_url: Arc>>, +) -> tokio::task::JoinHandle<()> +where + R: AsyncRead + Unpin + Send + 'static, +{ + tokio::spawn(async move { + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Some(url) = extract_public_url_from_line(&line) { + let mut discovered = discovered_public_url.lock().await; + if discovered.is_none() { + *discovered = Some(url); + } + } + } + }) +} + +async fn sync_discovered_public_url(runtime: &mut CloudflareTunnelRuntime) { + let discovered = runtime.discovered_public_url.lock().await.clone(); + if let Some(public_url) = discovered { + runtime.status.public_url = Some(public_url.clone()); + runtime.status.suggested_wss_url = to_suggested_wss_url(&public_url); + } +} + +async fn refresh_cloudflare_runtime(runtime: &mut CloudflareTunnelRuntime) { + let Some(child) = runtime.child.as_mut() else { + if matches!(runtime.status.state, TcpDaemonState::Running) { + runtime.status.state = TcpDaemonState::Stopped; + runtime.status.pid = None; + runtime.status.started_at_ms = None; + } + return; + }; + + match child.try_wait() { + Ok(Some(status)) => { + let pid = child.id(); + runtime.child = None; + if status.success() { + runtime.status.state = TcpDaemonState::Stopped; + runtime.status.pid = pid; + runtime.status.started_at_ms = None; + runtime.status.last_error = None; + } else { + runtime.status.state = TcpDaemonState::Error; + runtime.status.pid = pid; + runtime.status.last_error = + Some(format!("Cloudflare tunnel exited with status: {status}.")); + } + if let Some(task) = runtime.stdout_task.take() { + task.abort(); + } + if let Some(task) = runtime.stderr_task.take() { + task.abort(); + } + } + Ok(None) => { + runtime.status.state = TcpDaemonState::Running; + runtime.status.pid = child.id(); + } + Err(err) => { + runtime.status.state = TcpDaemonState::Error; + runtime.status.pid = child.id(); + runtime.status.last_error = + Some(format!("Failed to inspect Cloudflare tunnel process: {err}")); + } + } +} + +async fn ensure_local_target_reachable(local_url: &str) -> Result<(), String> { + let Some(connect_addr) = local_url.strip_prefix("http://") else { + return Err(format!( + "Invalid Cloudflare local URL `{local_url}`. Expected format http://127.0.0.1:." + )); + }; + let attempt = tokio::time::timeout(Duration::from_millis(1500), TcpStream::connect(connect_addr)) + .await + .map_err(|_| { + format!( + "Timed out connecting to daemon WebSocket listener at {connect_addr}. Start mobile access daemon first." + ) + })?; + attempt.map_err(|err| { + format!( + "Cannot reach daemon WebSocket listener at {connect_addr}: {err}. Start mobile access daemon first." + ) + })?; + Ok(()) +} + +async fn wait_for_public_url(discovered_public_url: Arc>>) -> Option { + let start = now_unix_ms(); + loop { + if let Some(url) = discovered_public_url.lock().await.clone() { + return Some(url); + } + if now_unix_ms() - start >= URL_WAIT_TIMEOUT_MS as i64 { + return None; + } + sleep(Duration::from_millis(URL_WAIT_INTERVAL_MS)).await; + } +} + +#[tauri::command] +pub(crate) async fn cloudflare_tunnel_start( + state: State<'_, AppState>, +) -> Result { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return Err(UNSUPPORTED_MESSAGE.to_string()); + } + + let settings = state.app_settings.lock().await.clone(); + let _token = settings + .remote_backend_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Set a remote backend token (password) before starting tunnel.".to_string())?; + + let (cloudflared_binary, version_output) = resolve_cloudflared_binary() + .await? + .ok_or_else(missing_cloudflared_message)?; + let version = cloudflared_version_from_output(&version_output); + let local_url = local_ws_target_url(&settings); + let managed_label = managed_tunnel_label(&local_url); + let pidfile_path = cloudflare_tunnel_pidfile_path(&state, &local_url); + ensure_local_target_reachable(&local_url).await?; + stop_managed_tunnels(&local_url, &managed_label, pidfile_path.as_ref()).await; + if let Some(path) = &pidfile_path { + remove_pidfile_if_exists(path); + } + + let discovered_public_url = { + let mut runtime = state.cloudflare_tunnel.lock().await; + refresh_cloudflare_runtime(&mut runtime).await; + sync_discovered_public_url(&mut runtime).await; + + runtime.status.installed = true; + runtime.status.version = version.clone(); + runtime.status.local_url = Some(local_url.clone()); + + if matches!(runtime.status.state, TcpDaemonState::Running) { + return Ok(runtime.status.clone()); + } + + *runtime.discovered_public_url.lock().await = None; + let mut command = tokio_command(cloudflared_binary.as_os_str()); + command + .arg("--label") + .arg(&managed_label) + .arg("tunnel") + .arg("--url") + .arg(&local_url) + .arg("--no-autoupdate") + .arg("--protocol") + .arg("http2") + .arg("--loglevel") + .arg("info"); + if let Some(path) = &pidfile_path { + command.arg("--pidfile").arg(path); + } + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("Failed to start Cloudflare tunnel: {err}"))?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let pid = child.id(); + let discovered = Arc::clone(&runtime.discovered_public_url); + runtime.stdout_task = stdout.map(|stream| spawn_log_reader_task(stream, Arc::clone(&discovered))); + runtime.stderr_task = stderr.map(|stream| spawn_log_reader_task(stream, Arc::clone(&discovered))); + runtime.child = Some(child); + runtime.status = CloudflareTunnelStatus { + state: TcpDaemonState::Running, + pid, + started_at_ms: Some(now_unix_ms()), + last_error: None, + local_url: Some(local_url.clone()), + public_url: None, + suggested_wss_url: None, + installed: true, + version: version.clone(), + }; + discovered + }; + + let _ = wait_for_public_url(discovered_public_url).await; + + let mut runtime = state.cloudflare_tunnel.lock().await; + refresh_cloudflare_runtime(&mut runtime).await; + sync_discovered_public_url(&mut runtime).await; + runtime.status.installed = true; + runtime.status.version = version; + runtime.status.local_url = Some(local_url); + if matches!(runtime.status.state, TcpDaemonState::Running) && runtime.status.public_url.is_none() { + runtime.status.last_error = Some( + "Tunnel started but public URL is not ready yet. Click Refresh status in a few seconds." + .to_string(), + ); + } + Ok(runtime.status.clone()) +} + +#[tauri::command] +pub(crate) async fn cloudflare_tunnel_stop( + state: State<'_, AppState>, +) -> Result { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return Err(UNSUPPORTED_MESSAGE.to_string()); + } + + let settings = state.app_settings.lock().await.clone(); + let local_url = local_ws_target_url(&settings); + let managed_label = managed_tunnel_label(&local_url); + let pidfile_path = cloudflare_tunnel_pidfile_path(&state, &local_url); + + let mut runtime = state.cloudflare_tunnel.lock().await; + if let Some(mut child) = runtime.child.take() { + kill_child_process_tree(&mut child).await; + let _ = child.wait().await; + } + if let Some(task) = runtime.stdout_task.take() { + task.abort(); + } + if let Some(task) = runtime.stderr_task.take() { + task.abort(); + } + *runtime.discovered_public_url.lock().await = None; + stop_managed_tunnels(&local_url, &managed_label, pidfile_path.as_ref()).await; + if let Some(path) = &pidfile_path { + remove_pidfile_if_exists(path); + } + + runtime.status.state = TcpDaemonState::Stopped; + runtime.status.pid = None; + runtime.status.started_at_ms = None; + runtime.status.last_error = None; + runtime.status.local_url = Some(local_url); + runtime.status.public_url = None; + runtime.status.suggested_wss_url = None; + + match resolve_cloudflared_binary().await { + Ok(Some((_binary, version_output))) => { + runtime.status.installed = true; + runtime.status.version = cloudflared_version_from_output(&version_output); + } + Ok(None) => { + runtime.status.installed = false; + runtime.status.version = None; + runtime.status.last_error = Some(missing_cloudflared_message()); + } + Err(err) => { + runtime.status.installed = true; + runtime.status.last_error = Some(err); + } + } + + Ok(runtime.status.clone()) +} + +#[tauri::command] +pub(crate) async fn cloudflare_tunnel_status( + state: State<'_, AppState>, +) -> Result { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return Err(UNSUPPORTED_MESSAGE.to_string()); + } + + let settings = state.app_settings.lock().await.clone(); + let local_url = local_ws_target_url(&settings); + let managed_label = managed_tunnel_label(&local_url); + let pidfile_path = cloudflare_tunnel_pidfile_path(&state, &local_url); + + let cloudflared = resolve_cloudflared_binary().await; + let mut runtime = state.cloudflare_tunnel.lock().await; + refresh_cloudflare_runtime(&mut runtime).await; + sync_discovered_public_url(&mut runtime).await; + let managed_pids = collect_managed_tunnel_pids(&local_url, &managed_label, pidfile_path.as_ref()).await; + if managed_pids.len() > 1 { + stop_selected_pids(&managed_pids[1..]).await; + } + if managed_pids.is_empty() { + if let Some(path) = &pidfile_path { + remove_pidfile_if_exists(path); + } + } + if !matches!(runtime.status.state, TcpDaemonState::Running) { + if let Some(pid) = managed_pids.first().copied() { + runtime.status.state = TcpDaemonState::Running; + runtime.status.pid = Some(pid); + if runtime.status.started_at_ms.is_none() { + runtime.status.started_at_ms = Some(now_unix_ms()); + } + runtime.status.last_error = None; + } + } + runtime.status.local_url = Some(local_url); + + match cloudflared { + Ok(Some((_binary, version_output))) => { + let missing_message = missing_cloudflared_message(); + runtime.status.installed = true; + runtime.status.version = cloudflared_version_from_output(&version_output); + if runtime.status.last_error.as_deref() == Some(missing_message.as_str()) { + runtime.status.last_error = None; + } + } + Ok(None) => { + runtime.status.installed = false; + runtime.status.version = None; + if matches!(runtime.status.state, TcpDaemonState::Running) { + runtime.status.last_error = Some( + "cloudflared CLI is unavailable in current environment.".to_string(), + ); + runtime.status.state = TcpDaemonState::Error; + } else { + runtime.status.last_error = Some(missing_cloudflared_message()); + } + } + Err(err) => { + runtime.status.installed = true; + runtime.status.version = None; + runtime.status.last_error = Some(err); + if !matches!(runtime.status.state, TcpDaemonState::Running) { + runtime.status.state = TcpDaemonState::Error; + } + } + } + + Ok(runtime.status.clone()) +} + +#[tauri::command] +pub(crate) async fn cloudflare_tunnel_install( + state: State<'_, AppState>, +) -> Result { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + return Err(UNSUPPORTED_MESSAGE.to_string()); + } + + install_cloudflared_cli().await?; + cloudflare_tunnel_status(state).await +} + +#[cfg(test)] +mod tests { + use super::{ + command_matches_legacy_managed_tunnel, command_matches_managed_tunnel, + extract_https_host, extract_public_url_from_line, managed_tunnel_label, + to_suggested_wss_url, ws_port_from_remote_host, + }; + + #[test] + fn extracts_public_url_from_quick_tunnel_line() { + let line = "Your quick Tunnel has been created! Visit it at https://abc.trycloudflare.com"; + let url = extract_public_url_from_line(line); + assert_eq!(url.as_deref(), Some("https://abc.trycloudflare.com")); + } + + #[test] + fn ignores_non_tunnel_https_links() { + let line = "Read docs: https://www.cloudflare.com/website-terms/) for details"; + let url = extract_public_url_from_line(line); + assert_eq!(url, None); + } + + #[test] + fn extracts_https_host_with_user_info_and_port() { + let host = extract_https_host("https://user:pass@abc.trycloudflare.com:443/path"); + assert_eq!(host.as_deref(), Some("abc.trycloudflare.com")); + } + + #[test] + fn converts_https_url_to_wss_url() { + let wss = to_suggested_wss_url("https://abc.trycloudflare.com"); + assert_eq!(wss.as_deref(), Some("wss://abc.trycloudflare.com")); + } + + #[test] + fn ws_port_defaults_to_4733() { + assert_eq!(ws_port_from_remote_host(""), 4733); + assert_eq!(ws_port_from_remote_host("127.0.0.1:4732"), 4733); + assert_eq!(ws_port_from_remote_host("127.0.0.1:9000"), 9001); + } + + #[test] + fn managed_label_tracks_ws_port() { + assert_eq!( + managed_tunnel_label("http://127.0.0.1:4733"), + "codexmonitor-managed-ws-4733" + ); + assert_eq!( + managed_tunnel_label("http://127.0.0.1:9001"), + "codexmonitor-managed-ws-9001" + ); + } + + #[test] + fn managed_tunnel_command_match_avoids_non_labeled_processes() { + let label = "codexmonitor-managed-ws-4733"; + let local_url = "http://127.0.0.1:4733"; + let managed = "cloudflared --label codexmonitor-managed-ws-4733 tunnel --url http://127.0.0.1:4733 --pidfile /tmp/cf.pid"; + let other = "cloudflared tunnel --url http://127.0.0.1:4733"; + assert!(command_matches_managed_tunnel(managed, label, local_url)); + assert!(!command_matches_managed_tunnel(other, label, local_url)); + } + + #[test] + fn legacy_managed_tunnel_signature_is_detected() { + let local_url = "http://127.0.0.1:4733"; + let legacy = "cloudflared tunnel --url http://127.0.0.1:4733 --no-autoupdate --protocol http2 --loglevel info"; + let non_managed = "cloudflared tunnel run --token abc.def"; + assert!(command_matches_legacy_managed_tunnel(legacy, local_url)); + assert!(!command_matches_legacy_managed_tunnel(non_managed, local_url)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 318aa5f94..9f42eedeb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ use tauri::RunEvent; use tauri::WindowEvent; mod backend; +mod cloudflare; mod codex; mod daemon_binary; mod dictation; @@ -43,21 +44,31 @@ mod workspaces; static EXIT_CLEANUP_IN_PROGRESS: AtomicBool = AtomicBool::new(false); #[cfg(desktop)] -fn keep_daemon_running_after_close(app_handle: &tauri::AppHandle) -> bool { +fn managed_process_exit_policy(app_handle: &tauri::AppHandle) -> (bool, bool) { let state = app_handle.state::(); tauri::async_runtime::block_on(async { - state - .app_settings - .lock() - .await - .keep_daemon_running_after_app_close + let settings = state.app_settings.lock().await; + ( + settings.keep_daemon_running_after_app_close, + settings.keep_tunnel_running_after_app_close, + ) }) } #[cfg(desktop)] -async fn stop_managed_daemons_for_exit(app_handle: tauri::AppHandle) { - let state = app_handle.state::(); - let _ = tailscale::tailscale_daemon_stop(state).await; +async fn stop_managed_daemons_for_exit( + app_handle: tauri::AppHandle, + stop_daemon: bool, + stop_tunnel: bool, +) { + if stop_tunnel { + let state = app_handle.state::(); + let _ = cloudflare::cloudflare_tunnel_stop(state).await; + } + if stop_daemon { + let state = app_handle.state::(); + let _ = tailscale::tailscale_daemon_stop(state).await; + } } #[tauri::command] @@ -294,6 +305,10 @@ pub fn run() { tailscale::tailscale_daemon_start, tailscale::tailscale_daemon_stop, tailscale::tailscale_daemon_status, + cloudflare::cloudflare_tunnel_start, + cloudflare::cloudflare_tunnel_stop, + cloudflare::cloudflare_tunnel_status, + cloudflare::cloudflare_tunnel_install, is_mobile_runtime ]) .build(tauri::generate_context!()) @@ -302,14 +317,16 @@ pub fn run() { app.run(|app_handle, event| { #[cfg(desktop)] if let RunEvent::ExitRequested { api, .. } = event { - if !EXIT_CLEANUP_IN_PROGRESS.load(Ordering::SeqCst) - && !keep_daemon_running_after_close(app_handle) - { + let (keep_daemon_running, keep_tunnel_running) = managed_process_exit_policy(app_handle); + let stop_daemon = !keep_daemon_running; + let stop_tunnel = !keep_tunnel_running; + if !EXIT_CLEANUP_IN_PROGRESS.load(Ordering::SeqCst) && (stop_daemon || stop_tunnel) { api.prevent_exit(); EXIT_CLEANUP_IN_PROGRESS.store(true, Ordering::SeqCst); let app_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { - stop_managed_daemons_for_exit(app_handle.clone()).await; + stop_managed_daemons_for_exit(app_handle.clone(), stop_daemon, stop_tunnel) + .await; app_handle.exit(0); }); } diff --git a/src-tauri/src/remote_backend/mod.rs b/src-tauri/src/remote_backend/mod.rs index 4ae3868d9..b30335440 100644 --- a/src-tauri/src/remote_backend/mod.rs +++ b/src-tauri/src/remote_backend/mod.rs @@ -1,6 +1,7 @@ mod protocol; mod tcp_transport; mod transport; +mod ws_transport; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -17,6 +18,7 @@ use crate::types::BackendMode; use self::protocol::{build_request_line, DEFAULT_REMOTE_HOST, DISCONNECTED_MESSAGE}; use self::tcp_transport::TcpTransport; use self::transport::{PendingMap, RemoteTransport, RemoteTransportConfig, RemoteTransportKind}; +use self::ws_transport::WssTransport; const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); const REMOTE_SEND_TIMEOUT: Duration = Duration::from_secs(15); @@ -109,7 +111,17 @@ impl RemoteBackend { pub(crate) async fn is_remote_mode(state: &AppState) -> bool { let settings = state.app_settings.lock().await; - matches!(settings.backend_mode, BackendMode::Remote) + should_use_remote_mode(&settings, cfg!(any(target_os = "ios", target_os = "android"))) +} + +fn should_use_remote_mode(settings: &crate::types::AppSettings, mobile_runtime: bool) -> bool { + if !matches!(settings.backend_mode, BackendMode::Remote) { + return false; + } + if mobile_runtime { + return true; + } + resolve_transport_config(settings).is_ok() } pub(crate) async fn call_remote( @@ -195,11 +207,11 @@ async fn ensure_remote_backend(state: &AppState, app: AppHandle) -> Result = match transport_config.kind() { RemoteTransportKind::Tcp => Box::new(TcpTransport), + RemoteTransportKind::Wss => Box::new(WssTransport), }; let connection = transport.connect(app, transport_config).await?; @@ -212,13 +224,11 @@ async fn ensure_remote_backend(state: &AppState, app: AppHandle) -> Result Result Result { - let host = if settings.remote_backend_host.trim().is_empty() { - DEFAULT_REMOTE_HOST.to_string() - } else { - settings.remote_backend_host.clone() - }; - Ok(RemoteTransportConfig::Tcp { - host, - auth_token: settings.remote_backend_token.clone(), - }) + match &settings.remote_backend_provider { + crate::types::RemoteBackendProvider::Tcp => { + let host = if settings.remote_backend_host.trim().is_empty() { + DEFAULT_REMOTE_HOST.to_string() + } else { + settings.remote_backend_host.clone() + }; + Ok(RemoteTransportConfig::Tcp { + host, + auth_token: settings.remote_backend_token.clone(), + }) + } + crate::types::RemoteBackendProvider::Wss => { + let url = settings.remote_backend_host.trim(); + if url.is_empty() { + return Err("Remote backend URL is required for WSS transport.".to_string()); + } + if !url.starts_with("wss://") { + return Err("WSS transport requires a URL that starts with wss://".to_string()); + } + Ok(RemoteTransportConfig::Wss { + url: url.to_string(), + auth_token: settings.remote_backend_token.clone(), + }) + } + } } #[cfg(test)] mod tests { - use super::{can_retry_after_disconnect, resolve_transport_config}; + use super::{can_retry_after_disconnect, resolve_transport_config, should_use_remote_mode}; use crate::remote_backend::transport::RemoteTransportConfig; - use crate::types::AppSettings; + use crate::types::{AppSettings, BackendMode, RemoteBackendProvider}; #[test] fn resolve_tcp_transport_uses_remote_host() { @@ -261,6 +288,29 @@ mod tests { assert_eq!(host, "tcp.example:4732"); } + #[test] + fn resolve_wss_transport_uses_remote_url() { + let mut settings = AppSettings::default(); + settings.remote_backend_provider = RemoteBackendProvider::Wss; + settings.remote_backend_host = "wss://codex.example.com/daemon".to_string(); + + let config = resolve_transport_config(&settings).expect("transport config"); + let RemoteTransportConfig::Wss { url, .. } = config else { + panic!("expected wss transport config"); + }; + assert_eq!(url, "wss://codex.example.com/daemon"); + } + + #[test] + fn resolve_wss_transport_requires_wss_scheme() { + let mut settings = AppSettings::default(); + settings.remote_backend_provider = RemoteBackendProvider::Wss; + settings.remote_backend_host = "https://codex.example.com/daemon".to_string(); + + let error = resolve_transport_config(&settings).expect_err("expected validation error"); + assert!(error.contains("wss://")); + } + #[test] fn retries_only_retry_safe_methods_after_disconnect() { assert!(can_retry_after_disconnect("resume_thread")); @@ -270,4 +320,24 @@ mod tests { assert!(!can_retry_after_disconnect("start_thread")); assert!(!can_retry_after_disconnect("remove_workspace")); } + + #[test] + fn remote_mode_disabled_on_desktop_when_wss_host_is_invalid() { + let mut settings = AppSettings::default(); + settings.backend_mode = BackendMode::Remote; + settings.remote_backend_provider = RemoteBackendProvider::Wss; + settings.remote_backend_host = "127.0.0.1:4732".to_string(); + + assert!(!should_use_remote_mode(&settings, false)); + } + + #[test] + fn remote_mode_kept_on_mobile_even_with_incomplete_wss_host() { + let mut settings = AppSettings::default(); + settings.backend_mode = BackendMode::Remote; + settings.remote_backend_provider = RemoteBackendProvider::Wss; + settings.remote_backend_host = "127.0.0.1:4732".to_string(); + + assert!(should_use_remote_mode(&settings, true)); + } } diff --git a/src-tauri/src/remote_backend/tcp_transport.rs b/src-tauri/src/remote_backend/tcp_transport.rs index cbddf4e7b..6b015f148 100644 --- a/src-tauri/src/remote_backend/tcp_transport.rs +++ b/src-tauri/src/remote_backend/tcp_transport.rs @@ -10,7 +10,9 @@ pub(crate) struct TcpTransport; impl RemoteTransport for TcpTransport { fn connect(&self, app: AppHandle, config: RemoteTransportConfig) -> TransportFuture { Box::pin(async move { - let RemoteTransportConfig::Tcp { host, .. } = config; + let RemoteTransportConfig::Tcp { host, .. } = config else { + return Err("invalid transport config: expected tcp".to_string()); + }; let stream = TcpStream::connect(host.clone()) .await diff --git a/src-tauri/src/remote_backend/transport.rs b/src-tauri/src/remote_backend/transport.rs index 57e7a4cbf..84b97e1d6 100644 --- a/src-tauri/src/remote_backend/transport.rs +++ b/src-tauri/src/remote_backend/transport.rs @@ -20,23 +20,30 @@ pub(crate) enum RemoteTransportConfig { host: String, auth_token: Option, }, + Wss { + url: String, + auth_token: Option, + }, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub(crate) enum RemoteTransportKind { Tcp, + Wss, } impl RemoteTransportConfig { pub(crate) fn kind(&self) -> RemoteTransportKind { match self { RemoteTransportConfig::Tcp { .. } => RemoteTransportKind::Tcp, + RemoteTransportConfig::Wss { .. } => RemoteTransportKind::Wss, } } pub(crate) fn auth_token(&self) -> Option<&str> { match self { RemoteTransportConfig::Tcp { auth_token, .. } => auth_token.as_deref(), + RemoteTransportConfig::Wss { auth_token, .. } => auth_token.as_deref(), } } } diff --git a/src-tauri/src/remote_backend/ws_transport.rs b/src-tauri/src/remote_backend/ws_transport.rs new file mode 100644 index 000000000..8abb3b4fb --- /dev/null +++ b/src-tauri/src/remote_backend/ws_transport.rs @@ -0,0 +1,78 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use futures_util::{SinkExt, StreamExt}; +use tauri::AppHandle; +use tokio::sync::{mpsc, Mutex}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use super::transport::{ + dispatch_incoming_line, mark_disconnected, PendingMap, RemoteTransport, RemoteTransportConfig, + TransportConnection, TransportFuture, +}; + +const OUTBOUND_QUEUE_CAPACITY: usize = 512; + +pub(crate) struct WssTransport; + +impl RemoteTransport for WssTransport { + fn connect(&self, app: AppHandle, config: RemoteTransportConfig) -> TransportFuture { + Box::pin(async move { + let RemoteTransportConfig::Wss { url, .. } = config else { + return Err("Expected WSS remote transport config".to_string()); + }; + + let (socket, _) = tokio_tungstenite::connect_async(url.clone()) + .await + .map_err(|err| { + format!("Failed to connect to remote backend via WebSocket at {url}: {err}") + })?; + + let (mut writer, mut reader) = socket.split(); + + let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); + let pending = Arc::new(Mutex::new(PendingMap::new())); + let pending_for_writer = Arc::clone(&pending); + let pending_for_reader = Arc::clone(&pending); + + let connected = Arc::new(AtomicBool::new(true)); + let connected_for_writer = Arc::clone(&connected); + let connected_for_reader = Arc::clone(&connected); + + tokio::spawn(async move { + while let Some(message) = out_rx.recv().await { + if writer.send(WsMessage::Text(message.into())).await.is_err() { + mark_disconnected(&pending_for_writer, &connected_for_writer).await; + break; + } + } + }); + + tokio::spawn(async move { + while let Some(frame) = reader.next().await { + match frame { + Ok(WsMessage::Text(line)) => { + dispatch_incoming_line(&app, &pending_for_reader, line.as_str()).await; + } + Ok(WsMessage::Binary(bytes)) => { + if let Ok(line) = String::from_utf8(bytes.to_vec()) { + dispatch_incoming_line(&app, &pending_for_reader, &line).await; + } + } + Ok(WsMessage::Close(_)) => break, + Ok(_) => {} + Err(_) => break, + } + } + + mark_disconnected(&pending_for_reader, &connected_for_reader).await; + }); + + Ok(TransportConnection { + out_tx, + pending, + connected, + }) + }) + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e28cf4ed7..778f0f043 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -2,13 +2,16 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tauri::{AppHandle, Manager}; +use tokio::task::JoinHandle; use tokio::process::Child; use tokio::sync::Mutex; use crate::dictation::DictationState; use crate::shared::codex_core::CodexLoginCancelState; use crate::storage::{read_settings, read_workspaces}; -use crate::types::{AppSettings, TcpDaemonState, TcpDaemonStatus, WorkspaceEntry}; +use crate::types::{ + AppSettings, CloudflareTunnelStatus, TcpDaemonState, TcpDaemonStatus, WorkspaceEntry, +}; pub(crate) struct TcpDaemonRuntime { pub(crate) child: Option, @@ -30,6 +33,36 @@ impl Default for TcpDaemonRuntime { } } +pub(crate) struct CloudflareTunnelRuntime { + pub(crate) child: Option, + pub(crate) stdout_task: Option>, + pub(crate) stderr_task: Option>, + pub(crate) discovered_public_url: Arc>>, + pub(crate) status: CloudflareTunnelStatus, +} + +impl Default for CloudflareTunnelRuntime { + fn default() -> Self { + Self { + child: None, + stdout_task: None, + stderr_task: None, + discovered_public_url: Arc::new(Mutex::new(None)), + status: CloudflareTunnelStatus { + state: TcpDaemonState::Stopped, + pid: None, + started_at_ms: None, + last_error: None, + local_url: None, + public_url: None, + suggested_wss_url: None, + installed: false, + version: None, + }, + } + } +} + pub(crate) struct AppState { pub(crate) workspaces: Mutex>, pub(crate) sessions: Mutex>>, @@ -41,6 +74,7 @@ pub(crate) struct AppState { pub(crate) dictation: Mutex, pub(crate) codex_login_cancels: Mutex>, pub(crate) tcp_daemon: Mutex, + pub(crate) cloudflare_tunnel: Mutex, } impl AppState { @@ -64,6 +98,7 @@ impl AppState { dictation: Mutex::new(DictationState::default()), codex_login_cancels: Mutex::new(HashMap::new()), tcp_daemon: Mutex::new(TcpDaemonRuntime::default()), + cloudflare_tunnel: Mutex::new(CloudflareTunnelRuntime::default()), } } } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 354015df4..a4b9df992 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -34,7 +34,7 @@ pub(crate) fn read_settings(path: &PathBuf) -> Result { match serde_json::from_value(value.clone()) { Ok(settings) => Ok(settings), Err(_) => { - sanitize_remote_settings_for_tcp_only(&mut value); + sanitize_remote_settings_for_supported_providers(&mut value); migrate_follow_up_message_behavior(&mut value); serde_json::from_value(value).map_err(|e| e.to_string()) } @@ -49,20 +49,31 @@ pub(crate) fn write_settings(path: &PathBuf, settings: &AppSettings) -> Result<( std::fs::write(path, data).map_err(|e| e.to_string()) } -fn sanitize_remote_settings_for_tcp_only(value: &mut Value) { +fn sanitize_remote_settings_for_supported_providers(value: &mut Value) { let Value::Object(root) = value else { return; }; + + fn normalized_provider(value: Option<&Value>) -> &'static str { + match value.and_then(Value::as_str) { + Some("tcp") => "tcp", + Some("wss") => "wss", + _ => "tcp", + } + } + + let root_provider = normalized_provider(root.get("remoteBackendProvider")); root.insert( "remoteBackendProvider".to_string(), - Value::String("tcp".to_string()), + Value::String(root_provider.to_string()), ); if let Some(Value::Array(remote_backends)) = root.get_mut("remoteBackends") { for entry in remote_backends { let Value::Object(entry_obj) = entry else { continue; }; - entry_obj.insert("provider".to_string(), Value::String("tcp".to_string())); + let provider = normalized_provider(entry_obj.get("provider")); + entry_obj.insert("provider".to_string(), Value::String(provider.to_string())); entry_obj.retain(|key, _| { matches!( key.as_str(), @@ -169,6 +180,49 @@ mod tests { assert_eq!(settings.theme, "dark"); } + #[test] + fn read_settings_preserves_wss_remote_provider() { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("settings.json"); + + std::fs::write( + &path, + r#"{ + "remoteBackendProvider": "wss", + "remoteBackendHost": "wss://codex.example.com/daemon", + "remoteBackendToken": "token-1", + "remoteBackends": [ + { + "id": "remote-a", + "name": "Remote A", + "provider": "wss", + "host": "wss://codex.example.com/daemon", + "token": "token-1" + } + ], + "theme": "dark" +}"#, + ) + .expect("write settings"); + + let settings = read_settings(&path).expect("read settings"); + assert!(matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Wss + )); + assert_eq!(settings.remote_backends.len(), 1); + assert!(matches!( + settings.remote_backends[0].provider, + crate::types::RemoteBackendProvider::Wss + )); + assert_eq!( + settings.remote_backend_host, + "wss://codex.example.com/daemon" + ); + assert_eq!(settings.theme, "dark"); + } + #[test] fn read_settings_migrates_follow_up_behavior_from_legacy_steer_enabled_true() { let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); diff --git a/src-tauri/src/tailscale/core.rs b/src-tauri/src/tailscale/core.rs index d63f7809e..a282cc8b2 100644 --- a/src-tauri/src/tailscale/core.rs +++ b/src-tauri/src/tailscale/core.rs @@ -5,7 +5,6 @@ use serde_json::Value; use crate::types::{TailscaleDaemonCommandPreview, TailscaleStatus}; -const DEFAULT_DAEMON_LISTEN_ADDR: &str = "0.0.0.0:4732"; const REMOTE_TOKEN_PLACEHOLDER: &str = ""; pub(crate) fn unavailable_status(version: Option, message: String) -> TailscaleStatus { @@ -252,12 +251,16 @@ pub(crate) fn daemon_command_preview( daemon_path: &Path, data_dir: &Path, token_configured: bool, + listen_addr: &str, + ws_listen_addr: &str, ) -> TailscaleDaemonCommandPreview { let daemon_path_str = daemon_path.to_string_lossy().to_string(); let data_dir_str = data_dir.to_string_lossy().to_string(); let args = vec![ "--listen".to_string(), - DEFAULT_DAEMON_LISTEN_ADDR.to_string(), + listen_addr.to_string(), + "--ws-listen".to_string(), + ws_listen_addr.to_string(), "--data-dir".to_string(), data_dir_str.clone(), "--token".to_string(), @@ -430,9 +433,13 @@ extra diagnostics line"#; Path::new("/tmp/codex_monitor_daemon"), Path::new("/tmp/data-dir"), true, + "0.0.0.0:4732", + "127.0.0.1:4733", ); assert!(preview.command.contains("--listen")); assert!(preview.command.contains("0.0.0.0:4732")); + assert!(preview.command.contains("--ws-listen")); + assert!(preview.command.contains("127.0.0.1:4733")); assert!(preview.command.contains("")); assert!(preview.token_configured); } diff --git a/src-tauri/src/tailscale/daemon_commands.rs b/src-tauri/src/tailscale/daemon_commands.rs index 33f208954..d5ca3aec0 100644 --- a/src-tauri/src/tailscale/daemon_commands.rs +++ b/src-tauri/src/tailscale/daemon_commands.rs @@ -74,11 +74,15 @@ pub(super) async fn tailscale_daemon_command_preview( .map(str::trim) .map(|value| !value.is_empty()) .unwrap_or(false); + let listen_addr = configured_daemon_listen_addr(&settings); + let ws_listen_addr = configured_daemon_ws_listen_addr(&settings); Ok(tailscale_core::daemon_command_preview( &daemon_path, &data_dir, token_configured, + &listen_addr, + &ws_listen_addr, )) } @@ -99,6 +103,7 @@ pub(super) async fn tailscale_daemon_start( "Set a Remote backend token before starting mobile access daemon.".to_string() })?; let listen_addr = configured_daemon_listen_addr(&settings); + let ws_listen_addr = configured_daemon_ws_listen_addr(&settings); let listen_port = parse_port_from_remote_host(&listen_addr) .ok_or_else(|| format!("Invalid daemon listen address: {listen_addr}"))?; let daemon_binary = resolve_daemon_binary_path()?; @@ -119,8 +124,12 @@ pub(super) async fn tailscale_daemon_start( info, } => { let pid = resolve_daemon_pid(listen_port, info.as_ref()).await; - let restart_required = should_restart_daemon(info.as_ref()); - let restart_reason = if restart_required { + let pid_is_expected_daemon = match pid { + Some(listener_pid) => is_pid_expected_daemon_process(listener_pid).await, + None => false, + }; + let mut restart_required = should_restart_daemon(info.as_ref()); + let mut restart_reason = if restart_required { Some(daemon_restart_reason(info.as_ref())) } else { None @@ -135,15 +144,24 @@ pub(super) async fn tailscale_daemon_start( listen_addr: Some(listen_addr.clone()), }; if !auth_ok { - return Err(auth_error.unwrap_or_else(|| { - "Daemon is already running but authentication failed.".to_string() - })); + if pid_is_expected_daemon { + restart_required = true; + restart_reason = Some( + "Daemon token mismatch detected; restarting managed daemon with current token." + .to_string(), + ); + } else { + return Err(auth_error.unwrap_or_else(|| { + "Daemon is already running but authentication failed.".to_string() + })); + } } - if !restart_required { + if auth_ok && !restart_required { return Ok(runtime.status.clone()); } - let force_kill_allowed = can_force_stop_daemon(auth_ok, info.as_ref()); + let force_kill_allowed = + can_force_stop_daemon(auth_ok, info.as_ref()) || pid_is_expected_daemon; let pid_for_control = pid; if let Err(shutdown_error) = request_daemon_shutdown(&listen_addr, Some(token)).await { if !force_kill_allowed { @@ -215,6 +233,8 @@ pub(super) async fn tailscale_daemon_start( let child = tokio_command(&daemon_binary) .arg("--listen") .arg(&listen_addr) + .arg("--ws-listen") + .arg(&ws_listen_addr) .arg("--data-dir") .arg(data_dir) .arg("--token") diff --git a/src-tauri/src/tailscale/mod.rs b/src-tauri/src/tailscale/mod.rs index 4053c54ba..d207e85f7 100644 --- a/src-tauri/src/tailscale/mod.rs +++ b/src-tauri/src/tailscale/mod.rs @@ -246,6 +246,14 @@ fn daemon_listen_addr(remote_host: &str) -> String { format!("0.0.0.0:{port}") } +fn daemon_ws_listen_addr(remote_host: &str) -> String { + let ws_port = match parse_port_from_remote_host(remote_host) { + Some(port) if port < u16::MAX => port + 1, + _ => 4733, + }; + format!("127.0.0.1:{ws_port}") +} + fn daemon_connect_addr(listen_addr: &str) -> Option { let port = parse_port_from_remote_host(listen_addr)?; Some(format!("127.0.0.1:{port}")) @@ -255,6 +263,10 @@ fn configured_daemon_listen_addr(settings: &crate::types::AppSettings) -> String daemon_listen_addr(&settings.remote_backend_host) } +fn configured_daemon_ws_listen_addr(settings: &crate::types::AppSettings) -> String { + daemon_ws_listen_addr(&settings.remote_backend_host) +} + fn sync_tcp_daemon_listen_addr(status: &mut TcpDaemonStatus, configured_listen_addr: &str) { if matches!(status.state, TcpDaemonState::Running) && status.listen_addr.is_some() { return; @@ -405,6 +417,33 @@ async fn kill_pid_gracefully(pid: u32) -> Result<(), String> { Err(format!("Daemon process {pid} is still running.")) } +#[cfg(unix)] +async fn is_pid_expected_daemon_process(pid: u32) -> bool { + let output = match tokio_command("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output() + .await + { + Ok(output) => output, + Err(_) => return false, + }; + + if !output.status.success() { + return false; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let command = stdout.lines().next().map(str::trim).unwrap_or_default(); + if command.is_empty() { + return false; + } + let basename = std::path::Path::new(command) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(command); + matches!(basename, "codex_monitor_daemon" | "codex-monitor-daemon") +} + #[cfg(not(unix))] async fn find_listener_pid(_port: u16) -> Option { None @@ -415,6 +454,11 @@ async fn kill_pid_gracefully(_pid: u32) -> Result<(), String> { Err("Stopping external daemon by pid is not supported on this platform.".to_string()) } +#[cfg(not(unix))] +async fn is_pid_expected_daemon_process(_pid: u32) -> bool { + false +} + #[tauri::command] pub(crate) async fn tailscale_status() -> Result { #[cfg(any(target_os = "android", target_os = "ios"))] @@ -520,9 +564,9 @@ pub(crate) async fn tailscale_status() -> Result { #[cfg(test)] mod tests { use super::{ - daemon_listen_addr, ensure_listen_addr_available, looks_like_tailscale_version, - parse_port_from_remote_host, sync_tcp_daemon_listen_addr, tailscale_binary_candidates, - truncate_preview, + daemon_listen_addr, daemon_ws_listen_addr, ensure_listen_addr_available, + looks_like_tailscale_version, parse_port_from_remote_host, + sync_tcp_daemon_listen_addr, tailscale_binary_candidates, truncate_preview, }; use crate::types::{TcpDaemonState, TcpDaemonStatus}; @@ -607,6 +651,15 @@ mod tests { assert_eq!(daemon_listen_addr("mac.example.ts.net"), "0.0.0.0:4732"); } + #[test] + fn builds_ws_listen_addr_with_port_offset() { + assert_eq!( + daemon_ws_listen_addr("mac.example.ts.net:8888"), + "127.0.0.1:8889" + ); + assert_eq!(daemon_ws_listen_addr("mac.example.ts.net"), "127.0.0.1:4733"); + } + #[test] fn syncs_listen_addr_for_stopped_state() { let mut status = TcpDaemonStatus { diff --git a/src-tauri/src/tailscale/rpc_client.rs b/src-tauri/src/tailscale/rpc_client.rs index 88965d7d4..b19c7fbd5 100644 --- a/src-tauri/src/tailscale/rpc_client.rs +++ b/src-tauri/src/tailscale/rpc_client.rs @@ -289,3 +289,15 @@ pub(super) async fn wait_for_daemon_shutdown(listen_addr: &str, token: Option<&s } false } + +#[cfg(test)] +mod tests { + use super::is_auth_error_message; + + #[test] + fn auth_error_detection_covers_invalid_token_variants() { + assert!(is_auth_error_message("invalid token")); + assert!(is_auth_error_message("Unauthorized")); + assert!(!is_auth_error_message("connection refused")); + } +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index cbb8afbaa..a6b729972 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -209,6 +209,27 @@ pub(crate) struct TcpDaemonStatus { pub(crate) listen_addr: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CloudflareTunnelStatus { + pub(crate) state: TcpDaemonState, + #[serde(default)] + pub(crate) pid: Option, + #[serde(default)] + pub(crate) started_at_ms: Option, + #[serde(default)] + pub(crate) last_error: Option, + #[serde(default, rename = "localUrl")] + pub(crate) local_url: Option, + #[serde(default, rename = "publicUrl")] + pub(crate) public_url: Option, + #[serde(default, rename = "suggestedWssUrl")] + pub(crate) suggested_wss_url: Option, + pub(crate) installed: bool, + #[serde(default)] + pub(crate) version: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub(crate) struct TailscaleStatus { @@ -393,6 +414,8 @@ pub(crate) struct AppSettings { pub(crate) active_remote_backend_id: Option, #[serde(default, rename = "keepDaemonRunningAfterAppClose")] pub(crate) keep_daemon_running_after_app_close: bool, + #[serde(default, rename = "keepTunnelRunningAfterAppClose")] + pub(crate) keep_tunnel_running_after_app_close: bool, #[serde(default = "default_access_mode", rename = "defaultAccessMode")] pub(crate) default_access_mode: String, #[serde( @@ -658,6 +681,7 @@ impl Default for BackendMode { #[serde(rename_all = "lowercase")] pub(crate) enum RemoteBackendProvider { Tcp, + Wss, } impl Default for RemoteBackendProvider { @@ -1119,6 +1143,7 @@ impl Default for AppSettings { remote_backends: default_remote_backends(), active_remote_backend_id: None, keep_daemon_running_after_app_close: false, + keep_tunnel_running_after_app_close: false, default_access_mode: "current".to_string(), review_delivery_mode: default_review_delivery_mode(), composer_model_shortcut: default_composer_model_shortcut(), @@ -1217,6 +1242,7 @@ mod tests { assert!(settings.remote_backends.is_empty()); assert!(settings.active_remote_backend_id.is_none()); assert!(!settings.keep_daemon_running_after_app_close); + assert!(!settings.keep_tunnel_running_after_app_close); assert_eq!(settings.default_access_mode, "current"); assert_eq!(settings.review_delivery_mode, "inline"); let expected_primary = if cfg!(target_os = "macos") { diff --git a/src/features/mobile/components/MobileServerSetupWizard.tsx b/src/features/mobile/components/MobileServerSetupWizard.tsx index d1afa5e5b..5e9581828 100644 --- a/src/features/mobile/components/MobileServerSetupWizard.tsx +++ b/src/features/mobile/components/MobileServerSetupWizard.tsx @@ -53,13 +53,13 @@ export function MobileServerSetupWizard({
onRemoteHostChange(event.target.value)} disabled={busy || checking} /> @@ -99,7 +99,7 @@ export function MobileServerSetupWizard({ ) : null}
- Use the Tailscale host from desktop Server settings and keep the desktop daemon running. + Use desktop Server settings values. Supports TCP host:port and public wss:// endpoints.
diff --git a/src/features/mobile/hooks/useMobileServerSetup.ts b/src/features/mobile/hooks/useMobileServerSetup.ts index 729ea02ba..08f1739ca 100644 --- a/src/features/mobile/hooks/useMobileServerSetup.ts +++ b/src/features/mobile/hooks/useMobileServerSetup.ts @@ -23,7 +23,11 @@ function isRemoteServerConfigured(settings: AppSettings): boolean { } function defaultMobileSetupMessage(): string { - return "Enter your desktop Tailscale host and token, then run Connect & test."; + return "Enter a remote host (TCP host:port or wss:// URL) and token, then run Connect & test."; +} + +function inferRemoteProviderFromHost(host: string): AppSettings["remoteBackendProvider"] { + return host.trim().toLowerCase().startsWith("wss://") ? "wss" : "tcp"; } function markActiveRemoteBackendConnected(settings: AppSettings, connectedAtMs: number): AppSettings { @@ -34,7 +38,7 @@ function markActiveRemoteBackendConnected(settings: AppSettings, connectedAtMs: { id: settings.activeRemoteBackendId ?? "remote-default", name: "Primary remote", - provider: "tcp" as const, + provider: inferRemoteProviderFromHost(settings.remoteBackendHost), host: settings.remoteBackendHost, token: settings.remoteBackendToken, lastConnectedAtMs: null, @@ -48,7 +52,7 @@ function markActiveRemoteBackendConnected(settings: AppSettings, connectedAtMs: const active = existingBackends[activeIndex]; existingBackends[activeIndex] = { ...active, - provider: "tcp", + provider: settings.remoteBackendProvider, host: settings.remoteBackendHost, token: settings.remoteBackendToken, lastConnectedAtMs: connectedAtMs, @@ -130,6 +134,7 @@ export function useMobileServerSetup({ } const nextHost = remoteHostDraft.trim(); + const nextProvider = inferRemoteProviderFromHost(nextHost); const nextToken = remoteTokenDraft.trim() ? remoteTokenDraft.trim() : null; if (!nextHost || !nextToken) { @@ -147,7 +152,7 @@ export function useMobileServerSetup({ const saved = await queueSaveSettings({ ...appSettings, backendMode: "remote", - remoteBackendProvider: "tcp", + remoteBackendProvider: nextProvider, remoteBackendHost: nextHost, remoteBackendToken: nextToken, }); diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index e8d5a7e03..53d8c8c89 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -77,6 +77,7 @@ const baseSettings: AppSettings = { ], activeRemoteBackendId: "remote-default", keepDaemonRunningAfterAppClose: false, + keepTunnelRunningAfterAppClose: false, defaultAccessMode: "current", reviewDeliveryMode: "inline", composerModelShortcut: null, @@ -783,7 +784,7 @@ describe("SettingsView Codex section", () => { }); }); - it("renders mobile daemon controls in local backend mode for TCP provider", async () => { + it("keeps local-only mode focused and hides remote daemon controls", async () => { cleanup(); render( { ); await waitFor(() => { - expect(screen.getByRole("button", { name: "Start daemon" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Stop daemon" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Refresh status" })).toBeTruthy(); - expect(screen.getByLabelText("Remote backend host")).toBeTruthy(); - expect(screen.getByLabelText("Remote backend token")).toBeTruthy(); + expect(screen.getByText("Local mode is active")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Start daemon" })).toBeNull(); + expect(screen.queryByLabelText("Remote backend host")).toBeNull(); + expect(screen.queryByLabelText("Remote backend token")).toBeNull(); + }); + }); + + it("switches to public WSS mode from server settings", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByRole("radio", { name: /3\. Public WSS/i })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + remoteBackendProvider: "wss", + backendMode: "remote", + }), + ); + }); + }); + + it("switches to public WSS mode even without a prefilled WSS host", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByRole("radio", { name: /3\. Public WSS/i })); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + remoteBackendProvider: "wss", + backendMode: "remote", + }), + ); + }); + expect(screen.queryByText(/Public WSS mode needs a valid `wss:\/\/` host/i)).toBeNull(); + }); + + it("shows cloudflared install step in public mode when cloudflared is unavailable", async () => { + cleanup(); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText("Step 1: Install Cloudflare tunnel")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Install cloudflared" })).toBeTruthy(); }); }); @@ -906,9 +1069,7 @@ describe("SettingsView Codex section", () => { expect(screen.queryByRole("button", { name: "Start daemon" })).toBeNull(); expect(screen.queryByRole("button", { name: "Detect Tailscale" })).toBeNull(); expect(screen.queryByRole("button", { name: "Start Runner" })).toBeNull(); - expect( - screen.getByText(/get the tailscale hostname and token from your desktop/i), - ).toBeTruthy(); + expect(screen.getByText(/configure either a tailscale tcp host or a wss tunnel endpoint/i)).toBeTruthy(); } finally { if (originalPlatformDescriptor) { Object.defineProperty(window.navigator, "platform", originalPlatformDescriptor); diff --git a/src/features/settings/components/sections/SettingsServerSection.tsx b/src/features/settings/components/sections/SettingsServerSection.tsx index 7a1139d6b..3e6897f5c 100644 --- a/src/features/settings/components/sections/SettingsServerSection.tsx +++ b/src/features/settings/components/sections/SettingsServerSection.tsx @@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from "react"; import X from "lucide-react/dist/esm/icons/x"; import type { AppSettings, + CloudflareTunnelStatus, TailscaleDaemonCommandPreview, TailscaleStatus, TcpDaemonStatus, @@ -16,10 +17,14 @@ import { type AddRemoteBackendDraft = { name: string; + provider: AppSettings["remoteBackendProvider"]; host: string; token: string; }; +type DesktopServerMode = "local" | "private-tcp" | "public-wss"; +type WizardStepState = "done" | "active" | "pending" | "error"; + type SettingsServerSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; @@ -34,6 +39,7 @@ type SettingsServerSectionProps = { remoteNameError: string | null; remoteHostError: string | null; remoteNameDraft: string; + remoteProviderDraft: AppSettings["remoteBackendProvider"]; remoteHostDraft: string; remoteTokenDraft: string; nextRemoteNameSuggestion: string; @@ -45,12 +51,19 @@ type SettingsServerSectionProps = { tailscaleCommandError: string | null; tcpDaemonStatus: TcpDaemonStatus | null; tcpDaemonBusyAction: "start" | "stop" | "status" | null; + cloudflareTunnelStatus: CloudflareTunnelStatus | null; + cloudflareTunnelBusyAction: "start" | "stop" | "status" | "setup" | "install" | null; onSetRemoteNameDraft: Dispatch>; + onSetRemoteProviderDraft: Dispatch>; onSetRemoteHostDraft: Dispatch>; onSetRemoteTokenDraft: Dispatch>; onCommitRemoteName: () => Promise; + onCommitRemoteProvider: ( + nextProvider?: AppSettings["remoteBackendProvider"], + ) => Promise; onCommitRemoteHost: () => Promise; onCommitRemoteToken: () => Promise; + onSetBackendMode: (nextMode: AppSettings["backendMode"]) => Promise; onSelectRemoteBackend: (id: string) => Promise; onAddRemoteBackend: (draft: AddRemoteBackendDraft) => Promise; onMoveRemoteBackend: (id: string, direction: "up" | "down") => Promise; @@ -61,6 +74,13 @@ type SettingsServerSectionProps = { onTcpDaemonStart: () => Promise; onTcpDaemonStop: () => Promise; onTcpDaemonStatus: () => Promise; + onCloudflareTunnelStart: () => Promise; + onCloudflareTunnelStop: () => Promise; + onCloudflareTunnelStatus: () => Promise; + onCloudflareTunnelInstall: () => Promise; + onGenerateRemotePassword: () => Promise; + onApplySuggestedWssUrl: () => Promise; + onOneClickWssSetup: () => Promise; onMobileConnectTest: () => void; }; @@ -78,6 +98,7 @@ export function SettingsServerSection({ remoteNameError, remoteHostError, remoteNameDraft, + remoteProviderDraft, remoteHostDraft, remoteTokenDraft, nextRemoteNameSuggestion, @@ -89,12 +110,17 @@ export function SettingsServerSection({ tailscaleCommandError, tcpDaemonStatus, tcpDaemonBusyAction, + cloudflareTunnelStatus, + cloudflareTunnelBusyAction, onSetRemoteNameDraft, + onSetRemoteProviderDraft, onSetRemoteHostDraft, onSetRemoteTokenDraft, onCommitRemoteName, + onCommitRemoteProvider, onCommitRemoteHost, onCommitRemoteToken, + onSetBackendMode, onSelectRemoteBackend, onAddRemoteBackend, onMoveRemoteBackend, @@ -105,6 +131,13 @@ export function SettingsServerSection({ onTcpDaemonStart, onTcpDaemonStop, onTcpDaemonStatus, + onCloudflareTunnelStart, + onCloudflareTunnelStop, + onCloudflareTunnelStatus, + onCloudflareTunnelInstall, + onGenerateRemotePassword, + onApplySuggestedWssUrl, + onOneClickWssSetup, onMobileConnectTest, }: SettingsServerSectionProps) { const [pendingDeleteRemoteId, setPendingDeleteRemoteId] = useState( @@ -113,7 +146,15 @@ export function SettingsServerSection({ const [addRemoteOpen, setAddRemoteOpen] = useState(false); const [addRemoteBusy, setAddRemoteBusy] = useState(false); const [addRemoteError, setAddRemoteError] = useState(null); + const [serverModeError, setServerModeError] = useState(null); + const [tokenVisible, setTokenVisible] = useState(false); + const [tokenActionMessage, setTokenActionMessage] = useState(null); + const [tokenActionError, setTokenActionError] = useState(false); + const [wssActionMessage, setWssActionMessage] = useState(null); + const [wssActionError, setWssActionError] = useState(false); const [addRemoteNameDraft, setAddRemoteNameDraft] = useState(""); + const [addRemoteProviderDraft, setAddRemoteProviderDraft] = + useState("tcp"); const [addRemoteHostDraft, setAddRemoteHostDraft] = useState(""); const [addRemoteTokenDraft, setAddRemoteTokenDraft] = useState(""); const isMobileSimplified = isMobilePlatform; @@ -138,10 +179,53 @@ export function SettingsServerSection({ } return `Mobile daemon is stopped${tcpDaemonStatus.listenAddr ? ` (${tcpDaemonStatus.listenAddr})` : ""}.`; })(); + const remoteHostPlaceholder = + remoteProviderDraft === "wss" ? "wss://codex.example.com/daemon" : "127.0.0.1:4732"; + const addRemoteHostPlaceholder = + addRemoteProviderDraft === "wss" + ? "wss://codex.example.com/daemon" + : "macbook.your-tailnet.ts.net:4732"; + const cloudflareTunnelStatusText = (() => { + if (!cloudflareTunnelStatus) { + return null; + } + if (cloudflareTunnelStatus.state === "running") { + if (cloudflareTunnelStatus.suggestedWssUrl) { + return `Cloudflare tunnel is running: ${cloudflareTunnelStatus.suggestedWssUrl}`; + } + return "Cloudflare tunnel is running. Waiting for public URL."; + } + if (cloudflareTunnelStatus.state === "error") { + return cloudflareTunnelStatus.lastError ?? "Cloudflare tunnel is in an error state."; + } + return "Cloudflare tunnel is stopped."; + })(); + const desktopServerMode: DesktopServerMode = useMemo(() => { + if (appSettings.backendMode === "local") { + return "local"; + } + return remoteProviderDraft === "wss" ? "public-wss" : "private-tcp"; + }, [appSettings.backendMode, remoteProviderDraft]); + const desktopIsLocalMode = !isMobileSimplified && desktopServerMode === "local"; + const desktopIsPrivateMode = !isMobileSimplified && desktopServerMode === "private-tcp"; + const desktopIsPublicMode = !isMobileSimplified && desktopServerMode === "public-wss"; + const tokenConfigured = remoteTokenDraft.trim().length > 0; + const daemonRunning = tcpDaemonStatus?.state === "running"; + const daemonFailed = tcpDaemonStatus?.state === "error"; + const cloudflareInstalled = cloudflareTunnelStatus?.installed ?? false; + const cloudflareRunning = cloudflareTunnelStatus?.state === "running"; + const cloudflareFailed = cloudflareTunnelStatus?.state === "error"; + const suggestedWssUrl = cloudflareTunnelStatus?.suggestedWssUrl?.trim() ?? ""; + const tunnelUrlReady = suggestedWssUrl.length > 0; + const tunnelUrlApplied = + tunnelUrlReady && + remoteProviderDraft === "wss" && + remoteHostDraft.trim() === suggestedWssUrl; const openAddRemoteModal = () => { setAddRemoteError(null); setAddRemoteNameDraft(nextRemoteNameSuggestion); + setAddRemoteProviderDraft(remoteProviderDraft); setAddRemoteHostDraft(remoteHostDraft); setAddRemoteTokenDraft(""); setAddRemoteOpen(true); @@ -165,6 +249,7 @@ export function SettingsServerSection({ try { await onAddRemoteBackend({ name: addRemoteNameDraft, + provider: addRemoteProviderDraft, host: addRemoteHostDraft, token: addRemoteTokenDraft, }); @@ -177,39 +262,196 @@ export function SettingsServerSection({ })(); }; + const handleSelectDesktopMode = (nextMode: DesktopServerMode) => { + if (isMobileSimplified || desktopServerMode === nextMode) { + return; + } + setServerModeError(null); + void (async () => { + if (nextMode === "local") { + await onSetBackendMode("local"); + return; + } + const nextProvider: AppSettings["remoteBackendProvider"] = + nextMode === "public-wss" ? "wss" : "tcp"; + await onCommitRemoteProvider(nextProvider); + await onSetBackendMode("remote"); + })(); + }; + + const handleCopyToken = () => { + const token = remoteTokenDraft.trim(); + if (!token) { + setTokenActionError(true); + setTokenActionMessage("Set a token first, then copy."); + return; + } + const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard; + if (!clipboard?.writeText) { + setTokenActionError(true); + setTokenActionMessage("Clipboard is unavailable in this runtime."); + return; + } + void clipboard + .writeText(token) + .then(() => { + setTokenActionError(false); + setTokenActionMessage("Token copied."); + }) + .catch(() => { + setTokenActionError(true); + setTokenActionMessage("Could not copy token. Copy manually."); + }); + }; + + const wizardStepStatus = ( + done: boolean, + { active, error }: { active: boolean; error?: boolean }, + ): WizardStepState => { + if (done) { + return "done"; + } + if (error) { + return "error"; + } + if (active) { + return "active"; + } + return "pending"; + }; + + const wizardStepLabel = (state: WizardStepState) => { + if (state === "done") { + return "Done"; + } + if (state === "active") { + return "Next"; + } + if (state === "error") { + return "Fix"; + } + return "Pending"; + }; + + const cloudflareStep = wizardStepStatus(cloudflareRunning, { + active: cloudflareInstalled && !cloudflareFailed, + error: cloudflareFailed, + }); + const daemonStep = wizardStepStatus(daemonRunning, { + active: cloudflareRunning && !daemonFailed, + error: daemonFailed, + }); + const connectReady = tokenConfigured && daemonRunning && tunnelUrlReady; + const connectStep = wizardStepStatus(connectReady, { + active: daemonRunning && cloudflareRunning, + }); + + const handleCopyWssUrl = () => { + if (!suggestedWssUrl) { + setWssActionError(true); + setWssActionMessage("Tunnel URL is not ready yet."); + return; + } + const clipboard = typeof navigator === "undefined" ? null : navigator.clipboard; + if (!clipboard?.writeText) { + setWssActionError(true); + setWssActionMessage("Clipboard is unavailable in this runtime."); + return; + } + void clipboard + .writeText(suggestedWssUrl) + .then(() => { + setWssActionError(false); + setWssActionMessage("WSS URL copied."); + }) + .catch(() => { + setWssActionError(true); + setWssActionMessage("Could not copy URL. Copy manually."); + }); + }; + + const handleApplyTunnelUrl = () => { + if (!suggestedWssUrl) { + setWssActionError(true); + setWssActionMessage("Tunnel URL is not ready yet."); + return; + } + void onApplySuggestedWssUrl() + .then(() => { + setWssActionError(false); + setWssActionMessage("Tunnel URL applied to remote host."); + }) + .catch((error) => { + setWssActionError(true); + setWssActionMessage(error instanceof Error ? error.message : "Could not apply tunnel URL."); + }); + }; + return ( {!isMobileSimplified && (
- - + + + +
- Local keeps desktop requests in-process. Remote routes desktop requests through the same - TCP transport path used by mobile clients. + Pick one mode. Only relevant controls are shown below for the selected mode.
+ {serverModeError &&
{serverModeError}
} )} @@ -232,7 +474,9 @@ export function SettingsServerSection({
{entry.name}
{isActive && Active} -
TCP · {entry.host}
+
+ {entry.provider.toUpperCase()} · {entry.host} +
Last connected:{" "} {typeof entry.lastConnectedAtMs === "number" @@ -333,30 +577,81 @@ export function SettingsServerSection({ )} - {!isMobileSimplified && ( - - - void onUpdateAppSettings({ - ...appSettings, - keepDaemonRunningAfterAppClose: !appSettings.keepDaemonRunningAfterAppClose, - }) - } - /> - + {!isMobileSimplified && desktopIsLocalMode && ( +
+
Local mode is active
+
+ CodexMonitor will keep desktop requests local. Switch to mode 2 or 3 when you want + phone/remote access. +
+
)} -
+ {!isMobileSimplified && !desktopIsLocalMode && ( + <> + + + void onUpdateAppSettings({ + ...appSettings, + keepDaemonRunningAfterAppClose: !appSettings.keepDaemonRunningAfterAppClose, + }) + } + /> + + {desktopIsPublicMode && ( + + + void onUpdateAppSettings({ + ...appSettings, + keepTunnelRunningAfterAppClose: !appSettings.keepTunnelRunningAfterAppClose, + }) + } + /> + + )} + + )} + + {(isMobileSimplified || desktopIsPrivateMode) && ( +
Remote backend
+ {isMobileSimplified ? ( + + ) : ( +
+ TCP +
+ )} onSetRemoteHostDraft(event.target.value)} onBlur={() => { void onCommitRemoteHost(); @@ -370,10 +665,10 @@ export function SettingsServerSection({ aria-label="Remote backend host" /> onSetRemoteTokenDraft(event.target.value)} onBlur={() => { void onCommitRemoteToken(); @@ -387,13 +682,55 @@ export function SettingsServerSection({ aria-label="Remote backend token" />
+
+ + + +
{remoteHostError &&
{remoteHostError}
} + {tokenActionMessage && ( +
+ {tokenActionMessage} +
+ )} + {!isMobileSimplified && remoteStatusText && ( +
+ {remoteStatusText} +
+ )}
{isMobileSimplified - ? "Use the Tailscale host from your desktop CodexMonitor app (Server section), for example `macbook.your-tailnet.ts.net:4732`." + ? remoteProviderDraft === "wss" + ? "Use your public WSS endpoint (for example `wss://codex.example.com/daemon`) and the same token." + : "Use the Tailscale host from your desktop CodexMonitor app (Server section), for example `macbook.your-tailnet.ts.net:4732`." : "This host/token is used by mobile clients and desktop remote-mode testing."}
+ )} {isMobileSimplified && (
@@ -414,13 +751,303 @@ export function SettingsServerSection({
)}
- Make sure your desktop app daemon is running and reachable on Tailscale, then retry - this test. + {remoteProviderDraft === "wss" + ? "Make sure your tunnel endpoint is reachable and forwarding to the daemon WebSocket listener." + : "Make sure your desktop app daemon is running and reachable on Tailscale, then retry this test."}
)} - {!isMobileSimplified && ( + {!isMobileSimplified && desktopIsPublicMode && ( + !cloudflareInstalled ? ( +
+
Step 1: Install Cloudflare tunnel
+
+
+ `cloudflared` is required for public WSS mode. Install once, then continue with + daemon + phone connection. +
+
+ + +
+ {cloudflareTunnelStatusText && ( +
{cloudflareTunnelStatusText}
+ )} + {cloudflareTunnelStatus?.version && ( +
cloudflared: {cloudflareTunnelStatus.version}
+ )} +
+
+ ) : ( + <> +
+
Public WSS setup
+
+
    +
  1. + + {wizardStepLabel(cloudflareStep)} + + Step 1: Start Cloudflare tunnel +
  2. +
  3. + + {wizardStepLabel(daemonStep)} + + Step 2: Start mobile daemon +
  4. +
  5. + + {wizardStepLabel(connectStep)} + + Step 3: Connect phone with URL + password +
  6. +
+
+
+ +
+
Step 1: Cloudflare tunnel
+
+ + + +
+ {cloudflareTunnelStatusText && ( +
{cloudflareTunnelStatusText}
+ )} + {cloudflareTunnelStatus?.version && ( +
cloudflared: {cloudflareTunnelStatus.version}
+ )} + {cloudflareTunnelStatus?.localUrl && ( +
+ Local target: {cloudflareTunnelStatus.localUrl} +
+ )} +
+ +
+
Step 2: Mobile access daemon
+
+ + + +
+ {tcpRunnerStatusText &&
{tcpRunnerStatusText}
} + {tcpDaemonStatus?.startedAtMs && ( +
+ Started at: {new Date(tcpDaemonStatus.startedAtMs).toLocaleString()} +
+ )} +
+ +
+
Step 3: Connect phone
+
+ onSetRemoteTokenDraft(event.target.value)} + onBlur={() => { + void onCommitRemoteToken(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void onCommitRemoteToken(); + } + }} + aria-label="Remote backend token" + /> + + + +
+
+ onSetRemoteHostDraft(event.target.value)} + onBlur={() => { + void onCommitRemoteHost(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void onCommitRemoteHost(); + } + }} + aria-label="Remote backend host" + /> + + +
+ {remoteHostError &&
{remoteHostError}
} + {tokenActionMessage && ( +
+ {tokenActionMessage} +
+ )} + {wssActionMessage && ( +
+ {wssActionMessage} +
+ )} + {remoteStatusText && ( +
+ {remoteStatusText} +
+ )} + {cloudflareTunnelStatus?.suggestedWssUrl && ( +
+ Current tunnel URL: {cloudflareTunnelStatus.suggestedWssUrl} +
+ )} + {tunnelUrlApplied && ( +
Remote host is already using current tunnel URL.
+ )} +
+ On mobile, choose WSS and enter this URL + password. URL only changes when tunnel process changes. +
+
+ Advanced controls +
+ +
+
+
+ + ) + )} + + {!isMobileSimplified && desktopIsPrivateMode && (
Mobile access daemon
@@ -468,7 +1095,7 @@ export function SettingsServerSection({
)} - {!isMobileSimplified && ( + {!isMobileSimplified && desktopIsPrivateMode && (
Tailscale helper
@@ -546,8 +1173,8 @@ export function SettingsServerSection({
{isMobileSimplified - ? "Use your own infrastructure only. On iOS, get the Tailscale hostname and token from your desktop CodexMonitor setup." - : "Mobile access should stay scoped to your own infrastructure (tailnet). CodexMonitor does not provide hosted backend services."} + ? "Use your own infrastructure only. On iOS, configure either a Tailscale TCP host or a WSS tunnel endpoint with token auth." + : "Mobile access should stay scoped to your own infrastructure. CodexMonitor does not provide hosted backend services."}
{addRemoteOpen && (
+
+ + +