use crate::managed_node::ensure_managed_node_binary; use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::VecDeque; use std::env; #[cfg(windows)] use std::ffi::c_void; use std::ffi::OsStr; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; #[cfg(windows)] use std::mem::{size_of, zeroed}; use std::net::TcpStream; #[cfg(unix)] use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; #[cfg(windows)] use std::os::windows::io::AsRawHandle; #[cfg(windows)] use std::os::windows::process::CommandExt; #[cfg(windows)] use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; #[cfg(windows)] use windows_sys::Win32::System::JobObjects::{ AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:"; #[cfg(windows)] #[derive(Debug)] struct WindowsJobObject { // The desktop wrapper may observe only a short-lived Node wrapper PID while the real // server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives // Tauri an OS-owned handle for the whole subtree instead of relying on a single PID. handle: HANDLE, } #[cfg(windows)] impl WindowsJobObject { fn create() -> anyhow::Result { let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) }; if handle.is_null() { return Err(anyhow::anyhow!( "CreateJobObjectW failed: {}", std::io::Error::last_os_error() )); } let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() }; info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; let ok = unsafe { SetInformationJobObject( handle, JobObjectExtendedLimitInformation, &mut info as *mut _ as *mut c_void, size_of::() as u32, ) }; if ok == 0 { let err = std::io::Error::last_os_error(); unsafe { CloseHandle(handle); } return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err)); } Ok(Self { handle }) } fn assign_child(&self, child: &Child) -> anyhow::Result<()> { let process_handle = child.as_raw_handle() as HANDLE; let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) }; if ok == 0 { return Err(anyhow::anyhow!( "AssignProcessToJobObject failed: {}", std::io::Error::last_os_error() )); } Ok(()) } } #[cfg(windows)] impl Drop for WindowsJobObject { fn drop(&mut self) { if !self.handle.is_null() { unsafe { CloseHandle(self.handle); } } } } #[cfg(windows)] unsafe impl Send for WindowsJobObject {} #[cfg(windows)] unsafe impl Sync for WindowsJobObject {} fn log_line(message: &str) { println!("[tauri-cli] {message}"); } #[cfg(windows)] fn configure_spawn(command: &mut Command) { command.creation_flags(CREATE_NO_WINDOW); } #[cfg(not(windows))] fn configure_spawn(_command: &mut Command) {} fn workspace_root() -> Option { std::env::current_dir().ok().and_then(|mut dir| { for _ in 0..3 { if let Some(parent) = dir.parent() { dir = parent.to_path_buf(); } } Some(dir) }) } const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session"; const CLI_STOP_GRACE_SECS: u64 = 30; #[cfg(windows)] const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000; #[cfg(unix)] fn configure_posix_process_group(command: &mut Command) { // Ensure the CLI runs in its own process group so we can terminate wrapper // processes (login shell/tsx) without leaving the server orphaned. unsafe { command.pre_exec(|| { if libc::setpgid(0, 0) != 0 { return Err(std::io::Error::last_os_error()); } Ok(()) }); } } #[cfg(windows)] fn kill_process_tree_windows(pid: u32, force: bool) -> bool { let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()]; if force { args.push("/F".to_string()); } let mut command = Command::new("taskkill"); command.args(&args); configure_spawn(&mut command); match command.output() { Ok(output) => { if output.status.success() { return true; } // If the PID is already gone, treat it as success. let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase(); let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase(); let combined = format!("{stdout}\n{stderr}"); combined.contains("not found") || combined.contains("no running instance") } Err(_) => false, } } fn navigate_main(app: &AppHandle, url: &str) { if let Some(win) = app.webview_windows().get("main") { let mut display = url.to_string(); if let Some(hash_index) = display.find('#') { display.replace_range(hash_index + 1.., "[REDACTED]"); } log_line(&format!("navigating main to {display}")); if let Ok(parsed) = Url::parse(url) { let _ = win.navigate(parsed); } else { log_line("failed to parse URL for navigation"); } } else { log_line("main window not found for navigation"); } } fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { let prefix = format!("{name}="); let cookie_kv = set_cookie.split(';').next()?.trim(); if !cookie_kv.starts_with(&prefix) { return None; } let value = cookie_kv.trim_start_matches(&prefix).trim(); if value.is_empty() { return None; } Some(value.to_string()) } fn exchange_bootstrap_token( base_url: &str, token: &str, cookie_name: &str, ) -> anyhow::Result> { let parsed = Url::parse(base_url)?; let host = parsed.host_str().unwrap_or("127.0.0.1"); let port = parsed.port_or_known_default().unwrap_or(80); // This is only used for local bootstrap; we assume plain HTTP. let mut stream = TcpStream::connect((host, port))?; let body = format!("{{\"token\":\"{}\"}}", token); let request = format!( "POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.as_bytes().len(), body ); stream.write_all(request.as_bytes())?; stream.flush()?; let mut response = String::new(); stream.read_to_string(&mut response)?; let (raw_headers, _rest) = response .split_once("\r\n\r\n") .or_else(|| response.split_once("\n\n")) .unwrap_or((response.as_str(), "")); let mut lines = raw_headers.lines(); let status_line = lines.next().unwrap_or(""); if !status_line.contains(" 200 ") { return Ok(None); } for line in lines { // handle case-insensitive header name if let Some(value) = line.strip_prefix("Set-Cookie:") { if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) { return Ok(Some(session_id)); } } else if let Some(value) = line.strip_prefix("set-cookie:") { if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) { return Ok(Some(session_id)); } } } Ok(None) } fn set_session_cookie( app: &AppHandle, base_url: &str, cookie_name: &str, session_id: &str, ) -> anyhow::Result<()> { let parsed = Url::parse(base_url)?; let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string(); let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string())) .domain(domain) .path("/") .http_only(true) .same_site(tauri::webview::cookie::SameSite::Lax) .build(); if let Some(win) = app.webview_windows().get("main") { win.set_cookie(cookie)?; } Ok(()) } fn generate_auth_cookie_name() -> String { let pid = std::process::id(); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or(0); format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}") } const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; #[derive(Debug, Deserialize)] struct PreferencesConfig { #[serde(rename = "listeningMode")] listening_mode: Option, } #[derive(Debug, Deserialize)] struct ServerConfig { #[serde(rename = "listeningMode")] listening_mode: Option, } #[derive(Debug, Deserialize)] struct AppConfig { preferences: Option, server: Option, } fn resolve_config_locations() -> (PathBuf, PathBuf) { let raw = env::var("CLI_CONFIG") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); let expanded = expand_home(&raw); let lower = raw.trim().to_lowercase(); if lower.ends_with(".yaml") || lower.ends_with(".yml") { let base = expanded .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| expanded.clone()); return (expanded, base.join("config.json")); } if lower.ends_with(".json") { let base = expanded .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| expanded.clone()); return (base.join("config.yaml"), expanded); } // Treat as directory. (expanded.join("config.yaml"), expanded.join("config.json")) } fn expand_home(path: &str) -> PathBuf { if path.starts_with("~/") { if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) { return home.join(path.trim_start_matches("~/")); } } PathBuf::from(path) } fn resolve_listening_mode() -> String { let (yaml_path, json_path) = resolve_config_locations(); if let Ok(content) = fs::read_to_string(&yaml_path) { if let Ok(config) = serde_yaml::from_str::(&content) { let mode = config .server .as_ref() .and_then(|srv| srv.listening_mode.as_ref()) .or_else(|| { config .preferences .as_ref() .and_then(|prefs| prefs.listening_mode.as_ref()) }); if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } if mode == "all" { return "all".to_string(); } } } } // Legacy fallback. if let Ok(content) = fs::read_to_string(&json_path) { if let Ok(config) = serde_json::from_str::(&content) { let mode = config .server .as_ref() .and_then(|srv| srv.listening_mode.as_ref()) .or_else(|| { config .preferences .as_ref() .and_then(|prefs| prefs.listening_mode.as_ref()) }); if let Some(mode) = mode { if mode == "local" { return "local".to_string(); } if mode == "all" { return "all".to_string(); } } } } "local".to_string() } fn resolve_listening_host() -> String { let mode = resolve_listening_mode(); if mode == "local" { "127.0.0.1".to_string() } else { "0.0.0.0".to_string() } } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum CliState { Starting, Ready, Error, Stopped, } #[derive(Debug, Clone, Serialize)] pub struct CliStatus { pub state: CliState, pub pid: Option, pub port: Option, pub url: Option, pub error: Option, } impl Default for CliStatus { fn default() -> Self { Self { state: CliState::Stopped, pid: None, port: None, url: None, error: None, } } } #[derive(Debug, Clone)] pub struct CliProcessManager { status: Arc>, child: Arc>>, #[cfg(windows)] job: Arc>>, ready: Arc, bootstrap_token: Arc>>, } impl CliProcessManager { pub fn new() -> Self { Self { status: Arc::new(Mutex::new(CliStatus::default())), child: Arc::new(Mutex::new(None)), #[cfg(windows)] job: Arc::new(Mutex::new(None)), ready: Arc::new(AtomicBool::new(false)), bootstrap_token: Arc::new(Mutex::new(None)), } } pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> { log_line(&format!("start requested (dev={dev})")); self.stop()?; self.ready.store(false, Ordering::SeqCst); *self.bootstrap_token.lock() = None; { let mut status = self.status.lock(); status.state = CliState::Starting; status.port = None; status.url = None; status.error = None; status.pid = None; } Self::emit_status(&app, &self.status.lock()); let status_arc = self.status.clone(); let child_arc = self.child.clone(); #[cfg(windows)] let job_arc = self.job.clone(); let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); thread::spawn(move || { if let Err(err) = Self::spawn_cli( app.clone(), status_arc.clone(), child_arc, #[cfg(windows)] job_arc, ready_flag, token_arc, dev, ) { log_line(&format!("cli spawn failed: {err}")); let mut locked = status_arc.lock(); locked.state = CliState::Error; locked.error = Some(err.to_string()); let snapshot = locked.clone(); drop(locked); let _ = app.emit("cli:error", json!({"message": err.to_string()})); let _ = app.emit("cli:status", snapshot); } }); Ok(()) } pub fn stop(&self) -> anyhow::Result<()> { #[cfg(windows)] let _job = self.job.lock().take(); let mut child_opt = self.child.lock(); if let Some(mut child) = child_opt.take() { log_line(&format!("stopping CLI pid={}", child.id())); #[cfg(unix)] unsafe { let pid = child.id() as i32; // Prefer signaling the process group to avoid orphaning children // when the CLI was launched via a wrapper shell. let group_res = libc::kill(-pid, libc::SIGTERM); if group_res != 0 { let _ = libc::kill(pid, libc::SIGTERM); } } #[cfg(windows)] { let _ = kill_process_tree_windows(child.id(), false); } let start = Instant::now(); loop { match child.try_wait() { Ok(Some(_)) => break, Ok(None) => { #[cfg(windows)] if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) { log_line(&format!( "regular Windows shutdown still running after {}ms; escalating pid={}", CLI_WINDOWS_FORCE_GRACE_MS, child.id() )); if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } break; } if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { log_line(&format!( "stop timed out after {}s; sending SIGKILL pid={}", CLI_STOP_GRACE_SECS, child.id() )); #[cfg(unix)] unsafe { let pid = child.id() as i32; let group_res = libc::kill(-pid, libc::SIGKILL); if group_res != 0 { let _ = libc::kill(pid, libc::SIGKILL); } } #[cfg(windows)] { if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } } break; } thread::sleep(Duration::from_millis(50)); } Err(_) => break, } } } else { #[cfg(windows)] log_line("tracked CLI process already exited; dropping Windows job object to reap descendants"); } let mut status = self.status.lock(); status.state = CliState::Stopped; status.pid = None; status.port = None; status.url = None; status.error = None; Ok(()) } pub fn status(&self) -> CliStatus { self.status.lock().clone() } fn spawn_cli( app: AppHandle, status: Arc>, child_holder: Arc>>, #[cfg(windows)] job_holder: Arc>>, ready: Arc, bootstrap_token: Arc>>, dev: bool, ) -> anyhow::Result<()> { log_line("resolving CLI entry"); let resolution = CliEntry::resolve(&app, dev)?; let host = resolve_listening_host(); log_line(&format!( "resolved CLI entry runner={:?} entry={} host={}", resolution.runner, resolution.entry, host )); let auth_cookie_name = Arc::new(generate_auth_cookie_name()); let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { log_line("development mode: will prefer tsx + source if present"); } let cwd = workspace_root(); if let Some(ref c) = cwd { log_line(&format!("using cwd={}", c.display())); } let use_user_shell = supports_user_shell(); if !use_user_shell && which::which(&resolution.node_binary).is_err() { return Err(anyhow::anyhow!( "Node binary '{}' not found. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.", resolution.node_binary )); } let command_info = if use_user_shell { log_line("spawning via user shell"); ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?) } else { log_line(if resolution.runner == Runner::Tsx { "spawning directly with node + tsx" } else { "spawning directly with node" }); ShellCommandType::Direct(DirectCommand { program: resolution.node_binary.clone(), args: resolution.runner_args(&args), }) }; let child = match &command_info { ShellCommandType::UserShell(cmd) => { log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args)); let mut c = Command::new(&cmd.shell); c.args(&cmd.args) .env("ELECTRON_RUN_AS_NODE", "1") .env_remove("npm_config_prefix") .env_remove("NPM_CONFIG_PREFIX") .stdout(Stdio::piped()) .stderr(Stdio::piped()); configure_spawn(&mut c); if let Some(ref cwd) = cwd { c.current_dir(cwd); } #[cfg(unix)] configure_posix_process_group(&mut c); c.spawn()? } ShellCommandType::Direct(cmd) => { log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args)); let mut c = Command::new(&cmd.program); c.args(&cmd.args) .env("ELECTRON_RUN_AS_NODE", "1") .stdout(Stdio::piped()) .stderr(Stdio::piped()); configure_spawn(&mut c); if let Some(ref cwd) = cwd { c.current_dir(cwd); } #[cfg(unix)] configure_posix_process_group(&mut c); c.spawn()? } }; let pid = child.id(); log_line(&format!("spawned pid={pid}")); #[cfg(windows)] match WindowsJobObject::create().and_then(|job| { job.assign_child(&child)?; Ok(job) }) { Ok(job) => { log_line(&format!("attached pid={pid} to Windows job object")); *job_holder.lock() = Some(job); } Err(err) => { log_line(&format!( "failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}" )); } } { let mut locked = status.lock(); locked.pid = Some(pid); } Self::emit_status(&app, &status.lock()); { let mut holder = child_holder.lock(); *holder = Some(child); } let child_clone = child_holder.clone(); let status_clone = status.clone(); let app_clone = app.clone(); let ready_clone = ready.clone(); let token_clone = bootstrap_token.clone(); let auth_cookie_name_clone = auth_cookie_name.clone(); thread::spawn(move || { let stdout = child_clone .lock() .as_mut() .and_then(|c| c.stdout.take()) .map(BufReader::new); let stderr = child_clone .lock() .as_mut() .and_then(|c| c.stderr.take()) .map(BufReader::new); if let Some(reader) = stdout { let app = app_clone.clone(); let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( reader, "stdout", &app, &status, &ready, &token, auth_cookie_name.as_str(), ); }); } if let Some(reader) = stderr { let app = app_clone.clone(); let status = status_clone.clone(); let ready = ready_clone.clone(); let token = token_clone.clone(); let auth_cookie_name = auth_cookie_name_clone.clone(); thread::spawn(move || { Self::process_stream( reader, "stderr", &app, &status, &ready, &token, auth_cookie_name.as_str(), ); }); } }); let app_clone = app.clone(); let status_clone = status.clone(); let ready_clone = ready.clone(); let child_holder_clone = child_holder.clone(); #[cfg(windows)] let job_holder_clone = job_holder.clone(); thread::spawn(move || { let timeout = Duration::from_secs(60); thread::sleep(timeout); if ready_clone.load(Ordering::SeqCst) { return; } let mut locked = status_clone.lock(); locked.state = CliState::Error; locked.error = Some("CLI did not start in time".to_string()); log_line("timeout waiting for CLI readiness"); if let Some(child) = child_holder_clone.lock().as_mut() { #[cfg(unix)] unsafe { let pid = child.id() as i32; let group_res = libc::kill(-pid, libc::SIGKILL); if group_res != 0 { let _ = libc::kill(pid, libc::SIGKILL); } } #[cfg(windows)] { if !kill_process_tree_windows(child.id(), true) { let _ = child.kill(); } } #[cfg(not(any(unix, windows)))] { let _ = child.kill(); } } let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"})); Self::emit_status(&app_clone, &locked); }); let status_clone = status.clone(); let app_clone = app.clone(); thread::spawn(move || { // Do not hold the child mutex while waiting for process exit. // Holding the lock across `wait()` deadlocks `stop()`, which needs the // same lock to send SIGTERM/SIGKILL when the user quits the app. let code = loop { let maybe_exited = { let mut guard = child_holder.lock(); if guard.is_none() { return; } match guard .as_mut() .and_then(|child| child.try_wait().ok().flatten()) { Some(status) => { // Drop the handle after the process exits so other callers // don't attempt to stop/kill a finished process. *guard = None; #[cfg(windows)] { let _ = job_holder_clone.lock().take(); } Some(status) } None => None, } }; if let Some(status) = maybe_exited { break Some(status); } thread::sleep(Duration::from_millis(100)); }; let mut locked = status_clone.lock(); let failed = locked.state != CliState::Ready; let err_msg = if failed { Some(match code { Some(status) => format!("CLI exited early: {status}"), None => "CLI exited early".to_string(), }) } else { None }; if failed { locked.state = CliState::Error; if locked.error.is_none() { locked.error = err_msg.clone(); } log_line(&format!( "cli process exited before ready: {:?}", locked.error )); let _ = app_clone.emit( "cli:error", json!({"message": locked.error.clone().unwrap_or_default()}), ); } else { locked.state = CliState::Stopped; log_line("cli process stopped cleanly"); } Self::emit_status(&app_clone, &locked); }); Ok(()) } fn process_stream( mut reader: R, stream: &str, app: &AppHandle, status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, auth_cookie_name: &str, ) { let mut buffer = String::new(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok(); let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; loop { buffer.clear(); match reader.read_line(&mut buffer) { Ok(0) => break, Ok(_) => { let line = buffer.trim_end(); if !line.is_empty() { if line.starts_with(token_prefix) { let token = line.trim_start_matches(token_prefix).trim(); if !token.is_empty() { let mut guard = bootstrap_token.lock(); if guard.is_none() { *guard = Some(token.to_string()); } } continue; } log_line(&format!("[cli][{}] {}", stream, line)); if ready.load(Ordering::SeqCst) { continue; } if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) { let mut locked = status.lock(); if locked.error.is_none() { locked.error = Some(format!( "Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.", node_binary.trim() )); } continue; } if let Some(url) = local_url_regex .as_ref() .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .map(|m| m.as_str().to_string()) { Self::mark_ready( app, status, ready, bootstrap_token, auth_cookie_name, url, ); continue; } } } Err(_) => break, } } } fn mark_ready( app: &AppHandle, status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, auth_cookie_name: &str, base_url: String, ) { ready.store(true, Ordering::SeqCst); let port = Url::parse(&base_url) .ok() .and_then(|u| u.port_or_known_default()) .map(|p| p as u16); let mut locked = status.lock(); locked.port = port; locked.url = Some(base_url.clone()); locked.state = CliState::Ready; locked.error = None; log_line(&format!("cli ready on {base_url}")); let token = bootstrap_token.lock().take(); if let Some(token) = token { // Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS, // skip the exchange and let the user authenticate normally. let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string()); if scheme.as_deref() != Some("http") { navigate_main(app, &base_url); } else { match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) { Ok(Some(session_id)) => { if let Err(err) = set_session_cookie(app, &base_url, &auth_cookie_name, &session_id) { log_line(&format!("failed to set session cookie: {err}")); navigate_main(app, &format!("{base_url}/login")); } else { navigate_main(app, &base_url); } } Ok(None) => { log_line("bootstrap token exchange failed (invalid token)"); navigate_main(app, &format!("{base_url}/login")); } Err(err) => { log_line(&format!("bootstrap token exchange failed: {err}")); navigate_main(app, &format!("{base_url}/login")); } } } } else { navigate_main(app, &base_url); } let _ = app.emit("cli:ready", locked.clone()); Self::emit_status(app, &locked); } fn emit_status(app: &AppHandle, status: &CliStatus) { let _ = app.emit("cli:status", status.clone()); } } fn supports_user_shell() -> bool { cfg!(unix) } #[derive(Debug)] struct ShellCommand { shell: String, args: Vec, } #[derive(Debug)] struct DirectCommand { program: String, args: Vec, } #[derive(Debug)] enum ShellCommandType { UserShell(ShellCommand), Direct(DirectCommand), } #[derive(Debug)] struct CliEntry { entry: String, runner: Runner, runner_path: Option, node_binary: String, node_args: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Runner { Node, Tsx, } impl CliEntry { fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result { if dev { let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string()); if let Some(tsx_path) = resolve_tsx(app) { if let Some(entry) = resolve_dev_entry(app) { return Ok(Self { entry, runner: Runner::Tsx, runner_path: Some(tsx_path), node_binary, node_args: Vec::new(), }); } } } if let Some(entry) = resolve_prod_entry(app) { return Ok(Self { entry, runner: Runner::Node, runner_path: None, node_binary: ensure_managed_node_binary(app)?, node_args: vec!["--experimental-specifier-resolution=node".to_string()], }); } Err(anyhow::anyhow!( "Unable to locate the packaged CodeNomad server entrypoint (dist/bin.js). Please rebuild the desktop bundle." )) } fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec { let mut args = vec![ "serve".to_string(), "--host".to_string(), host.to_string(), "--auth-cookie-name".to_string(), auth_cookie_name.to_string(), "--generate-token".to_string(), "--unrestricted-root".to_string(), ]; if dev { // Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so // remote proxy sessions can still spin up secure local windows. let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL") .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| { std::env::var("ELECTRON_RENDERER_URL") .ok() .filter(|value| !value.trim().is_empty()) }) .unwrap_or_else(|| "http://localhost:3000".to_string()); let log_level = std::env::var("CLI_LOG_LEVEL") .ok() .map(|value| value.trim().to_lowercase()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "info".to_string()); args.push("--https".to_string()); args.push("true".to_string()); args.push("--http".to_string()); args.push("true".to_string()); args.push("--http-port".to_string()); args.push("0".to_string()); args.push("--ui-dev-server".to_string()); args.push(ui_dev_server); args.push("--log-level".to_string()); args.push(log_level); } else { // Prod desktop: always keep loopback HTTP enabled. args.push("--https".to_string()); args.push("true".to_string()); args.push("--http".to_string()); args.push("true".to_string()); } args } fn runner_args(&self, cli_args: &[String]) -> Vec { let mut args = VecDeque::new(); for arg in &self.node_args { args.push_back(arg.clone()); } if self.runner == Runner::Tsx { if let Some(path) = &self.runner_path { args.push_back(path.clone()); } } args.push_back(self.entry.clone()); for arg in cli_args { args.push_back(arg.clone()); } args.into_iter().collect() } } fn resolve_tsx(_app: &AppHandle) -> Option { let cwd = std::env::current_dir().ok(); let workspace = workspace_root(); let mut candidates = vec![ cwd.as_ref() .map(|p| p.join("node_modules/tsx/dist/cli.mjs")), cwd.as_ref() .map(|p| p.join("node_modules/tsx/dist/cli.cjs")), cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")), cwd.as_ref() .map(|p| p.join("../node_modules/tsx/dist/cli.mjs")), cwd.as_ref() .map(|p| p.join("../node_modules/tsx/dist/cli.cjs")), cwd.as_ref() .map(|p| p.join("../node_modules/tsx/dist/cli.js")), cwd.as_ref() .map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")), cwd.as_ref() .map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")), cwd.as_ref() .map(|p| p.join("../../node_modules/tsx/dist/cli.js")), workspace .as_ref() .map(|p| p.join("node_modules/tsx/dist/cli.mjs")), workspace .as_ref() .map(|p| p.join("node_modules/tsx/dist/cli.cjs")), workspace .as_ref() .map(|p| p.join("node_modules/tsx/dist/cli.js")), ]; if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs"))); candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs"))); candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js"))); } } first_existing(candidates) } fn resolve_dev_entry(_app: &AppHandle) -> Option { let cwd = std::env::current_dir().ok(); let workspace = workspace_root(); let candidates = vec![ workspace .as_ref() .map(|p| p.join("packages/server/src/index.ts")), cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")), cwd.as_ref().map(|p| p.join("../server/src/index.ts")), cwd.as_ref().map(|p| p.join("../../server/src/index.ts")), ]; first_existing(candidates) } fn resolve_prod_entry(_app: &AppHandle) -> Option { let base = workspace_root(); let mut candidates = vec![base .as_ref() .map(|p| p.join("packages/server/dist/bin.js"))]; if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { candidates.push(Some(dir.join("resources/server/dist/bin.js"))); let resources = dir.join("../Resources"); candidates.push(Some(resources.join("server/dist/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/bin.js"))); let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; for root in linux_resource_roots { candidates.push(Some(root.join("server/dist/bin.js"))); candidates.push(Some(root.join("resources/server/dist/bin.js"))); } } } first_existing(candidates) } fn build_shell_command_string( entry: &CliEntry, cli_args: &[String], ) -> anyhow::Result { let shell = default_shell(); let mut quoted: Vec = Vec::new(); quoted.push(shell_escape(&entry.node_binary)); for arg in entry.runner_args(cli_args) { quoted.push(shell_escape(&arg)); } let command = format!( "if [ -x {} ] || command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {}; exit 127; fi", shell_escape(&entry.node_binary), shell_escape(&entry.node_binary), quoted.join(" "), MISSING_NODE_PREFIX, shell_escape(&entry.node_binary), ); let wrapped_command = wrap_command_for_shell(&command, &shell); let args = build_shell_args(&shell, &wrapped_command); log_line(&format!("user shell command: {} {:?}", shell, args)); Ok(ShellCommand { shell, args }) } fn default_shell() -> String { if let Ok(shell) = std::env::var("SHELL") { if !shell.trim().is_empty() { return shell; } } if cfg!(target_os = "macos") { "/bin/zsh".to_string() } else { "/bin/bash".to_string() } } fn wrap_command_for_shell(command: &str, shell: &str) -> String { let shell_name = std::path::Path::new(shell) .file_name() .and_then(OsStr::to_str) .unwrap_or("") .to_lowercase(); if shell_name.contains("bash") { return format!( "if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; {}", command ); } if shell_name.contains("zsh") { return format!( "if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; {}", command ); } command.to_string() } fn shell_escape(input: &str) -> String { if input.is_empty() { "''".to_string() } else if !input .chars() .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!')) { input.to_string() } else { let escaped = input.replace('\'', "'\\''"); format!("'{}'", escaped) } } fn build_shell_args(shell: &str, command: &str) -> Vec { let shell_name = std::path::Path::new(shell) .file_name() .and_then(OsStr::to_str) .unwrap_or("") .to_lowercase(); if shell_name.contains("zsh") || shell_name.contains("bash") { vec!["-i".into(), "-l".into(), "-c".into(), command.into()] } else { vec!["-l".into(), "-c".into(), command.into()] } } fn first_existing(paths: Vec>) -> Option { paths .into_iter() .flatten() .find(|p| p.exists()) .map(|p| normalize_path(p)) } fn normalize_path(path: PathBuf) -> String { let resolved = if let Ok(clean) = path.canonicalize() { clean } else { path }; let rendered = resolved.to_string_lossy().to_string(); if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") { format!("\\\\{}", stripped) } else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") { stripped.to_string() } else { rendered } }