| #![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<Option<Child>>, |
| token: Mutex<Option<String>>, |
| port: Mutex<Option<u16>>, |
| 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(), |
| } |
| } |
| } |
|
|
| |
| |
| struct SecretsCache { |
| secrets: Mutex<HashMap<String, String>>, |
| } |
|
|
| |
| |
| |
| struct PersistentCache { |
| data: Mutex<Map<String, Value>>, |
| dirty: Mutex<bool>, |
| write_lock: Mutex<()>, |
| generation: Mutex<u64>, |
| flush_scheduled: Mutex<bool>, |
| } |
|
|
| impl SecretsCache { |
| fn load_from_keychain() -> Self { |
| |
| 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::<HashMap<String, String>>(&json) { |
| let secrets: HashMap<String, String> = 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), |
| }; |
| } |
| } |
| } |
|
|
| |
| |
| 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); |
| } |
| } |
| } |
| } |
|
|
| |
| 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::<Value>(&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<Value> { |
| let data = self.data.lock().unwrap_or_else(|e| e.into_inner()); |
| data.get(key).cloned() |
| } |
|
|
| |
| |
| fn flush(&self, path: &Path) -> Result<bool, String> { |
| 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<u16>, |
| } |
|
|
| #[derive(Deserialize)] |
| struct LocalApiProxyRequest { |
| method: String, |
| path: String, |
| headers: HashMap<String, String>, |
| body: Option<Vec<u8>>, |
| } |
|
|
| #[derive(Serialize)] |
| struct LocalApiProxyResponse { |
| status: u16, |
| headers: HashMap<String, String>, |
| body: Vec<u8>, |
| } |
|
|
| #[derive(Serialize)] |
| struct SecretValidationResponse { |
| status: u16, |
| payload: Value, |
| } |
|
|
| fn save_vault(cache: &HashMap<String, String>) -> 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) |
| } |
|
|
| |
| |
| 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<String, String>) -> Vec<String> { |
| secrets |
| .keys() |
| .filter(|key| is_renderer_managed_secret_key(key)) |
| .cloned() |
| .collect() |
| } |
|
|
| fn normalized_local_api_proxy_path(path: &str) -> Result<String, String> { |
| 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")) |
| |
| |
| && 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<String, String>, |
| body: Option<Vec<u8>>, |
| ) -> Result<LocalApiProxyResponse, String> { |
| 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<LocalApiProxyResponse, String> { |
| 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<DesktopRuntimeInfo, String> { |
| 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<u16, String> { |
| 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<Vec<String>, 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())?; |
| |
| 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<String, String>, |
| state: tauri::State<'_, LocalApiState>, |
| ) -> Result<SecretValidationResponse, String> { |
| 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<PathBuf, String> { |
| 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<Option<Value>, 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::<PersistentCache>() 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<PathBuf, String> { |
| 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<PathBuf, String> { |
| Ok(logs_dir_path(app)?.join(LOCAL_API_LOG_FILE)) |
| } |
|
|
| fn desktop_log_path(app: &AppHandle) -> Result<PathBuf, String> { |
| 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> { |
| |
| |
| #[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)) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| #[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<PathBuf, String> { |
| let dir = logs_dir_path(app)?; |
| open_path_in_shell(&dir)?; |
| Ok(dir) |
| } |
|
|
| fn open_sidecar_log_impl(app: &AppHandle) -> Result<PathBuf, String> { |
| 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<String, String> { |
| 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<String, String> { |
| 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<String>, |
| ) -> 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(()) |
| } |
|
|
| |
| |
| #[tauri::command] |
| async fn fetch_polymarket(webview: Webview, state: tauri::State<'_, LocalApiState>, path: String, params: String) -> Result<String, String> { |
| 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}"))?; |
|
|
| |
| |
| #[cfg(not(target_os = "macos"))] |
| let _ = _settings_window.remove_menu(); |
|
|
| Ok(()) |
| } |
|
|
| fn open_live_channels_window(app: &AppHandle, base_url: Option<String>) -> 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(()); |
| } |
|
|
| |
| |
| 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<Menu<tauri::Wry>> { |
| 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(); |
| } |
| } |
| } |
| _ => {} |
| } |
| } |
|
|
| |
| |
| |
| 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<String> { |
| 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<PathBuf> { |
| 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) { |
| |
| |
| |
| 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<u16> { |
| 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::<u16>() { |
| if port > 0 { |
| return Some(port); |
| } |
| } |
| } |
| std::thread::sleep(interval); |
| } |
| None |
| } |
|
|
| fn start_local_api(app: &AppHandle) -> Result<(), String> { |
| let state = app.state::<LocalApiState>(); |
| let mut slot = state |
| .child |
| .lock() |
| .map_err(|_| "Failed to lock local API state".to_string())?; |
| if slot.is_some() { |
| return Ok(()); |
| } |
|
|
| |
| 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() |
| ), |
| ); |
|
|
| |
| 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); |
| |
| |
| |
| 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); |
| } |
|
|
| |
| let mut secret_count = 0u32; |
| let secrets_cache = app.state::<SecretsCache>(); |
| 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"), |
| ); |
|
|
| |
| 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); |
|
|
| |
| 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::<LocalApiState>().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<PathBuf> { |
| let appdir = env::var_os("APPDIR")?; |
| let appdir = PathBuf::from(appdir); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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() { |
| |
| |
| |
| |
| |
| #[cfg(target_os = "linux")] |
| { |
| if env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { |
| |
| unsafe { env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1") }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| let in_vm = std::fs::read_to_string("/proc/cpuinfo") |
| .map(|c| c.contains("hypervisor")) |
| .unwrap_or(false) |
| || std::fs::read_to_string("/sys/class/dmi/id/sys_vendor") |
| .map(|v| { |
| let v = v.trim().to_lowercase(); |
| v.contains("qemu") || v.contains("vmware") || v.contains("virtualbox") |
| || v.contains("apple") || v.contains("parallels") || v.contains("xen") |
| || v.contains("microsoft") || v.contains("innotek") |
| }) |
| .unwrap_or(false); |
|
|
| if in_vm { |
| if env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { |
| unsafe { env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1") }; |
| } |
| if env::var_os("LIBGL_ALWAYS_SOFTWARE").is_none() { |
| unsafe { env::set_var("LIBGL_ALWAYS_SOFTWARE", "1") }; |
| } |
| eprintln!("[tauri] VM detected; disabled WebKitGTK accelerated compositing for iframe/video compatibility"); |
| } |
|
|
| |
| |
| |
| |
| |
| let has_nvidia = std::path::Path::new("/proc/driver/nvidia").exists(); |
| if has_nvidia { |
| if env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() { |
| unsafe { env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1") }; |
| } |
| |
| |
| |
| if env::var_os("WAYLAND_DISPLAY").is_some() && env::var_os("GDK_BACKEND").is_none() { |
| unsafe { env::set_var("GDK_BACKEND", "x11") }; |
| eprintln!( |
| "[tauri] NVIDIA GPU + Wayland detected; forcing GDK_BACKEND=x11 to avoid EGL_BAD_ALLOC. \ |
| Set GDK_BACKEND=wayland to override." |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| if env::var_os("WAYLAND_DISPLAY").is_some() && env::var_os("GDK_BACKEND").is_none() { |
| unsafe { env::set_var("GDK_BACKEND", "wayland,x11") }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if env::var_os("APPIMAGE").is_some() && env::var_os("GIO_MODULE_DIR").is_none() { |
| if let Some(module_dir) = resolve_appimage_gio_module_dir() { |
| unsafe { env::set_var("GIO_MODULE_DIR", &module_dir) }; |
| } else if env::var_os("GIO_USE_VFS").is_none() { |
| |
| |
| unsafe { env::set_var("GIO_USE_VFS", "local") }; |
| eprintln!( |
| "[tauri] APPIMAGE detected but bundled gio/modules not found; using GIO_USE_VFS=local fallback" |
| ); |
| } |
| } |
|
|
| |
| |
| |
| if env::var_os("APPIMAGE").is_some() { |
| |
| |
| |
| |
| if env::var_os("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS").is_none() { |
| unsafe { env::set_var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") }; |
| } |
| |
| |
| if env::var_os("GTK_IM_MODULE").is_none() { |
| unsafe { env::set_var("GTK_IM_MODULE", "gtk-im-context-simple") }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if env::var_os("GST_PLUGIN_SYSTEM_PATH_1_0").is_none() { |
| |
| unsafe { env::set_var("GST_PLUGIN_SYSTEM_PATH_1_0", "") }; |
| } |
| } |
| } |
|
|
| tauri::Builder::default() |
| .menu(build_app_menu) |
| .on_menu_event(handle_menu_event) |
| .manage(LocalApiState::default()) |
| .manage(SecretsCache::load_from_keychain()) |
| .invoke_handler(tauri::generate_handler![ |
| list_configured_secret_keys, |
| set_secret, |
| delete_secret, |
| validate_secret_with_sidecar, |
| proxy_local_api_request, |
| get_local_api_port, |
| get_desktop_runtime_info, |
| read_cache_entry, |
| write_cache_entry, |
| delete_cache_entry, |
| delete_cache_entries_by_prefix, |
| open_logs_folder, |
| open_sidecar_log_file, |
| open_settings_window_command, |
| close_settings_window, |
| open_live_channels_window_command, |
| close_live_channels_window, |
| open_url, |
| open_youtube_login, |
| fetch_polymarket |
| ]) |
| .setup(|app| { |
| |
| let cache_path = cache_file_path(&app.handle()).unwrap_or_default(); |
| app.manage(PersistentCache::load(&cache_path)); |
|
|
| if let Err(err) = start_local_api(&app.handle()) { |
| append_desktop_log( |
| &app.handle(), |
| "ERROR", |
| &format!("local API sidecar failed to start: {err}"), |
| ); |
| eprintln!("[tauri] local API sidecar failed to start: {err}"); |
| } |
|
|
| Ok(()) |
| }) |
| .build(tauri::generate_context!()) |
| .expect("error while running world-monitor tauri application") |
| .run(|app, event| { |
| match &event { |
| |
| #[cfg(target_os = "macos")] |
| RunEvent::WindowEvent { |
| label, |
| event: WindowEvent::CloseRequested { api, .. }, |
| .. |
| } if label == "main" => { |
| api.prevent_close(); |
| if let Some(w) = app.get_webview_window("main") { |
| let _ = w.hide(); |
| } |
| } |
| |
| #[cfg(target_os = "macos")] |
| RunEvent::Reopen { .. } => { |
| if let Some(w) = app.get_webview_window("main") { |
| let _ = w.show(); |
| let _ = w.set_focus(); |
| } |
| } |
| |
| |
| |
| #[cfg(target_os = "macos")] |
| RunEvent::WindowEvent { |
| label, |
| event: WindowEvent::Focused(true), |
| .. |
| } if label == "main" => { |
| if let Some(sw) = app.get_webview_window("settings") { |
| let _ = sw.show(); |
| let _ = sw.set_focus(); |
| } |
| } |
| RunEvent::ExitRequested { .. } | RunEvent::Exit => { |
| |
| if let Ok(path) = cache_file_path(app) { |
| if let Some(cache) = app.try_state::<PersistentCache>() { |
| let _ = cache.flush(&path); |
| } |
| } |
| stop_local_api(app); |
| } |
| _ => {} |
| } |
| }); |
| } |
|
|