#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use std::{ net::TcpListener, process::Command, sync::Mutex, time::Duration, }; use std::{fs, path::PathBuf}; use std::env; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; fn eval_in_main_window(app: &tauri::AppHandle, script: &str) { let Some(window) = app.get_webview_window("main") else { return; }; let _ = window.eval(script); } fn dispatch_menu_action(app: &tauri::AppHandle, action: &str) { let _ = app.emit("openchamber:menu-action", action); let event = serde_json::to_string("openchamber:menu-action") .unwrap_or_else(|_| "\"openchamber:menu-action\"".into()); let detail = serde_json::to_string(action).unwrap_or_else(|_| "\"\"".into()); let script = format!("window.dispatchEvent(new CustomEvent({event}, {{ detail: {detail} }}));"); eval_in_main_window(app, &script); } fn dispatch_check_for_updates(app: &tauri::AppHandle) { let _ = app.emit("openchamber:check-for-updates", ()); let event = serde_json::to_string("openchamber:check-for-updates") .unwrap_or_else(|_| "\"openchamber:check-for-updates\"".into()); let script = format!("window.dispatchEvent(new Event({event}));"); eval_in_main_window(app, &script); } use tauri_plugin_shell::{process::CommandChild, process::CommandEvent, ShellExt}; use tauri_plugin_updater::UpdaterExt; #[cfg(target_os = "macos")] const MENU_ITEM_ABOUT_ID: &str = "menu_about"; #[cfg(target_os = "macos")] const MENU_ITEM_CHECK_FOR_UPDATES_ID: &str = "menu_check_for_updates"; #[cfg(target_os = "macos")] const MENU_ITEM_SETTINGS_ID: &str = "menu_settings"; #[cfg(target_os = "macos")] const MENU_ITEM_COMMAND_PALETTE_ID: &str = "menu_command_palette"; #[cfg(target_os = "macos")] const MENU_ITEM_NEW_SESSION_ID: &str = "menu_new_session"; #[cfg(target_os = "macos")] const MENU_ITEM_WORKTREE_CREATOR_ID: &str = "menu_worktree_creator"; #[cfg(target_os = "macos")] const MENU_ITEM_CHANGE_WORKSPACE_ID: &str = "menu_change_workspace"; #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_GIT_TAB_ID: &str = "menu_open_git_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_DIFF_TAB_ID: &str = "menu_open_diff_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_FILES_TAB_ID: &str = "menu_open_files_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "menu_open_terminal_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_THEME_LIGHT_ID: &str = "menu_theme_light"; #[cfg(target_os = "macos")] const MENU_ITEM_THEME_DARK_ID: &str = "menu_theme_dark"; #[cfg(target_os = "macos")] const MENU_ITEM_THEME_SYSTEM_ID: &str = "menu_theme_system"; #[cfg(target_os = "macos")] const MENU_ITEM_TOGGLE_SIDEBAR_ID: &str = "menu_toggle_sidebar"; #[cfg(target_os = "macos")] const MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID: &str = "menu_toggle_memory_debug"; #[cfg(target_os = "macos")] const MENU_ITEM_HELP_DIALOG_ID: &str = "menu_help_dialog"; #[cfg(target_os = "macos")] const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "menu_download_logs"; #[cfg(target_os = "macos")] const MENU_ITEM_REPORT_BUG_ID: &str = "menu_report_bug"; #[cfg(target_os = "macos")] const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature"; #[cfg(target_os = "macos")] const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord"; #[cfg(target_os = "macos")] const GITHUB_BUG_REPORT_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml"; #[cfg(target_os = "macos")] const GITHUB_FEATURE_REQUEST_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml"; #[cfg(target_os = "macos")] const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA"; #[cfg(target_os = "macos")] fn build_macos_menu( app: &tauri::AppHandle, ) -> tauri::Result> { use tauri::menu::{ Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, }; let pkg_info = app.package_info(); let auto_worktree = app .try_state::() .map(|state| *state.auto_worktree.lock().expect("menu state mutex")) .unwrap_or(false); let new_session_shortcut = if auto_worktree { "Cmd+Shift+N" } else { "Cmd+N" }; let new_worktree_shortcut = if auto_worktree { "Cmd+N" } else { "Cmd+Shift+N" }; let about = MenuItem::with_id( app, MENU_ITEM_ABOUT_ID, format!("About {}", pkg_info.name), true, None::<&str>, )?; let check_for_updates = MenuItem::with_id( app, MENU_ITEM_CHECK_FOR_UPDATES_ID, "Check for Updates", true, None::<&str>, )?; let settings = MenuItem::with_id(app, MENU_ITEM_SETTINGS_ID, "Settings", true, Some("Cmd+,"))?; let command_palette = MenuItem::with_id( app, MENU_ITEM_COMMAND_PALETTE_ID, "Command Palette", true, Some("Cmd+K"), )?; let new_session = MenuItem::with_id( app, MENU_ITEM_NEW_SESSION_ID, "New Session", true, Some(new_session_shortcut), )?; let worktree_creator = MenuItem::with_id( app, MENU_ITEM_WORKTREE_CREATOR_ID, "New Worktree", true, Some(new_worktree_shortcut), )?; let change_workspace = MenuItem::with_id( app, MENU_ITEM_CHANGE_WORKSPACE_ID, "Add Workspace", true, None::<&str>, )?; let open_git_tab = MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?; let open_diff_tab = MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?; let open_files_tab = MenuItem::with_id(app, MENU_ITEM_OPEN_FILES_TAB_ID, "Files", true, None::<&str>)?; let open_terminal_tab = MenuItem::with_id( app, MENU_ITEM_OPEN_TERMINAL_TAB_ID, "Terminal", true, Some("Cmd+T"), )?; let theme_light = MenuItem::with_id(app, MENU_ITEM_THEME_LIGHT_ID, "Light Theme", true, None::<&str>)?; let theme_dark = MenuItem::with_id(app, MENU_ITEM_THEME_DARK_ID, "Dark Theme", true, None::<&str>)?; let theme_system = MenuItem::with_id(app, MENU_ITEM_THEME_SYSTEM_ID, "System Theme", true, None::<&str>)?; let toggle_sidebar = MenuItem::with_id( app, MENU_ITEM_TOGGLE_SIDEBAR_ID, "Toggle Session Sidebar", true, Some("Cmd+L"), )?; let toggle_memory_debug = MenuItem::with_id( app, MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID, "Toggle Memory Debug", true, Some("Cmd+Shift+D"), )?; let help_dialog = MenuItem::with_id( app, MENU_ITEM_HELP_DIALOG_ID, "Keyboard Shortcuts", true, Some("Cmd+."), )?; let download_logs = MenuItem::with_id( app, MENU_ITEM_DOWNLOAD_LOGS_ID, "Show Diagnostics", true, Some("Cmd+Shift+L"), )?; let report_bug = MenuItem::with_id(app, MENU_ITEM_REPORT_BUG_ID, "Report a Bug", true, None::<&str>)?; let request_feature = MenuItem::with_id( app, MENU_ITEM_REQUEST_FEATURE_ID, "Request a Feature", true, None::<&str>, )?; let join_discord = MenuItem::with_id(app, MENU_ITEM_JOIN_DISCORD_ID, "Join Discord", true, None::<&str>)?; let theme_submenu = Submenu::with_items(app, "Theme", true, &[&theme_light, &theme_dark, &theme_system])?; let window_menu = Submenu::with_id_and_items( app, WINDOW_SUBMENU_ID, "Window", true, &[ &PredefinedMenuItem::minimize(app, None)?, &PredefinedMenuItem::maximize(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::close_window(app, None)?, ], )?; let help_menu = Submenu::with_id_and_items( app, HELP_SUBMENU_ID, "Help", true, &[ &help_dialog, &download_logs, &PredefinedMenuItem::separator(app)?, &report_bug, &request_feature, &PredefinedMenuItem::separator(app)?, &join_discord, ], )?; Menu::with_items( app, &[ &Submenu::with_items( app, pkg_info.name.clone(), true, &[ &about, &check_for_updates, &PredefinedMenuItem::separator(app)?, &settings, &command_palette, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::services(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::hide(app, None)?, &PredefinedMenuItem::hide_others(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::quit(app, None)?, ], )?, &Submenu::with_items( app, "File", true, &[ &new_session, &worktree_creator, &PredefinedMenuItem::separator(app)?, &change_workspace, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::close_window(app, None)?, ], )?, &Submenu::with_items( app, "Edit", true, &[ &PredefinedMenuItem::undo(app, None)?, &PredefinedMenuItem::redo(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::cut(app, None)?, &PredefinedMenuItem::copy(app, None)?, &PredefinedMenuItem::paste(app, None)?, &PredefinedMenuItem::select_all(app, None)?, ], )?, &Submenu::with_items( app, "View", true, &[ &open_git_tab, &open_diff_tab, &open_files_tab, &open_terminal_tab, &PredefinedMenuItem::separator(app)?, &theme_submenu, &PredefinedMenuItem::separator(app)?, &toggle_sidebar, &toggle_memory_debug, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::fullscreen(app, None)?, ], )?, &window_menu, &help_menu, ], ) } #[tauri::command] fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Result<(), String> { let Some(state) = app.try_state::() else { return Ok(()); }; { let mut guard = state.auto_worktree.lock().expect("menu state mutex"); *guard = enabled; } #[cfg(target_os = "macos")] { use tauri::menu::MenuItemKind; let new_session_shortcut = if enabled { "Cmd+Shift+N" } else { "Cmd+N" }; let new_worktree_shortcut = if enabled { "Cmd+N" } else { "Cmd+Shift+N" }; if let Some(menu) = app.menu() { if let Some(MenuItemKind::MenuItem(item)) = menu.get(MENU_ITEM_NEW_SESSION_ID) { item.set_accelerator(Some(new_session_shortcut)) .map_err(|err| err.to_string())?; } if let Some(MenuItemKind::MenuItem(item)) = menu.get(MENU_ITEM_WORKTREE_CREATOR_ID) { item.set_accelerator(Some(new_worktree_shortcut)) .map_err(|err| err.to_string())?; } } else { // Should not happen on macOS, but keep as fallback. let menu = build_macos_menu(&app).map_err(|err| err.to_string())?; app.set_menu(menu).map_err(|err| err.to_string())?; } } Ok(()) } const SIDECAR_NAME: &str = "openchamber-server"; const SIDECAR_NOTIFY_PREFIX: &str = "[OpenChamberDesktopNotify] "; const HEALTH_TIMEOUT: Duration = Duration::from_secs(20); const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250); const DEFAULT_DESKTOP_PORT: u16 = 57123; const LOCAL_HOST_ID: &str = "local"; #[derive(Default)] struct SidecarState { child: Mutex>, url: Mutex>, } #[derive(Default)] struct DesktopUiInjectionState { script: Mutex>, } struct WindowFocusState { focused: Mutex, } impl Default for WindowFocusState { fn default() -> Self { Self { focused: Mutex::new(true), } } } #[derive(Default)] struct MenuRuntimeState { auto_worktree: Mutex, } #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopHost { id: String, label: String, url: String, } #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopHostsConfig { hosts: Vec, default_host_id: Option, } fn normalize_host_url(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { return None; } let parsed = url::Url::parse(trimmed).ok()?; let scheme = parsed.scheme(); if scheme != "http" && scheme != "https" { return None; } let host = parsed.host_str()?; let mut normalized = format!("{}://{}", scheme, host); if let Some(port) = parsed.port() { normalized.push(':'); normalized.push_str(&port.to_string()); } Some(normalized) } fn settings_file_path() -> PathBuf { if let Ok(dir) = env::var("OPENCHAMBER_DATA_DIR") { if !dir.trim().is_empty() { return PathBuf::from(dir.trim()).join("settings.json"); } } let home = env::var("HOME").unwrap_or_default(); PathBuf::from(home) .join(".config") .join("openchamber") .join("settings.json") } fn read_desktop_local_port_from_disk() -> Option { let path = settings_file_path(); let raw = fs::read_to_string(path).ok(); let parsed = raw .as_deref() .and_then(|s| serde_json::from_str::(s).ok()); parsed .as_ref() .and_then(|v| v.get("desktopLocalPort")) .and_then(|v| v.as_u64()) .and_then(|v| if v > 0 && v <= u16::MAX as u64 { Some(v as u16) } else { None }) } fn write_desktop_local_port_to_disk(port: u16) -> Result<()> { let path = settings_file_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) } else { serde_json::json!({}) }; if !root.is_object() { root = serde_json::json!({}); } root["desktopLocalPort"] = serde_json::Value::Number(serde_json::Number::from(port)); fs::write(&path, serde_json::to_string_pretty(&root)?)?; Ok(()) } fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig { let path = settings_file_path(); let raw = fs::read_to_string(path).ok(); let parsed = raw .as_deref() .and_then(|s| serde_json::from_str::(s).ok()); let hosts_value = parsed .as_ref() .and_then(|v| v.get("desktopHosts")) .cloned() .unwrap_or(serde_json::Value::Null); let default_value = parsed .as_ref() .and_then(|v| v.get("desktopDefaultHostId")) .and_then(|v| v.as_str()) .map(|s| s.to_string()); let mut hosts: Vec = Vec::new(); if let serde_json::Value::Array(items) = hosts_value { for item in items { if let Ok(host) = serde_json::from_value::(item) { if host.id.trim().is_empty() || host.id == LOCAL_HOST_ID { continue; } if let Some(url) = normalize_host_url(&host.url) { hosts.push(DesktopHost { id: host.id, label: if host.label.trim().is_empty() { url.clone() } else { host.label }, url, }); } } } } DesktopHostsConfig { hosts, default_host_id: default_value, } } fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> { let path = settings_file_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) } else { serde_json::json!({}) }; if !root.is_object() { root = serde_json::json!({}); } let hosts: Vec = config .hosts .iter() .filter_map(|h| { let id = h.id.trim(); if id.is_empty() || id == LOCAL_HOST_ID { return None; } let url = normalize_host_url(&h.url)?; Some(DesktopHost { id: id.to_string(), label: if h.label.trim().is_empty() { url.clone() } else { h.label.trim().to_string() }, url, }) }) .collect(); root["desktopHosts"] = serde_json::to_value(hosts).unwrap_or(serde_json::Value::Array(vec![])); root["desktopDefaultHostId"] = match &config.default_host_id { Some(id) if !id.trim().is_empty() => serde_json::Value::String(id.trim().to_string()), _ => serde_json::Value::Null, }; fs::write(&path, serde_json::to_string_pretty(&root)?)?; Ok(()) } #[tauri::command] fn desktop_hosts_get() -> Result { Ok(read_desktop_hosts_config_from_disk()) } #[tauri::command] fn desktop_hosts_set(config: DesktopHostsConfig) -> Result<(), String> { write_desktop_hosts_config_to_disk(&config).map_err(|err| err.to_string()) } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct HostProbeResult { status: String, latency_ms: u64, } #[tauri::command] async fn desktop_host_probe(url: String) -> Result { let normalized = normalize_host_url(&url).ok_or_else(|| "Invalid URL".to_string())?; let health = format!("{}/health", normalized.trim_end_matches('/')); let client = reqwest::Client::builder() .no_proxy() .timeout(Duration::from_secs(2)) .build() .map_err(|err| err.to_string())?; let started = std::time::Instant::now(); match client.get(&health).send().await { Ok(resp) => { let status = resp.status(); let latency_ms = started.elapsed().as_millis() as u64; if status.is_success() { Ok(HostProbeResult { status: "ok".to_string(), latency_ms, }) } else if status.as_u16() == 401 || status.as_u16() == 403 { Ok(HostProbeResult { status: "auth".to_string(), latency_ms, }) } else { Ok(HostProbeResult { status: "unreachable".to_string(), latency_ms, }) } } Err(_) => Ok(HostProbeResult { status: "unreachable".to_string(), latency_ms: started.elapsed().as_millis() as u64, }), } } #[derive(Clone, Serialize)] #[serde(tag = "event", content = "data")] enum UpdateProgressEvent { #[serde(rename_all = "camelCase")] Started { content_length: Option, }, #[serde(rename_all = "camelCase")] Progress { chunk_length: usize, downloaded: u64, total: Option, }, Finished, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct DesktopUpdateInfo { available: bool, current_version: String, version: Option, body: Option, date: Option, } struct PendingUpdate(Mutex>); fn pick_unused_port() -> Result { let listener = TcpListener::bind("127.0.0.1:0")?; let port = listener.local_addr()?.port(); Ok(port) } fn is_nonempty_string(value: &str) -> bool { !value.trim().is_empty() } const CHANGELOG_URL: &str = "https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md"; fn parse_semver_num(value: &str) -> Option { let trimmed = value.trim().trim_start_matches('v'); let mut parts = trimmed.split('.'); let major: u32 = parts.next()?.parse().ok()?; let minor: u32 = parts.next()?.parse().ok()?; let patch: u32 = parts.next()?.parse().ok()?; Some(major.saturating_mul(10_000) + minor.saturating_mul(100) + patch) } fn is_placeholder_release_notes(body: &Option) -> bool { let Some(body) = body.as_ref() else { return true; }; let trimmed = body.trim(); if trimmed.is_empty() { return true; } trimmed .to_ascii_lowercase() .starts_with("see release notes at") } async fn fetch_changelog_notes(from_version: &str, to_version: &str) -> Option { let from_num = parse_semver_num(from_version)?; let to_num = parse_semver_num(to_version)?; if to_num <= from_num { return None; } let client = reqwest::Client::builder() .no_proxy() .timeout(Duration::from_secs(10)) .build() .ok()?; let response = client.get(CHANGELOG_URL).send().await.ok()?; if !response.status().is_success() { return None; } let changelog = response.text().await.ok()?; if changelog.trim().is_empty() { return None; } let mut markers: Vec<(usize, Option)> = Vec::new(); let mut offset: usize = 0; for line in changelog.lines() { let line_trimmed = line.trim_end_matches('\r'); if line_trimmed.starts_with("## [") { let ver = line_trimmed .strip_prefix("## [") .and_then(|rest| rest.split(']').next()) .unwrap_or(""); markers.push((offset, parse_semver_num(ver))); } offset = offset.saturating_add(line.len().saturating_add(1)); } if markers.is_empty() { return None; } let mut relevant: Vec = Vec::new(); for idx in 0..markers.len() { let (start, ver_num) = markers[idx]; let end = markers.get(idx + 1).map(|m| m.0).unwrap_or_else(|| changelog.len()); let Some(ver_num) = ver_num else { continue; }; if ver_num <= from_num || ver_num > to_num { continue; } if start >= changelog.len() || end <= start { continue; } let end_clamped = end.min(changelog.len()); let section = changelog[start..end_clamped].trim(); if !section.is_empty() { relevant.push(section.to_string()); } } if relevant.is_empty() { None } else { Some(relevant.join("\n\n")) } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SidecarNotifyPayload { title: Option, body: Option, tag: Option, require_hidden: Option, } fn maybe_show_sidecar_notification(app: &tauri::AppHandle, payload: SidecarNotifyPayload) { let require_hidden = payload.require_hidden.unwrap_or(false); if require_hidden { let focused = app .try_state::() .map(|state| *state.focused.lock().expect("focus mutex")) .unwrap_or(false); if focused { return; } } let title = payload .title .filter(|t| is_nonempty_string(t)) .unwrap_or_else(|| "OpenChamber".to_string()); let body = payload.body.filter(|b| is_nonempty_string(b)); let _tag = payload.tag; use tauri_plugin_notification::NotificationExt; let mut builder = app.notification().builder().title(title); if let Some(body) = body { builder = builder.body(body); } #[cfg(target_os = "macos")] { builder = builder.sound("Glass"); } let _ = builder.show(); } async fn wait_for_health(url: &str) -> bool { let client = match reqwest::Client::builder().no_proxy().build() { Ok(c) => c, Err(_) => return false, }; let deadline = std::time::Instant::now() + HEALTH_TIMEOUT; let health_url = format!("{}/health", url.trim_end_matches('/')); while std::time::Instant::now() < deadline { if let Ok(resp) = client.get(&health_url).send().await { if resp.status().is_success() { return true; } } tokio::time::sleep(HEALTH_POLL_INTERVAL).await; } false } fn kill_sidecar(app: tauri::AppHandle) { let Some(state) = app.try_state::() else { return; }; let mut guard = state.child.lock().expect("sidecar mutex"); if let Some(child) = guard.take() { let _ = child.kill(); } } fn build_local_url(port: u16) -> String { format!("http://127.0.0.1:{port}") } async fn spawn_local_server(app: &tauri::AppHandle) -> Result { let stored_port = read_desktop_local_port_from_disk(); let mut candidates: Vec> = Vec::new(); if let Some(port) = stored_port { candidates.push(Some(port)); } candidates.push(Some(DEFAULT_DESKTOP_PORT)); candidates.push(None); let dist_dir = resolve_web_dist_dir(app)?; let no_proxy = "localhost,127.0.0.1"; // macOS app launch env often lacks user PATH entries. let mut path_segments: Vec = Vec::new(); let mut seen = std::collections::HashSet::::new(); let resolved_home_dir = app .path() .home_dir() .ok() .and_then(|p| { let s = p.to_string_lossy().to_string(); if s.trim().is_empty() { None } else { Some(s) } }); let mut push_unique = |value: String| { let trimmed = value.trim(); if trimmed.is_empty() { return; } if seen.insert(trimmed.to_string()) { path_segments.push(trimmed.to_string()); } }; // Respect explicit binary overrides by adding their parent dir first. for var in [ "OPENCHAMBER_OPENCODE_PATH", "OPENCHAMBER_OPENCODE_BIN", "OPENCODE_PATH", "OPENCODE_BINARY", ] { if let Ok(val) = env::var(var) { let trimmed = val.trim(); if trimmed.is_empty() { continue; } let path = std::path::Path::new(trimmed); if let Some(parent) = path.parent() { push_unique(parent.to_string_lossy().to_string()); } } } // Common locations. push_unique("/opt/homebrew/bin".to_string()); push_unique("/usr/local/bin".to_string()); push_unique("/usr/bin".to_string()); push_unique("/bin".to_string()); push_unique("/usr/sbin".to_string()); push_unique("/sbin".to_string()); if let Some(home) = resolved_home_dir.as_deref() { // OpenCode installer default. push_unique(format!("{home}/.opencode/bin")); push_unique(format!("{home}/.local/bin")); push_unique(format!("{home}/.bun/bin")); push_unique(format!("{home}/.cargo/bin")); push_unique(format!("{home}/bin")); } if let Ok(existing) = env::var("PATH") { for segment in existing.split(':') { push_unique(segment.to_string()); } } let augmented_path = path_segments.join(":"); for candidate in candidates { let port = match candidate { Some(p) => p, None => pick_unused_port()?, }; let url = build_local_url(port); let mut cmd = app .shell() .sidecar(SIDECAR_NAME) .map_err(|err| anyhow!("Failed to resolve sidecar '{SIDECAR_NAME}': {err}"))? .args(["--port", &port.to_string()]) .env("OPENCHAMBER_HOST", "127.0.0.1") .env("OPENCHAMBER_DIST_DIR", dist_dir.clone()) .env("OPENCHAMBER_DESKTOP_NOTIFY", "true") .env("PATH", augmented_path.clone()) .env("NO_PROXY", no_proxy) .env("no_proxy", no_proxy); if let Some(home) = resolved_home_dir.as_deref() { cmd = cmd.env("HOME", home); } let (rx, child) = match cmd.spawn() { Ok(v) => v, Err(err) => { log::warn!("[sidecar] spawn failed on port {port}: {err}"); continue; } }; let app_handle = app.clone(); tauri::async_runtime::spawn(async move { let mut rx = rx; while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(bytes) => { let line = String::from_utf8_lossy(&bytes); if let Some(rest) = line.strip_prefix(SIDECAR_NOTIFY_PREFIX) { if let Ok(parsed) = serde_json::from_str::(rest.trim()) { maybe_show_sidecar_notification(&app_handle, parsed); } } } CommandEvent::Error(error) => { log::warn!("[sidecar] error: {error}"); } CommandEvent::Terminated(payload) => { log::warn!( "[sidecar] terminated code={:?} signal={:?}", payload.code, payload.signal ); break; } _ => {} } } }); if let Some(state) = app.try_state::() { *state.child.lock().expect("sidecar mutex") = Some(child); *state.url.lock().expect("sidecar url mutex") = Some(url.clone()); } if !wait_for_health(&url).await { kill_sidecar(app.clone()); continue; } let _ = write_desktop_local_port_to_disk(port); return Ok(url); } Err(anyhow!("Sidecar health check failed")) } fn resolve_web_dist_dir(app: &tauri::AppHandle) -> Result { let candidates = ["web-dist", "resources/web-dist"]; for candidate in candidates { let path = app .path() .resolve(candidate, tauri::path::BaseDirectory::Resource) .map_err(|err| anyhow!("Failed to resolve '{candidate}' resources: {err}"))?; let index = path.join("index.html"); if fs::metadata(&index).is_ok() { return Ok(path); } } Err(anyhow!( "Web assets missing in app resources (expected index.html under web-dist)" )) } fn normalize_server_url(input: &str) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { return None; } match url::Url::parse(trimmed) { Ok(url) => { if url.scheme() == "http" || url.scheme() == "https" { Some(trimmed.trim_end_matches('/').to_string()) } else { None } } Err(_) => None, } } #[derive(Deserialize)] struct DesktopNotifyPayload { title: Option, body: Option, tag: Option, } #[tauri::command] fn desktop_notify( app: tauri::AppHandle, payload: Option, ) -> Result { let payload = payload.unwrap_or(DesktopNotifyPayload { title: None, body: None, tag: None, }); use tauri_plugin_notification::NotificationExt; let mut builder = app .notification() .builder() .title(payload.title.unwrap_or_else(|| "OpenChamber".to_string())); if let Some(body) = payload.body { if is_nonempty_string(&body) { builder = builder.body(body); } } if let Some(tag) = payload.tag { if is_nonempty_string(&tag) { let _ = tag; } } #[cfg(target_os = "macos")] { builder = builder.sound("Glass"); } builder.show().map(|_| true).map_err(|err| err.to_string()) } #[tauri::command] async fn desktop_check_for_updates( app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>, ) -> Result { let updater = app.updater().map_err(|err| err.to_string())?; let update = updater.check().await.map_err(|err| err.to_string())?; let current_version = app.package_info().version.to_string(); let info = if let Some(update) = update { *pending.0.lock().expect("pending update mutex") = Some(update.clone()); let mut body = update.body.clone(); if is_placeholder_release_notes(&body) { if let Some(notes) = fetch_changelog_notes(¤t_version, &update.version).await { body = Some(notes); } } DesktopUpdateInfo { available: true, current_version, version: Some(update.version.clone()), body, date: update.date.map(|date| date.to_string()), } } else { *pending.0.lock().expect("pending update mutex") = None; DesktopUpdateInfo { available: false, current_version, version: None, body: None, date: None, } }; Ok(info) } #[tauri::command] async fn desktop_download_and_install_update( app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>, ) -> Result<(), String> { let Some(update) = pending.0.lock().expect("pending update mutex").take() else { return Err("No pending update".to_string()); }; let mut downloaded: u64 = 0; let mut total: Option = None; let mut started = false; update .download_and_install( |chunk_length, content_length| { if !started { total = content_length; let _ = app.emit( "openchamber:update-progress", UpdateProgressEvent::Started { content_length }, ); started = true; } downloaded = downloaded.saturating_add(chunk_length as u64); let _ = app.emit( "openchamber:update-progress", UpdateProgressEvent::Progress { chunk_length, downloaded, total, }, ); }, || { let _ = app.emit("openchamber:update-progress", UpdateProgressEvent::Finished); }, ) .await .map_err(|err| err.to_string())?; Ok(()) } #[tauri::command] fn desktop_restart(app: tauri::AppHandle) { app.restart(); } fn create_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Result<()> { let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default(); #[cfg(target_os = "macos")] fn macos_major_version() -> Option { fn cmd_stdout(cmd: &str, args: &[&str]) -> Option { let output = Command::new(cmd).args(args).output().ok()?; if !output.status.success() { return None; } String::from_utf8(output.stdout).ok() } // Use marketing version (sw_vers), but map legacy 10.x to minor (10.15 -> 15). // This matches WebKit UA fallback logic in the UI. if let Some(raw) = cmd_stdout("/usr/bin/sw_vers", &["-productVersion"]).or_else(|| cmd_stdout("sw_vers", &["-productVersion"])) { let raw = raw.trim(); let mut parts = raw.split('.'); let major = parts.next().and_then(|v| v.parse::().ok())?; let minor = parts.next().and_then(|v| v.parse::().ok()).unwrap_or(0); return Some(if major == 10 { minor } else { major }); } // Fallback: derive from Darwin major (kern.osrelease major). let raw = cmd_stdout("/usr/sbin/sysctl", &["-n", "kern.osrelease"]) .or_else(|| cmd_stdout("sysctl", &["-n", "kern.osrelease"])) .or_else(|| cmd_stdout("/usr/bin/uname", &["-r"])) .or_else(|| cmd_stdout("uname", &["-r"]))?; let raw = raw.trim(); let major = raw.split('.').next()?.parse::().ok()?; if major >= 20 { return Some(major - 9); } if major >= 15 { return Some(major - 4); } Some(major) } #[cfg(not(target_os = "macos"))] fn macos_major_version() -> Option { None } let macos_major = macos_major_version().unwrap_or(0); let home_json = serde_json::to_string(&home).unwrap_or_else(|_| "\"\"".into()); let local_json = serde_json::to_string(local_origin).unwrap_or_else(|_| "\"\"".into()); let mut init_script = format!( "(function(){{try{{window.__OPENCHAMBER_HOME__={home_json};window.__OPENCHAMBER_MACOS_MAJOR__={macos_major};window.__OPENCHAMBER_LOCAL_ORIGIN__={local_json};}}catch(_e){{}}}})();" ); // Cleanup: older builds injected a native-ish Instance switcher button into pages. // Remove it if present so the UI-owned host switcher is the only one. init_script.push_str("\ntry{var old=document.getElementById('__oc-instance-switcher');if(old)old.remove();}catch(_e){}"); if !cfg!(debug_assertions) { init_script.push_str("\ntry{document.addEventListener('contextmenu',function(e){e.preventDefault();},true);}catch(_e){}"); } if let Some(state) = app.try_state::() { *state.script.lock().expect("desktop ui injection mutex") = Some(init_script.clone()); } let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(parsed)) .title("OpenChamber") .inner_size(1280.0, 800.0) .decorations(true) .visible(false) .initialization_script(&init_script) ; #[cfg(target_os = "macos")] { builder = builder .hidden_title(true) .title_bar_style(tauri::TitleBarStyle::Overlay) .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition { x: 17.0, y: 26.0 })); } let window = builder.build()?; let _ = window.show(); let _ = window.set_focus(); Ok(()) } fn main() { let log_builder = tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) .clear_targets() .targets([ tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview), ]); let builder = tauri::Builder::default() .manage(SidecarState::default()) .manage(DesktopUiInjectionState::default()) .manage(WindowFocusState::default()) .manage(MenuRuntimeState::default()) .manage(PendingUpdate(Mutex::new(None))) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(log_builder.build()) .on_page_load(|window, _payload| { if let Some(state) = window.app_handle().try_state::() { if let Ok(guard) = state.script.lock() { if let Some(script) = guard.as_ref() { let _ = window.eval(script); } } } }) .menu(|app| { #[cfg(target_os = "macos")] { build_macos_menu(app) } #[cfg(not(target_os = "macos"))] { tauri::menu::Menu::default(app) } }) .on_menu_event(|app, event| { #[cfg(target_os = "macos")] { let id = event.id().as_ref(); log::info!("[menu] click id={}", id); #[cfg(debug_assertions)] { let msg = serde_json::to_string(id).unwrap_or_else(|_| "\"(unserializable)\"".into()); eval_in_main_window(app, &format!("console.log('[menu] id=', {});", msg)); } if id == MENU_ITEM_CHECK_FOR_UPDATES_ID { dispatch_check_for_updates(app); return; } if id == MENU_ITEM_REPORT_BUG_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { let _ = app.shell().open(GITHUB_BUG_REPORT_URL, None); } return; } if id == MENU_ITEM_REQUEST_FEATURE_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { let _ = app.shell().open(GITHUB_FEATURE_REQUEST_URL, None); } return; } if id == MENU_ITEM_JOIN_DISCORD_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { let _ = app.shell().open(DISCORD_INVITE_URL, None); } return; } if id == MENU_ITEM_ABOUT_ID { dispatch_menu_action(app, "about"); return; } if id == MENU_ITEM_SETTINGS_ID { dispatch_menu_action(app, "settings"); return; } if id == MENU_ITEM_COMMAND_PALETTE_ID { dispatch_menu_action(app, "command-palette"); return; } if id == MENU_ITEM_NEW_SESSION_ID { dispatch_menu_action(app, "new-session"); return; } if id == MENU_ITEM_WORKTREE_CREATOR_ID { dispatch_menu_action(app, "new-worktree-session"); return; } if id == MENU_ITEM_CHANGE_WORKSPACE_ID { dispatch_menu_action(app, "change-workspace"); return; } if id == MENU_ITEM_OPEN_GIT_TAB_ID { dispatch_menu_action(app, "open-git-tab"); return; } if id == MENU_ITEM_OPEN_DIFF_TAB_ID { dispatch_menu_action(app, "open-diff-tab"); return; } if id == MENU_ITEM_OPEN_FILES_TAB_ID { dispatch_menu_action(app, "open-files-tab"); return; } if id == MENU_ITEM_OPEN_TERMINAL_TAB_ID { dispatch_menu_action(app, "open-terminal-tab"); return; } if id == MENU_ITEM_THEME_LIGHT_ID { dispatch_menu_action(app, "theme-light"); return; } if id == MENU_ITEM_THEME_DARK_ID { dispatch_menu_action(app, "theme-dark"); return; } if id == MENU_ITEM_THEME_SYSTEM_ID { dispatch_menu_action(app, "theme-system"); return; } if id == MENU_ITEM_TOGGLE_SIDEBAR_ID { dispatch_menu_action(app, "toggle-sidebar"); return; } if id == MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID { dispatch_menu_action(app, "toggle-memory-debug"); return; } if id == MENU_ITEM_HELP_DIALOG_ID { dispatch_menu_action(app, "help-dialog"); return; } if id == MENU_ITEM_DOWNLOAD_LOGS_ID { dispatch_menu_action(app, "download-logs"); } } }) .on_window_event(|window, event| { if let tauri::WindowEvent::Focused(focused) = event { let app = window.app_handle(); if let Some(state) = app.try_state::() { *state.focused.lock().expect("focus mutex") = *focused; } } }) .invoke_handler(tauri::generate_handler![ desktop_notify, desktop_check_for_updates, desktop_download_and_install_update, desktop_restart, desktop_set_auto_worktree_menu, desktop_hosts_get, desktop_hosts_set, desktop_host_probe, ]) .setup(|app| { let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { // Always ensure local server is running for escape hatch. let local_url = if cfg!(debug_assertions) { let dev_url = "http://127.0.0.1:3001"; if wait_for_health(dev_url).await { dev_url.to_string() } else { match spawn_local_server(&handle).await { Ok(local) => local, Err(err) => { log::error!("[desktop] failed to start local server: {err}"); return; } } } } else { match spawn_local_server(&handle).await { Ok(local) => local, Err(err) => { log::error!("[desktop] failed to start local server: {err}"); return; } } }; // Ensure local URL is always available to desktop commands, // even when we are using the Vite dev server (no sidecar child). if let Some(state) = handle.try_state::() { *state.url.lock().expect("sidecar url mutex") = Some(local_url.clone()); } let local_origin = url::Url::parse(&local_url) .ok() .map(|u| u.origin().ascii_serialization()) .unwrap_or_else(|| local_url.clone()); // Selected host: env override first, then desktop default host, else local. let env_target = std::env::var("OPENCHAMBER_SERVER_URL") .ok() .and_then(|raw| normalize_server_url(&raw)); let mut initial_url = env_target.unwrap_or_else(|| local_url.clone()); if initial_url == local_url { let cfg = read_desktop_hosts_config_from_disk(); if let Some(default_id) = cfg.default_host_id { if default_id == LOCAL_HOST_ID { initial_url = local_url.clone(); } else if let Some(host) = cfg.hosts.into_iter().find(|h| h.id == default_id) { initial_url = host.url; } } } if let Err(err) = create_main_window(&handle, &initial_url, &local_origin) { log::error!("[desktop] failed to create window: {err}"); } }); Ok(()) }) ; let app = builder .build(tauri::generate_context!()) .expect("failed to build Tauri application"); app.run(|app_handle, event| { match event { tauri::RunEvent::ExitRequested { .. } => { // Best-effort cleanup; never block shutdown. kill_sidecar(app_handle.clone()); } tauri::RunEvent::Exit => { kill_sidecar(app_handle.clone()); } _ => {} } }); }