#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod cache_bounds; use std::collections::HashMap; use std::env; use std::fs::{self, File, OpenOptions}; use std::io::Write; #[cfg(windows)] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use keyring::Entry; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Manager, RunEvent, Webview, WebviewUrl, WebviewWindowBuilder, WindowEvent}; use cache_bounds::validate_cache_write_sizes; const DEFAULT_LOCAL_API_PORT: u16 = 46123; const MAX_LOCAL_API_PROXY_BYTES: usize = 16 * 1024 * 1024; const KEYRING_SERVICE: &str = "world-monitor"; const LOCAL_API_LOG_FILE: &str = "local-api.log"; const DESKTOP_LOG_FILE: &str = "desktop.log"; const MENU_FILE_SETTINGS_ID: &str = "file.settings"; const MENU_HELP_GITHUB_ID: &str = "help.github"; #[cfg(feature = "devtools")] const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools"; const TRUSTED_WINDOWS: [&str; 3] = ["main", "settings", "live-channels"]; const SECRET_MANAGEMENT_WINDOWS: [&str; 2] = ["main", "settings"]; const DESKTOP_SHARED_SECRET_KEY: &str = "WM_DESKTOP_SHARED_SECRET"; const BUILD_TIME_SIDECAR_ENV_KEYS: [&str; 2] = ["CONVEX_URL", DESKTOP_SHARED_SECRET_KEY]; const SUPPORTED_SECRET_KEYS: [&str; 29] = [ "GROQ_API_KEY", "OPENROUTER_API_KEY", "EXA_API_KEYS", "BRAVE_API_KEYS", "SERPAPI_API_KEYS", "FRED_API_KEY", "EIA_API_KEY", "CLOUDFLARE_API_TOKEN", "ACLED_ACCESS_TOKEN", "URLHAUS_AUTH_KEY", "OTX_API_KEY", "ABUSEIPDB_API_KEY", "WINGBITS_API_KEY", "WS_RELAY_URL", "VITE_OPENSKY_RELAY_URL", "OPENSKY_CLIENT_ID", "OPENSKY_CLIENT_SECRET", "AISSTREAM_API_KEY", "VITE_WS_RELAY_URL", "FINNHUB_API_KEY", "NASA_FIRMS_API_KEY", "UCDP_ACCESS_TOKEN", "OLLAMA_API_URL", "OLLAMA_MODEL", "WORLDMONITOR_API_KEY", "WTO_API_KEY", "AVIATIONSTACK_API", "ICAO_API_KEY", DESKTOP_SHARED_SECRET_KEY, ]; struct LocalApiState { child: Mutex>, token: Mutex>, port: Mutex>, http_client: reqwest::Client, } impl Default for LocalApiState { fn default() -> Self { Self { child: Mutex::new(None), token: Mutex::new(None), port: Mutex::new(None), http_client: reqwest::Client::builder() .use_native_tls() .pool_max_idle_per_host(2) .build() .unwrap_or_default(), } } } /// In-memory cache for keychain secrets. Populated once at startup to avoid /// repeated macOS Keychain prompts (each `Entry::get_password()` triggers one). struct SecretsCache { secrets: Mutex>, } /// In-memory mirror of persistent-cache.json. The file can grow to 10+ MB, /// so reading/parsing/writing it on every IPC call blocks the main thread. /// Instead, load once into RAM and serialize writes to preserve ordering. struct PersistentCache { data: Mutex>, dirty: Mutex, write_lock: Mutex<()>, generation: Mutex, flush_scheduled: Mutex, } impl SecretsCache { fn load_from_keychain() -> Self { // Try consolidated vault first — single keychain prompt if let Ok(entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") { if let Ok(json) = entry.get_password() { if let Ok(map) = serde_json::from_str::>(&json) { let secrets: HashMap = map .into_iter() .filter(|(k, v)| { SUPPORTED_SECRET_KEYS.contains(&k.as_str()) && !v.trim().is_empty() }) .map(|(k, v)| (k, v.trim().to_string())) .collect(); return SecretsCache { secrets: Mutex::new(secrets), }; } } } // Migration: read individual keys (old format), consolidate into vault. // This triggers one keychain prompt per key — happens only once. let mut secrets = HashMap::new(); for key in SUPPORTED_SECRET_KEYS.iter() { if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) { if let Ok(value) = entry.get_password() { let trimmed = value.trim().to_string(); if !trimmed.is_empty() { secrets.insert((*key).to_string(), trimmed); } } } } // Write consolidated vault and clean up individual entries if !secrets.is_empty() { if let Ok(json) = serde_json::to_string(&secrets) { if let Ok(vault_entry) = Entry::new(KEYRING_SERVICE, "secrets-vault") { if vault_entry.set_password(&json).is_ok() { for key in SUPPORTED_SECRET_KEYS.iter() { if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) { let _ = entry.delete_credential(); } } } } } } SecretsCache { secrets: Mutex::new(secrets), } } } impl PersistentCache { fn load(path: &Path) -> Self { let data = if path.exists() { std::fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|v| v.as_object().cloned()) .unwrap_or_default() } else { Map::new() }; PersistentCache { data: Mutex::new(data), dirty: Mutex::new(false), write_lock: Mutex::new(()), generation: Mutex::new(0), flush_scheduled: Mutex::new(false), } } fn get(&self, key: &str) -> Option { let data = self.data.lock().unwrap_or_else(|e| e.into_inner()); data.get(key).cloned() } /// Flush to disk only if dirty. Returns Ok(true) if written. /// Uses atomic write (temp file + rename) to prevent corruption on crash. fn flush(&self, path: &Path) -> Result { let _write_guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner()); let is_dirty = { let dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty }; if !is_dirty { return Ok(false); } let data = self.data.lock().unwrap_or_else(|e| e.into_inner()); let serialized = serde_json::to_string(&Value::Object(data.clone())) .map_err(|e| format!("Failed to serialize cache: {e}"))?; drop(data); let tmp = path.with_extension("tmp"); std::fs::write(&tmp, &serialized) .map_err(|e| format!("Failed to write cache tmp {}: {e}", tmp.display()))?; std::fs::rename(&tmp, path) .map_err(|e| format!("Failed to rename cache {}: {e}", path.display()))?; let mut dirty = self.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty = false; Ok(true) } } #[derive(Serialize)] struct DesktopRuntimeInfo { os: String, arch: String, local_api_port: Option, } #[derive(Deserialize)] struct LocalApiProxyRequest { method: String, path: String, headers: HashMap, body: Option>, } #[derive(Serialize)] struct LocalApiProxyResponse { status: u16, headers: HashMap, body: Vec, } #[derive(Serialize)] struct SecretValidationResponse { status: u16, payload: Value, } fn save_vault(cache: &HashMap) -> Result<(), String> { let json = serde_json::to_string(cache).map_err(|e| format!("Failed to serialize vault: {e}"))?; let entry = Entry::new(KEYRING_SERVICE, "secrets-vault") .map_err(|e| format!("Keyring init failed: {e}"))?; entry .set_password(&json) .map_err(|e| format!("Failed to write vault: {e}"))?; Ok(()) } fn generate_local_token() -> String { let mut buf = [0u8; 32]; getrandom::getrandom(&mut buf).expect("OS CSPRNG unavailable"); buf.iter().map(|b| format!("{b:02x}")).collect() } fn require_trusted_window(label: &str) -> Result<(), String> { if TRUSTED_WINDOWS.contains(&label) { Ok(()) } else { Err(format!("Command not allowed from window '{label}'")) } } fn can_manage_renderer_secrets(label: &str) -> bool { SECRET_MANAGEMENT_WINDOWS.contains(&label) } // The shared desktop secret is host-managed. All other supported vault keys // may be configured by the first-party main/settings renderers. fn is_renderer_managed_secret_key(key: &str) -> bool { key != DESKTOP_SHARED_SECRET_KEY && SUPPORTED_SECRET_KEYS.contains(&key) } fn require_secret_management_window(label: &str) -> Result<(), String> { if can_manage_renderer_secrets(label) { Ok(()) } else { Err(format!("Secret management not allowed from window '{label}'")) } } fn configured_renderer_secret_keys(secrets: &HashMap) -> Vec { secrets .keys() .filter(|key| is_renderer_managed_secret_key(key)) .cloned() .collect() } fn normalized_local_api_proxy_path(path: &str) -> Result { let url = Url::parse(&format!("http://127.0.0.1{path}")) .map_err(|_| "Invalid local API path".to_string())?; if url.host_str() != Some("127.0.0.1") || url.fragment().is_some() { return Err("Invalid local API path".to_string()); } Ok(match url.query() { Some(query) => format!("{}?{query}", url.path()), None => url.path().to_string(), }) } fn normalized_local_api_proxy_path_is_allowed(normalized_path: &str) -> bool { let route = normalized_path.split('?').next().unwrap_or(normalized_path); route.starts_with("/api/") && !route.starts_with("//") && (!route.starts_with("/api/local-") || matches!(route, "/api/local-debug-toggle" | "/api/local-traffic-log")) // Keep the denylist explicit as a guard against accidentally widening // the local-* exception above during future maintenance. && route != "/api/local-env-update" && route != "/api/local-env-update-batch" && route != "/api/local-validate-secret" } #[cfg(test)] fn local_api_proxy_path_is_allowed(path: &str) -> bool { normalized_local_api_proxy_path(path) .is_ok_and(|normalized| normalized_local_api_proxy_path_is_allowed(&normalized)) } async fn send_local_api_request( state: &LocalApiState, method: &str, path: &str, headers: &HashMap, body: Option>, ) -> Result { if body.as_ref().is_some_and(|body| body.len() > MAX_LOCAL_API_PROXY_BYTES) { return Err("Local API request body exceeds the proxy limit".to_string()); } let port = state .port .lock() .map_err(|_| "Failed to lock local API port".to_string())? .ok_or_else(|| "Local API sidecar is not ready".to_string())?; let token = state .token .lock() .map_err(|_| "Failed to lock local API token".to_string())? .clone() .ok_or_else(|| "Local API token is unavailable".to_string())?; let method = reqwest::Method::from_bytes(method.as_bytes()) .map_err(|_| "Unsupported local API method".to_string())?; let url = Url::parse(&format!("http://127.0.0.1:{port}{path}")) .map_err(|_| "Invalid local API path".to_string())?; let mut request = state.http_client.request(method, url).bearer_auth(token); for (name, value) in headers { if !name.eq_ignore_ascii_case("authorization") && !name.eq_ignore_ascii_case("host") && !name.eq_ignore_ascii_case("content-length") { request = request.header(name, value); } } if let Some(body) = body { request = request.body(body); } let response = request .send() .await .map_err(|error| format!("Local API request failed: {error}"))?; let status = response.status().as_u16(); let headers = response .headers() .iter() .filter_map(|(name, value)| value.to_str().ok().map(|value| (name.to_string(), value.to_string()))) .collect(); let body = response .bytes() .await .map_err(|error| format!("Failed to read local API response: {error}"))? .to_vec(); if body.len() > MAX_LOCAL_API_PROXY_BYTES { return Err("Local API response exceeds the proxy limit".to_string()); } Ok(LocalApiProxyResponse { status, headers, body }) } #[tauri::command] async fn proxy_local_api_request( webview: Webview, request: LocalApiProxyRequest, state: tauri::State<'_, LocalApiState>, ) -> Result { require_secret_management_window(webview.label())?; let normalized_path = normalized_local_api_proxy_path(&request.path)?; let route = normalized_path.split('?').next().unwrap_or(&normalized_path); if !normalized_local_api_proxy_path_is_allowed(&normalized_path) { return Err(format!("Local API route is not proxyable: {route}")); } send_local_api_request(&state, &request.method, &normalized_path, &request.headers, request.body).await } #[tauri::command] fn get_desktop_runtime_info(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result { require_trusted_window(webview.label())?; let port = state.port.lock().ok().and_then(|g| *g); Ok(DesktopRuntimeInfo { os: env::consts::OS.to_string(), arch: env::consts::ARCH.to_string(), local_api_port: port, }) } #[tauri::command] fn get_local_api_port(webview: Webview, state: tauri::State<'_, LocalApiState>) -> Result { require_trusted_window(webview.label())?; state.port.lock() .map_err(|_| "Failed to lock port state".to_string())? .ok_or_else(|| "Port not yet assigned".to_string()) } #[tauri::command] fn list_configured_secret_keys( webview: Webview, cache: tauri::State<'_, SecretsCache>, ) -> Result, String> { require_secret_management_window(webview.label())?; let secrets = cache .secrets .lock() .map_err(|_| "Lock poisoned".to_string())?; Ok(configured_renderer_secret_keys(&secrets)) } fn update_renderer_secret_cache( cache: &SecretsCache, key: &str, value: Option<&str>, ) -> Result<(), String> { let mut secrets = cache .secrets .lock() .map_err(|_| "Lock poisoned".to_string())?; // Build proposed state, persist first, then commit to cache. let mut proposed = secrets.clone(); match value { Some(value) => { proposed.insert(key.to_string(), value.to_string()); } None => { proposed.remove(key); } } save_vault(&proposed)?; *secrets = proposed; Ok(()) } async fn sync_renderer_secret_to_sidecar( state: &LocalApiState, key: &str, value: Option<&str>, ) { let body = match serde_json::to_vec(&serde_json::json!({ "key": key, "value": value })) { Ok(body) => body, Err(error) => { eprintln!("[tauri] failed to serialize local secret sync for {key}: {error}"); return; } }; let headers = HashMap::from([(String::from("Content-Type"), String::from("application/json"))]); match send_local_api_request(state, "POST", "/api/local-env-update", &headers, Some(body)).await { Ok(response) if (200..300).contains(&response.status) => {} Ok(response) => eprintln!( "[tauri] local secret sync for {key} returned HTTP {}", response.status ), Err(error) => eprintln!("[tauri] local secret sync failed for {key}: {error}"), } } #[tauri::command] async fn set_secret( webview: Webview, key: String, value: String, cache: tauri::State<'_, SecretsCache>, state: tauri::State<'_, LocalApiState>, ) -> Result<(), String> { require_secret_management_window(webview.label())?; if !is_renderer_managed_secret_key(&key) { return Err(format!("Unsupported secret key: {key}")); } let value = (!value.trim().is_empty()).then(|| value.trim().to_string()); update_renderer_secret_cache(&cache, &key, value.as_deref())?; sync_renderer_secret_to_sidecar(&state, &key, value.as_deref()).await; Ok(()) } #[tauri::command] async fn delete_secret( webview: Webview, key: String, cache: tauri::State<'_, SecretsCache>, state: tauri::State<'_, LocalApiState>, ) -> Result<(), String> { require_secret_management_window(webview.label())?; if !is_renderer_managed_secret_key(&key) { return Err(format!("Unsupported secret key: {key}")); } update_renderer_secret_cache(&cache, &key, None)?; sync_renderer_secret_to_sidecar(&state, &key, None).await; Ok(()) } #[tauri::command] async fn validate_secret_with_sidecar( webview: Webview, key: String, value: String, context: HashMap, state: tauri::State<'_, LocalApiState>, ) -> Result { require_secret_management_window(webview.label())?; if !is_renderer_managed_secret_key(&key) { return Err(format!("Unsupported secret key: {key}")); } let body = serde_json::to_vec(&serde_json::json!({ "key": key, "value": value, "context": context })) .map_err(|error| format!("Failed to serialize secret validation: {error}"))?; let headers = HashMap::from([(String::from("Content-Type"), String::from("application/json"))]); let response = send_local_api_request(&state, "POST", "/api/local-validate-secret", &headers, Some(body)).await?; let payload = serde_json::from_slice(&response.body).unwrap_or_else(|_| { serde_json::json!({ "valid": false, "message": format!("Secret validation returned HTTP {} with an invalid response", response.status), }) }); Ok(SecretValidationResponse { status: response.status, payload, }) } fn cache_file_path(app: &AppHandle) -> Result { let dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; std::fs::create_dir_all(&dir) .map_err(|e| format!("Failed to create app data directory {}: {e}", dir.display()))?; Ok(dir.join("persistent-cache.json")) } #[tauri::command] fn read_cache_entry(webview: Webview, cache: tauri::State<'_, PersistentCache>, key: String) -> Result, String> { require_trusted_window(webview.label())?; Ok(cache.get(&key)) } const MAX_FLUSH_RETRIES: u32 = 5; fn schedule_debounced_flush(cache: &PersistentCache, app: &AppHandle) { { let mut gen = cache.generation.lock().unwrap_or_else(|e| e.into_inner()); *gen += 1; } let should_spawn = { let mut sched = cache.flush_scheduled.lock().unwrap_or_else(|e| e.into_inner()); if *sched { false } else { *sched = true; true } }; if should_spawn { let handle = app.app_handle().clone(); std::thread::spawn(move || { let mut retries = 0u32; loop { std::thread::sleep(std::time::Duration::from_secs(2)); let Some(c) = handle.try_state::() else { break }; let Ok(path) = cache_file_path(&handle) else { break }; let gen_before = *c.generation.lock().unwrap_or_else(|e| e.into_inner()); match c.flush(&path) { Ok(_) => { retries = 0; let gen_after = *c.generation.lock().unwrap_or_else(|e| e.into_inner()); if gen_after > gen_before { continue; } *c.flush_scheduled.lock().unwrap_or_else(|e| e.into_inner()) = false; break; } Err(e) => { retries += 1; eprintln!("[cache] flush error ({retries}/{MAX_FLUSH_RETRIES}): {e}"); if retries >= MAX_FLUSH_RETRIES { eprintln!("[cache] giving up after {MAX_FLUSH_RETRIES} failures"); *c.flush_scheduled.lock().unwrap_or_else(|e| e.into_inner()) = false; break; } continue; } } } }); } } #[tauri::command] fn delete_cache_entry(webview: Webview, app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String) -> Result<(), String> { require_trusted_window(webview.label())?; { let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); data.remove(&key); } { let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty = true; } schedule_debounced_flush(&cache, &app); Ok(()) } #[tauri::command] fn delete_cache_entries_by_prefix(webview: Webview, app: AppHandle, cache: tauri::State<'_, PersistentCache>, prefix: String) -> Result<(), String> { require_trusted_window(webview.label())?; let suffix = prefix .strip_prefix("breaker:") .ok_or_else(|| "delete_cache_entries_by_prefix only accepts breaker: prefixes".to_string())?; if suffix.is_empty() || suffix.chars().all(|ch| ch == ':') { return Err("delete_cache_entries_by_prefix requires a specific breaker: prefix".to_string()); } let removed_any = { let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); let before = data.len(); data.retain(|key, _| !key.starts_with(&prefix)); data.len() != before }; if removed_any { { let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty = true; } schedule_debounced_flush(&cache, &app); } Ok(()) } #[tauri::command] fn write_cache_entry(webview: Webview, app: AppHandle, cache: tauri::State<'_, PersistentCache>, key: String, value: String) -> Result<(), String> { require_trusted_window(webview.label())?; validate_cache_write_sizes(&key, &value)?; let parsed_value: Value = serde_json::from_str(&value) .map_err(|e| format!("Invalid cache payload JSON: {e}"))?; { let mut data = cache.data.lock().unwrap_or_else(|e| e.into_inner()); data.insert(key, parsed_value); } { let mut dirty = cache.dirty.lock().unwrap_or_else(|e| e.into_inner()); *dirty = true; } schedule_debounced_flush(&cache, &app); Ok(()) } fn logs_dir_path(app: &AppHandle) -> Result { let dir = app .path() .app_log_dir() .map_err(|e| format!("Failed to resolve app log dir: {e}"))?; fs::create_dir_all(&dir) .map_err(|e| format!("Failed to create app log dir {}: {e}", dir.display()))?; Ok(dir) } fn sidecar_log_path(app: &AppHandle) -> Result { Ok(logs_dir_path(app)?.join(LOCAL_API_LOG_FILE)) } fn desktop_log_path(app: &AppHandle) -> Result { Ok(logs_dir_path(app)?.join(DESKTOP_LOG_FILE)) } fn append_desktop_log(app: &AppHandle, level: &str, message: &str) { let Ok(path) = desktop_log_path(app) else { return; }; let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else { return; }; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let _ = writeln!(file, "[{timestamp}][{level}] {message}"); } fn open_in_shell(arg: &str) -> Result<(), String> { // Linux keeps its own path: spawn xdg-open directly with LD_* scrubbed // (library-injection hardening that a generic opener would drop). #[cfg(all(unix, not(target_os = "macos")))] { let mut cmd = Command::new("xdg-open"); cmd.arg(arg); cmd.env_remove("LD_LIBRARY_PATH"); cmd.env_remove("LD_PRELOAD"); cmd.spawn() .map(|_| ()) .map_err(|e| format!("Failed to open {}: {e}", arg)) } // macOS + Windows: `opener` opens the target with the OS default handler // via `/usr/bin/open` (macOS) and `ShellExecuteW` (Windows). It NEVER routes // through `cmd.exe`, so a URL containing shell metacharacters (`&`, `|`, …) // is passed as a single argument and cannot inject commands. This is the fix // for GHSA-2x6r-qq54-mmhr: the old Windows branch ran `cmd /c start "" ` // with the URL unquoted, so `https://x/?a=1&calc` executed `calc` on click. #[cfg(not(all(unix, not(target_os = "macos"))))] { opener::open(arg).map_err(|e| format!("Failed to open {}: {e}", arg)) } } fn open_path_in_shell(path: &Path) -> Result<(), String> { open_in_shell(&path.to_string_lossy()) } #[tauri::command] fn open_url(webview: Webview, url: String) -> Result<(), String> { require_trusted_window(webview.label())?; let parsed = Url::parse(&url).map_err(|_| "Invalid URL".to_string())?; match parsed.scheme() { "https" => open_in_shell(parsed.as_str()), "http" => match parsed.host_str() { Some("localhost") | Some("127.0.0.1") => open_in_shell(parsed.as_str()), _ => Err("Only https:// URLs are allowed (http:// only for localhost)".to_string()), }, _ => Err("Only https:// URLs are allowed (http:// only for localhost)".to_string()), } } fn open_logs_folder_impl(app: &AppHandle) -> Result { let dir = logs_dir_path(app)?; open_path_in_shell(&dir)?; Ok(dir) } fn open_sidecar_log_impl(app: &AppHandle) -> Result { let log_path = sidecar_log_path(app)?; if !log_path.exists() { File::create(&log_path) .map_err(|e| format!("Failed to create sidecar log {}: {e}", log_path.display()))?; } open_path_in_shell(&log_path)?; Ok(log_path) } #[tauri::command] fn open_logs_folder(webview: Webview, app: AppHandle) -> Result { require_trusted_window(webview.label())?; open_logs_folder_impl(&app).map(|path| path.display().to_string()) } #[tauri::command] fn open_sidecar_log_file(webview: Webview, app: AppHandle) -> Result { require_trusted_window(webview.label())?; open_sidecar_log_impl(&app).map(|path| path.display().to_string()) } #[tauri::command] async fn open_settings_window_command(webview: Webview, app: AppHandle) -> Result<(), String> { require_trusted_window(webview.label())?; open_settings_window(&app) } #[tauri::command] fn close_settings_window(webview: Webview, app: AppHandle) -> Result<(), String> { require_trusted_window(webview.label())?; if let Some(window) = app.get_webview_window("settings") { window .close() .map_err(|e| format!("Failed to close settings window: {e}"))?; } Ok(()) } #[tauri::command] async fn open_live_channels_window_command( webview: Webview, app: AppHandle, base_url: Option, ) -> Result<(), String> { require_trusted_window(webview.label())?; if let Some(ref url) = base_url { if !url.is_empty() { let parsed = Url::parse(url).map_err(|_| "Invalid base URL".to_string())?; match parsed.scheme() { "http" => match parsed.host_str() { Some("localhost") | Some("127.0.0.1") => {} _ => return Err("base_url http only allowed for localhost".to_string()), }, "https" => {} _ => return Err("base_url must be http(s)".to_string()), } } } open_live_channels_window(&app, base_url) } #[tauri::command] fn close_live_channels_window(webview: Webview, app: AppHandle) -> Result<(), String> { require_trusted_window(webview.label())?; if let Some(window) = app.get_webview_window("live-channels") { window .close() .map_err(|e| format!("Failed to close live channels window: {e}"))?; } Ok(()) } /// Fetch JSON from Polymarket Gamma API using native TLS (bypasses Cloudflare JA3 blocking). /// Called from frontend when browser CORS and sidecar Node.js TLS both fail. #[tauri::command] async fn fetch_polymarket(webview: Webview, state: tauri::State<'_, LocalApiState>, path: String, params: String) -> Result { require_trusted_window(webview.label())?; let allowed = ["events", "markets", "tags"]; let segment = path.trim_start_matches('/'); if !allowed.iter().any(|a| segment.starts_with(a)) { return Err("Invalid Polymarket path".into()); } let url = format!("https://gamma-api.polymarket.com/{}?{}", segment, params); let resp = state.http_client .get(&url) .header("Accept", "application/json") .timeout(std::time::Duration::from_secs(10)) .send() .await .map_err(|e| format!("Polymarket fetch failed: {e}"))?; if !resp.status().is_success() { return Err(format!("Polymarket HTTP {}", resp.status())); } resp.text() .await .map_err(|e| format!("Read body failed: {e}")) } fn open_settings_window(app: &AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("settings") { let _ = window.show(); window .set_focus() .map_err(|e| format!("Failed to focus settings window: {e}"))?; return Ok(()); } #[allow(unused_mut)] let mut settings_builder = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into())) .title("World Monitor Settings") .inner_size(980.0, 600.0) .min_inner_size(820.0, 480.0) .resizable(true) .background_color(tauri::webview::Color(26, 28, 30, 255)); #[cfg(target_os = "macos")] { settings_builder = settings_builder.title_bar_style(tauri::TitleBarStyle::Overlay); } let _settings_window = settings_builder.build() .map_err(|e| format!("Failed to create settings window: {e}"))?; // On Windows/Linux, menus are per-window. Remove the inherited app menu // from the settings window (macOS uses a shared app-wide menu bar instead). #[cfg(not(target_os = "macos"))] let _ = _settings_window.remove_menu(); Ok(()) } fn open_live_channels_window(app: &AppHandle, base_url: Option) -> Result<(), String> { if let Some(window) = app.get_webview_window("live-channels") { let _ = window.show(); window .set_focus() .map_err(|e| format!("Failed to focus live channels window: {e}"))?; return Ok(()); } // In dev, use the same origin as the main window (e.g. http://localhost:3001) so we don't // get "connection refused" when Vite runs on a different port than devUrl. let url = match base_url { Some(ref origin) if !origin.is_empty() => { let path = origin.trim_end_matches('/'); let full_url = format!("{}/live-channels.html", path); WebviewUrl::External(Url::parse(&full_url).map_err(|_| "Invalid base URL".to_string())?) } _ => WebviewUrl::App("live-channels.html".into()), }; #[allow(unused_mut)] let mut channels_builder = WebviewWindowBuilder::new(app, "live-channels", url) .title("Channel management - World Monitor") .inner_size(680.0, 760.0) .min_inner_size(520.0, 600.0) .resizable(true) .background_color(tauri::webview::Color(26, 28, 30, 255)); #[cfg(target_os = "macos")] { channels_builder = channels_builder.title_bar_style(tauri::TitleBarStyle::Overlay); } let _live_channels_window = channels_builder.build() .map_err(|e| format!("Failed to create live channels window: {e}"))?; #[cfg(not(target_os = "macos"))] let _ = _live_channels_window.remove_menu(); Ok(()) } fn open_youtube_login_window(app: &AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("youtube-login") { let _ = window.show(); window .set_focus() .map_err(|e| format!("Failed to focus YouTube login window: {e}"))?; return Ok(()); } let url = WebviewUrl::External( Url::parse("https://accounts.google.com/ServiceLogin?service=youtube&continue=https://www.youtube.com/") .map_err(|e| format!("Invalid URL: {e}"))? ); let _yt_window = WebviewWindowBuilder::new(app, "youtube-login", url) .title("Sign in to YouTube") .inner_size(500.0, 700.0) .resizable(true) .build() .map_err(|e| format!("Failed to create YouTube login window: {e}"))?; #[cfg(not(target_os = "macos"))] let _ = _yt_window.remove_menu(); Ok(()) } #[tauri::command] async fn open_youtube_login(webview: Webview, app: AppHandle) -> Result<(), String> { require_trusted_window(webview.label())?; open_youtube_login_window(&app) } fn build_app_menu(handle: &AppHandle) -> tauri::Result> { let settings_item = MenuItem::with_id( handle, MENU_FILE_SETTINGS_ID, "Settings...", true, Some("CmdOrCtrl+,"), )?; let separator = PredefinedMenuItem::separator(handle)?; let quit_item = PredefinedMenuItem::quit(handle, Some("Quit"))?; let file_menu = Submenu::with_items( handle, "File", true, &[&settings_item, &separator, &quit_item], )?; let about_metadata = AboutMetadata { name: Some("World Monitor".into()), version: Some(env!("CARGO_PKG_VERSION").into()), copyright: Some("\u{00a9} 2025 Elie Habib".into()), website: Some("https://worldmonitor.app".into()), website_label: Some("worldmonitor.app".into()), ..Default::default() }; let about_item = PredefinedMenuItem::about(handle, Some("About World Monitor"), Some(about_metadata))?; let github_item = MenuItem::with_id( handle, MENU_HELP_GITHUB_ID, "GitHub Repository", true, None::<&str>, )?; let help_separator = PredefinedMenuItem::separator(handle)?; #[cfg(feature = "devtools")] let help_menu = { let devtools_item = MenuItem::with_id( handle, MENU_HELP_DEVTOOLS_ID, "Toggle Developer Tools", true, Some("CmdOrCtrl+Alt+I"), )?; Submenu::with_items( handle, "Help", true, &[&about_item, &help_separator, &github_item, &devtools_item], )? }; #[cfg(not(feature = "devtools"))] let help_menu = Submenu::with_items( handle, "Help", true, &[&about_item, &help_separator, &github_item], )?; let edit_menu = { let undo = PredefinedMenuItem::undo(handle, None)?; let redo = PredefinedMenuItem::redo(handle, None)?; let sep1 = PredefinedMenuItem::separator(handle)?; let cut = PredefinedMenuItem::cut(handle, None)?; let copy = PredefinedMenuItem::copy(handle, None)?; let paste = PredefinedMenuItem::paste(handle, None)?; let select_all = PredefinedMenuItem::select_all(handle, None)?; Submenu::with_items( handle, "Edit", true, &[&undo, &redo, &sep1, &cut, ©, &paste, &select_all], )? }; Menu::with_items(handle, &[&file_menu, &edit_menu, &help_menu]) } fn handle_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { match event.id().as_ref() { MENU_FILE_SETTINGS_ID => { if let Err(err) = open_settings_window(app) { append_desktop_log(app, "ERROR", &format!("settings menu failed: {err}")); eprintln!("[tauri] settings menu failed: {err}"); } } MENU_HELP_GITHUB_ID => { let _ = open_in_shell("https://github.com/koala73/worldmonitor"); } #[cfg(feature = "devtools")] MENU_HELP_DEVTOOLS_ID => { if let Some(window) = app.get_webview_window("main") { if window.is_devtools_open() { window.close_devtools(); } else { window.open_devtools(); } } } _ => {} } } /// Strip Windows extended-length path prefixes that `canonicalize()` adds. /// Preserve UNC semantics: `\\?\UNC\server\share\...` must become /// `\\server\share\...` (not `UNC\server\share\...`). fn sanitize_path_for_node(p: &Path) -> String { let s = p.to_string_lossy(); if let Some(stripped_unc) = s.strip_prefix("\\\\?\\UNC\\") { format!("\\\\{stripped_unc}") } else if let Some(stripped) = s.strip_prefix("\\\\?\\") { stripped.to_string() } else { s.into_owned() } } fn build_time_sidecar_env_value(key: &str) -> Option<&'static str> { match key { "CONVEX_URL" => option_env!("CONVEX_URL"), DESKTOP_SHARED_SECRET_KEY => option_env!("WM_DESKTOP_SHARED_SECRET"), _ => None, } .filter(|value| !value.trim().is_empty()) } fn sidecar_env_value(key: &str) -> Option { build_time_sidecar_env_value(key) .map(ToString::to_string) .or_else(|| std::env::var(key).ok().filter(|value| !value.trim().is_empty())) } #[cfg(test)] mod sanitize_path_tests { use super::{ build_time_sidecar_env_value, can_manage_renderer_secrets, configured_renderer_secret_keys, is_renderer_managed_secret_key, local_api_proxy_path_is_allowed, sanitize_path_for_node, BUILD_TIME_SIDECAR_ENV_KEYS, DESKTOP_SHARED_SECRET_KEY, SUPPORTED_SECRET_KEYS, }; use std::collections::HashMap; use std::path::Path; #[test] fn strips_extended_drive_prefix() { let raw = Path::new(r"\\?\C:\Program Files\nodejs\node.exe"); assert_eq!( sanitize_path_for_node(raw), r"C:\Program Files\nodejs\node.exe".to_string() ); } #[test] fn strips_extended_unc_prefix_and_preserves_unc_root() { let raw = Path::new(r"\\?\UNC\server\share\sidecar\local-api-server.mjs"); assert_eq!( sanitize_path_for_node(raw), r"\\server\share\sidecar\local-api-server.mjs".to_string() ); } #[test] fn leaves_standard_paths_unchanged() { let raw = Path::new(r"C:\Users\alice\sidecar\local-api-server.mjs"); assert_eq!( sanitize_path_for_node(raw), r"C:\Users\alice\sidecar\local-api-server.mjs".to_string() ); } #[test] fn supports_desktop_shared_secret_for_keychain_injection() { assert!(SUPPORTED_SECRET_KEYS.contains(&DESKTOP_SHARED_SECRET_KEY)); } #[test] fn supports_desktop_shared_secret_for_packaged_sidecar_env() { assert!(BUILD_TIME_SIDECAR_ENV_KEYS.contains(&DESKTOP_SHARED_SECRET_KEY)); } #[test] fn renderer_secret_commands_cannot_manage_desktop_shared_secret() { assert!(!is_renderer_managed_secret_key(DESKTOP_SHARED_SECRET_KEY)); } #[test] fn only_main_and_settings_can_manage_renderer_secrets() { assert!(can_manage_renderer_secrets("main")); assert!(can_manage_renderer_secrets("settings")); assert!(!can_manage_renderer_secrets("live-channels")); assert!(!can_manage_renderer_secrets("youtube-login")); } #[test] fn configured_secret_metadata_filters_internal_values_and_keys() { let secrets = HashMap::from([ ("GROQ_API_KEY".to_string(), "secret-value".to_string()), (DESKTOP_SHARED_SECRET_KEY.to_string(), "internal-value".to_string()), ]); assert_eq!(configured_renderer_secret_keys(&secrets), vec!["GROQ_API_KEY"]); } #[test] fn ignores_unknown_build_time_sidecar_env_keys() { assert_eq!(build_time_sidecar_env_value("NOT_A_SUPPORTED_SIDECAR_KEY"), None); } #[test] fn local_api_proxy_allows_normal_api_routes_and_settings_diagnostics() { assert!(local_api_proxy_path_is_allowed("/api/fred-data?series_id=CPI")); assert!(local_api_proxy_path_is_allowed("/api/local-debug-toggle")); assert!(local_api_proxy_path_is_allowed("/api/local-traffic-log")); } #[test] fn local_api_proxy_rejects_secret_control_routes_and_non_api_paths() { for path in [ "/api/local-env-update", "/api/local-env-update-batch", "/api/local-validate-secret", "/api/../api/local-env-update", "/api/%2e%2e/api/local-env-update", "/api/local-unexpected", "/settings", "//api/fred-data", ] { assert!(!local_api_proxy_path_is_allowed(path), "{path} must be rejected"); } } } fn local_api_paths(app: &AppHandle) -> (PathBuf, PathBuf) { let resource_dir = app .path() .resource_dir() .unwrap_or_else(|_| PathBuf::from(".")); let sidecar_script = if cfg!(debug_assertions) { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("sidecar/local-api-server.mjs") } else { resource_dir.join("sidecar/local-api-server.mjs") }; let api_dir_root = if cfg!(debug_assertions) { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } else { let direct_api = resource_dir.join("api"); let lifted_root = resource_dir.join("_up_"); let lifted_api = lifted_root.join("api"); if direct_api.exists() { resource_dir } else if lifted_api.exists() { lifted_root } else { resource_dir } }; (sidecar_script, api_dir_root) } fn resolve_node_binary(app: &AppHandle) -> Option { if let Ok(explicit) = env::var("LOCAL_API_NODE_BIN") { let explicit_path = PathBuf::from(explicit); if explicit_path.is_file() { return Some(explicit_path); } append_desktop_log( app, "WARN", &format!( "LOCAL_API_NODE_BIN is set but not a valid file: {}", explicit_path.display() ), ); } if !cfg!(debug_assertions) { let node_name = if cfg!(windows) { "node.exe" } else { "node" }; if let Ok(resource_dir) = app.path().resource_dir() { let mut candidates = vec![resource_dir.join("sidecar").join("node").join(node_name)]; if cfg!(windows) { // NSIS resource paths can flatten nested names in some upgrade scenarios. // Keep this fallback so sidecar startup still succeeds if the runtime is // materialized as sidecar\node.node.exe instead of sidecar\node\node.exe. candidates.push(resource_dir.join("sidecar").join("node.node.exe")); } for bundled in candidates { if bundled.is_file() { return Some(bundled); } } } } let node_name = if cfg!(windows) { "node.exe" } else { "node" }; if let Some(path_var) = env::var_os("PATH") { for dir in env::split_paths(&path_var) { let candidate = dir.join(node_name); if candidate.is_file() { return Some(candidate); } } } let common_locations = if cfg!(windows) { vec![ PathBuf::from(r"C:\Program Files\nodejs\node.exe"), PathBuf::from(r"C:\Program Files (x86)\nodejs\node.exe"), ] } else { vec![ PathBuf::from("/opt/homebrew/bin/node"), PathBuf::from("/usr/local/bin/node"), PathBuf::from("/usr/bin/node"), PathBuf::from("/opt/local/bin/node"), ] }; common_locations.into_iter().find(|path| path.is_file()) } fn read_port_file(path: &Path, timeout_ms: u64) -> Option { let start = std::time::Instant::now(); let interval = std::time::Duration::from_millis(100); let timeout = std::time::Duration::from_millis(timeout_ms); while start.elapsed() < timeout { if let Ok(contents) = fs::read_to_string(path) { if let Ok(port) = contents.trim().parse::() { if port > 0 { return Some(port); } } } std::thread::sleep(interval); } None } fn start_local_api(app: &AppHandle) -> Result<(), String> { let state = app.state::(); let mut slot = state .child .lock() .map_err(|_| "Failed to lock local API state".to_string())?; if slot.is_some() { return Ok(()); } // Clear port state for fresh start if let Ok(mut port_slot) = state.port.lock() { *port_slot = None; } let (script, resource_root) = local_api_paths(app); if !script.exists() { return Err(format!( "Local API sidecar script missing at {}", script.display() )); } let node_binary = resolve_node_binary(app).ok_or_else(|| { "Node.js executable not found. Install Node 18+ or set LOCAL_API_NODE_BIN".to_string() })?; let port_file = logs_dir_path(app)?.join("sidecar.port"); let _ = fs::remove_file(&port_file); let log_path = sidecar_log_path(app)?; let log_file = OpenOptions::new() .create(true) .append(true) .open(&log_path) .map_err(|e| format!("Failed to open local API log {}: {e}", log_path.display()))?; let log_file_err = log_file .try_clone() .map_err(|e| format!("Failed to clone local API log handle: {e}"))?; append_desktop_log( app, "INFO", &format!( "starting local API sidecar script={} resource_root={} log={}", script.display(), resource_root.display(), log_path.display() ), ); append_desktop_log( app, "INFO", &format!("resolved node binary={}", node_binary.display()), ); append_desktop_log( app, "INFO", &format!( "local API sidecar preferred port={} port_file={}", DEFAULT_LOCAL_API_PORT, port_file.display() ), ); // Generate a unique token for local API auth (prevents other local processes from accessing sidecar) let mut token_slot = state .token .lock() .map_err(|_| "Failed to lock token slot")?; if token_slot.is_none() { *token_slot = Some(generate_local_token()); } let local_api_token = token_slot.clone().unwrap(); drop(token_slot); let mut cmd = Command::new(&node_binary); #[cfg(windows)] cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW — hide the node.exe console // Sanitize paths for Node.js on Windows: strip \\?\ UNC prefix and set // explicit working directory to avoid bare drive-letter CWD issues that // cause EISDIR errors in Node.js module resolution. let script_for_node = sanitize_path_for_node(&script); let resource_for_node = sanitize_path_for_node(&resource_root); append_desktop_log( app, "INFO", &format!("node args: script={script_for_node} resource_dir={resource_for_node}"), ); let data_dir = logs_dir_path(app) .map(|p| sanitize_path_for_node(&p)) .unwrap_or_else(|_| resource_for_node.clone()); cmd.arg(&script_for_node) .env("LOCAL_API_PORT", DEFAULT_LOCAL_API_PORT.to_string()) .env("LOCAL_API_PORT_FILE", &port_file) .env("LOCAL_API_RESOURCE_DIR", &resource_for_node) .env("LOCAL_API_DATA_DIR", &data_dir) .env("LOCAL_API_MODE", "tauri-sidecar") .env("LOCAL_API_CLOUD_FALLBACK", "true") .env("LOCAL_API_TOKEN", &local_api_token) .stdout(Stdio::from(log_file)) .stderr(Stdio::from(log_file_err)); if let Some(parent) = script.parent() { cmd.current_dir(parent); } // Pass cached keychain secrets to sidecar as env vars (no keychain re-read) let mut secret_count = 0u32; let secrets_cache = app.state::(); if let Ok(secrets) = secrets_cache.secrets.lock() { for (key, value) in secrets.iter() { cmd.env(key, value); secret_count += 1; } } append_desktop_log( app, "INFO", &format!("injected {secret_count} keychain secrets into sidecar env"), ); // Inject packaged secrets (CI) with runtime env fallback (dev). for key in BUILD_TIME_SIDECAR_ENV_KEYS { if let Some(value) = sidecar_env_value(key) { cmd.env(key, value); } } let child = cmd .spawn() .map_err(|e| format!("Failed to launch local API: {e}"))?; append_desktop_log( app, "INFO", &format!("local API sidecar started pid={}", child.id()), ); *slot = Some(child); drop(slot); // Wait for sidecar to write confirmed port (up to 5s) if let Some(confirmed_port) = read_port_file(&port_file, 5000) { append_desktop_log( app, "INFO", &format!("sidecar confirmed port={confirmed_port}"), ); if let Ok(mut port_slot) = state.port.lock() { *port_slot = Some(confirmed_port); } } else { append_desktop_log( app, "WARN", "sidecar port file not found within timeout, using default", ); if let Ok(mut port_slot) = state.port.lock() { *port_slot = Some(DEFAULT_LOCAL_API_PORT); } } Ok(()) } fn stop_local_api(app: &AppHandle) { if let Ok(state) = app.try_state::().ok_or(()) { if let Ok(mut slot) = state.child.lock() { if let Some(mut child) = slot.take() { let _ = child.kill(); append_desktop_log(app, "INFO", "local API sidecar stopped"); } } if let Ok(mut port_slot) = state.port.lock() { *port_slot = None; } if let Ok(log_dir) = logs_dir_path(app) { let _ = fs::remove_file(log_dir.join("sidecar.port")); } } } #[cfg(target_os = "linux")] fn resolve_appimage_gio_module_dir() -> Option { let appdir = env::var_os("APPDIR")?; let appdir = PathBuf::from(appdir); // Common layouts produced by AppImage/linuxdeploy on Debian and RPM families. let preferred = [ "usr/lib/gio/modules", "usr/lib64/gio/modules", "usr/lib/x86_64-linux-gnu/gio/modules", "usr/lib/aarch64-linux-gnu/gio/modules", "usr/lib/arm-linux-gnueabihf/gio/modules", "lib/gio/modules", "lib64/gio/modules", ]; for relative in preferred { let candidate = appdir.join(relative); if candidate.is_dir() { return Some(candidate); } } // Fallback: probe one level of arch-specific directories, e.g. usr/lib//gio/modules. for lib_root in ["usr/lib", "usr/lib64", "lib", "lib64"] { let root = appdir.join(lib_root); if !root.is_dir() { continue; } let entries = match fs::read_dir(&root) { Ok(entries) => entries, Err(_) => continue, }; for entry in entries.flatten() { let candidate = entry.path().join("gio/modules"); if candidate.is_dir() { return Some(candidate); } } } None } fn main() { // Work around WebKitGTK rendering issues on Linux that can cause blank white // screens. DMA-BUF renderer failures are common with NVIDIA drivers and on // immutable distros (e.g. Bazzite/Fedora Atomic). Setting the env var before // WebKit initialises forces a software fallback path. Only set when the user // hasn't explicitly configured the variable. #[cfg(target_os = "linux")] { if env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { // SAFETY: called before any threads are spawned (Tauri hasn't started yet). unsafe { env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1") }; } // WebKitGTK promotes iframes,