#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod assistant_notifications; mod commands; mod logging; mod opencode_auth; mod opencode_config; mod opencode_manager; mod path_utils; mod session_activity; mod skills_catalog; mod window_state; use std::{ collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}, }; use anyhow::{anyhow, Result}; use assistant_notifications::spawn_assistant_notifications; use axum::{ body::{to_bytes, Body}, extract::{Request, State}, http::{Method, StatusCode}, response::{IntoResponse, Response}, routing::{any, get, post}, Json, Router, }; use commands::files::{create_directory, exec_commands, list_directory, read_file, search_files, write_file}; use commands::git::{ add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, ensure_openchamber_ignored, generate_commit_message, get_commit_files, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff, get_git_identities, get_git_log, get_git_status, git_fetch, git_pull, git_push, is_linked_worktree, list_git_worktrees, remove_git_worktree, revert_git_file, set_git_identity, update_git_identity, }; use commands::logs::fetch_desktop_logs; use commands::notifications::desktop_notify; use commands::permissions::{ pick_directory, process_directory_selection, request_directory_access, restore_bookmarks_on_startup, start_accessing_directory, stop_accessing_directory, }; use commands::settings::{load_settings, restart_opencode, save_settings}; use commands::terminal::{ close_terminal, create_terminal_session, force_kill_terminal, resize_terminal, restart_terminal_session, send_terminal_input, TerminalState, }; use futures_util::StreamExt as FuturesStreamExt; use log::{error, info, warn}; use opencode_manager::OpenCodeManager; use path_utils::expand_tilde_path; use portpicker::pick_unused_port; use reqwest::{header, Body as ReqwestBody, Client}; use serde::{Deserialize, Serialize}; use serde_json::Value; use session_activity::spawn_session_activity_tracker; #[cfg(feature = "devtools")] use tauri::WebviewWindow; use tauri::{Emitter, Manager}; use tauri_plugin_dialog::init as dialog_plugin; use tauri_plugin_fs::init as fs_plugin; use tauri_plugin_log::{Target, TargetKind}; use tauri_plugin_notification::init as notification_plugin; use tauri_plugin_shell::init as shell_plugin; use tokio::{ fs, net::TcpListener, sync::{broadcast, Mutex}, }; use tower_http::cors::CorsLayer; use window_state::{load_window_state, persist_window_state, WindowStateManager}; #[cfg(target_os = "macos")] use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; #[cfg(target_os = "macos")] static NEEDS_TRAFFIC_LIGHT_FIX: AtomicBool = AtomicBool::new(false); const PROXY_BODY_LIMIT: usize = 32 * 1024 * 1024; // 32MB const CLIENT_RELOAD_DELAY_MS: u64 = 800; const MODELS_DEV_API_URL: &str = "https://models.dev/api.json"; const MODELS_METADATA_CACHE_TTL: Duration = Duration::from_secs(5 * 60); const MODELS_METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(8); const CHECK_FOR_UPDATES_EVENT: &str = "openchamber:check-for-updates"; #[cfg(target_os = "macos")] const MENU_ITEM_CHECK_FOR_UPDATES_ID: &str = "openchamber_check_for_updates"; #[cfg(target_os = "macos")] const MENU_ITEM_REPORT_BUG_ID: &str = "openchamber_report_bug"; #[cfg(target_os = "macos")] const MENU_ITEM_REQUEST_FEATURE_ID: &str = "openchamber_request_feature"; #[cfg(target_os = "macos")] const MENU_ITEM_JOIN_DISCORD_ID: &str = "openchamber_join_discord"; // App menu #[cfg(target_os = "macos")] const MENU_ITEM_ABOUT_ID: &str = "openchamber_about"; #[cfg(target_os = "macos")] const MENU_ITEM_SETTINGS_ID: &str = "openchamber_settings"; #[cfg(target_os = "macos")] const MENU_ITEM_COMMAND_PALETTE_ID: &str = "openchamber_command_palette"; // File menu #[cfg(target_os = "macos")] const MENU_ITEM_NEW_SESSION_ID: &str = "openchamber_new_session"; #[cfg(target_os = "macos")] const MENU_ITEM_WORKTREE_CREATOR_ID: &str = "openchamber_worktree_creator"; #[cfg(target_os = "macos")] const MENU_ITEM_CHANGE_WORKSPACE_ID: &str = "openchamber_change_workspace"; // View menu #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_GIT_TAB_ID: &str = "openchamber_open_git_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_DIFF_TAB_ID: &str = "openchamber_open_diff_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "openchamber_open_terminal_tab"; #[cfg(target_os = "macos")] const MENU_ITEM_THEME_LIGHT_ID: &str = "openchamber_theme_light"; #[cfg(target_os = "macos")] const MENU_ITEM_THEME_DARK_ID: &str = "openchamber_theme_dark"; #[cfg(target_os = "macos")] const MENU_ITEM_THEME_SYSTEM_ID: &str = "openchamber_theme_system"; #[cfg(target_os = "macos")] const MENU_ITEM_TOGGLE_SIDEBAR_ID: &str = "openchamber_toggle_sidebar"; #[cfg(target_os = "macos")] const MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID: &str = "openchamber_toggle_memory_debug"; // Help menu #[cfg(target_os = "macos")] const MENU_ITEM_HELP_DIALOG_ID: &str = "openchamber_help_dialog"; #[cfg(target_os = "macos")] const MENU_ITEM_DOWNLOAD_LOGS_ID: &str = "openchamber_download_logs"; const GITHUB_BUG_REPORT_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml"; const GITHUB_FEATURE_REQUEST_URL: &str = "https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml"; const DISCORD_INVITE_URL: &str = "https://discord.gg/ZYRSdnwwKA"; #[derive(Clone)] pub(crate) struct DesktopRuntime { server_port: u16, shutdown_tx: broadcast::Sender<()>, opencode: Arc, settings: Arc, } impl DesktopRuntime { fn initialize_sync() -> Result { let settings = Arc::new(SettingsStore::new()?); let opencode = Arc::new(OpenCodeManager::new_with_directory(None)); let client = Client::builder().build()?; let (shutdown_tx, shutdown_rx) = broadcast::channel(2); let server_port = pick_unused_port().ok_or_else(|| anyhow!("No free port available"))? as u16; let server_state = ServerState { client, opencode: opencode.clone(), settings: settings.clone(), server_port, directory_change_lock: Arc::new(Mutex::new(())), models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())), }; spawn_http_server(server_port, server_state, shutdown_rx); Ok(Self { server_port, shutdown_tx, opencode, settings, }) } async fn start_opencode(&self) { if self.opencode.is_cli_available() { if let Err(e) = self.opencode.ensure_running().await { warn!("[desktop] Failed to start OpenCode: {}", e); } } else { info!("[desktop] OpenCode CLI not available - running in limited mode"); } } async fn shutdown(&self) { let _ = self.shutdown_tx.send(()); let _ = self.opencode.shutdown().await; } pub(crate) fn settings(&self) -> &SettingsStore { self.settings.as_ref() } pub(crate) fn subscribe_shutdown(&self) -> broadcast::Receiver<()> { self.shutdown_tx.subscribe() } pub(crate) fn opencode_manager(&self) -> Arc { self.opencode.clone() } } #[derive(Clone)] struct ServerState { client: Client, opencode: Arc, settings: Arc, server_port: u16, directory_change_lock: Arc>, models_metadata_cache: Arc>, } #[derive(Default)] struct ModelsMetadataCache { payload: Option, fetched_at: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ConfigActionResponse { success: bool, requires_reload: bool, message: String, reload_delay_ms: u64, } #[derive(Serialize)] struct ConfigErrorResponse { error: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ConfigMetadataResponse { name: String, sources: opencode_config::ConfigSources, scope: Option, is_built_in: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct HealthResponse { status: &'static str, server_port: u16, opencode_port: Option, api_prefix: String, is_opencode_ready: bool, cli_available: bool, } #[derive(Serialize)] struct ServerInfoPayload { server_port: u16, opencode_port: Option, api_prefix: String, cli_available: bool, has_last_directory: bool, } #[tauri::command] async fn desktop_server_info( state: tauri::State<'_, DesktopRuntime>, ) -> Result { let has_last_directory = state .settings() .last_directory() .await .ok() .flatten() .is_some(); Ok(ServerInfoPayload { server_port: state.server_port, opencode_port: state.opencode.current_port(), api_prefix: state.opencode.api_prefix(), cli_available: state.opencode.is_cli_available(), has_last_directory, }) } #[tauri::command] async fn desktop_restart_opencode(state: tauri::State<'_, DesktopRuntime>) -> Result<(), String> { state .opencode .restart() .await .map_err(|err| err.to_string()) } #[cfg(feature = "devtools")] #[tauri::command] async fn desktop_open_devtools(window: WebviewWindow) -> Result<(), String> { window.open_devtools(); Ok(()) } #[cfg(target_os = "macos")] fn get_macos_major_version() -> isize { use objc2_foundation::NSProcessInfo; let process_info = NSProcessInfo::processInfo(); let version = process_info.operatingSystemVersion(); version.majorVersion } #[cfg(target_os = "macos")] fn adjust_traffic_lights_position( window: &tauri::WebviewWindow, x: f64, y: f64, ) { use objc2::msg_send; use objc2::runtime::AnyObject; use objc2_foundation::{NSPoint, NSRect}; if let Ok(ns_window) = window.ns_window() { unsafe { let ns_window: *mut AnyObject = ns_window.cast(); let close_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: 0usize]; if !close_button.is_null() { let superview: *mut AnyObject = msg_send![close_button, superview]; if !superview.is_null() { let frame: NSRect = msg_send![superview, frame]; let new_frame = NSRect::new(NSPoint::new(x, y), frame.size); let _: () = msg_send![superview, setFrame: new_frame]; } } } } } #[cfg(target_os = "macos")] fn prevent_app_nap() { use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString}; let options = NSActivityOptions(0x00FFFFFF | 0xFF00000000); let reason = NSString::from_str("Prevent App Nap"); let process_info = NSProcessInfo::processInfo(); let activity = process_info.beginActivityWithOptions_reason(options, &reason); std::mem::forget(activity); info!("[macos] App Nap prevention enabled via objc2"); } #[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 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>, )?; // App menu items 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"), )?; // File menu items let new_session = MenuItem::with_id( app, MENU_ITEM_NEW_SESSION_ID, "New Session", true, Some("Cmd+N"), )?; let worktree_creator = MenuItem::with_id( app, MENU_ITEM_WORKTREE_CREATOR_ID, "New Worktree", true, Some("Cmd+Shift+N"), )?; let change_workspace = MenuItem::with_id( app, MENU_ITEM_CHANGE_WORKSPACE_ID, "Change Workspace", true, None::<&str>, )?; // View menu items 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_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"), )?; // Help menu items 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, "Download Logs", 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_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, ], ) } fn main() { let mut log_builder = tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) .clear_targets() .target(Target::new(TargetKind::Stdout)) .target(Target::new(TargetKind::Webview)); if let Some(dir) = logging::log_directory() { log_builder = log_builder.target(Target::new(TargetKind::Folder { path: dir, file_name: Some("openchamber".into()), })); } let app = tauri::Builder::default() .plugin(shell_plugin()) .plugin(dialog_plugin()) .plugin(fs_plugin()) .plugin(notification_plugin()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(log_builder.build()) .menu(|app| { #[cfg(target_os = "macos")] { return build_macos_menu(app); } #[cfg(not(target_os = "macos"))] { return tauri::menu::Menu::default(app); } }) .setup(|app| { #[cfg(target_os = "macos")] prevent_app_nap(); app.manage(TerminalState::new()); let stored_state = tauri::async_runtime::block_on(load_window_state()).unwrap_or(None); let manager = WindowStateManager::new(stored_state.clone().unwrap_or_default()); app.manage(manager.clone()); if let Some(window) = app.get_webview_window("main") { #[cfg(target_os = "macos")] { let macos_version = get_macos_major_version(); info!("[macos] Detected macOS version: {}", macos_version); let corner_radius = if macos_version >= 26 { 24.0 } else { 10.0 }; if let Err(error) = apply_vibrancy( &window, NSVisualEffectMaterial::Sidebar, None, Some(corner_radius), ) { warn!( "[desktop:vibrancy] Failed to apply macOS vibrancy: {}", error ); } else { info!( "[desktop:vibrancy] Applied macOS Sidebar vibrancy with radius {}", corner_radius ); } if macos_version < 26 { NEEDS_TRAFFIC_LIGHT_FIX.store(true, Ordering::SeqCst); adjust_traffic_lights_position(&window, 17.0, 16.0); } } if let Some(saved) = &stored_state { let _ = window_state::apply_window_state(&window, saved); } let _ = window.show(); let _ = window.set_focus(); } let runtime = DesktopRuntime::initialize_sync()?; app.manage(runtime.clone()); let app_handle = app.app_handle().clone(); let runtime_clone = runtime.clone(); tauri::async_runtime::spawn(async move { runtime_clone.start_opencode().await; if let Err(e) = restore_bookmarks_on_startup(app_handle.state::().clone()).await { warn!("Failed to restore bookmarks on startup: {}", e); } let _ = app_handle.emit("openchamber:runtime-ready", ()); }); // Sidecar watchdog: restart on unexpected exit and notify UI { let app_handle = app.app_handle().clone(); let runtime = runtime.clone(); tauri::async_runtime::spawn(async move { let mut backoff_ms: u64 = 1000; loop { if runtime.opencode_manager().is_shutting_down() { break; } let mut sleep_ms = backoff_ms; match runtime.opencode_manager().is_child_running().await { Ok(true) => { sleep_ms = 1000; backoff_ms = 1000; } Ok(false) => { let _ = app_handle.emit("server.instance.disposed", ()); if runtime.opencode_manager().is_cli_available() { if let Err(err) = runtime.opencode_manager().ensure_running().await { warn!( "[desktop:watchdog] Failed to restart OpenCode: {err}" ); } else { backoff_ms = 1000; } } } Err(err) => { warn!("[desktop:watchdog] Failed to check child status: {err}"); } } tokio::time::sleep(Duration::from_millis(sleep_ms)).await; backoff_ms = (backoff_ms * 2).min(8000); } }); } // Health and wake monitor: emit health and port updates to webview { let app_handle = app.app_handle().clone(); let runtime = runtime.clone(); tauri::async_runtime::spawn(async move { #[derive(Clone, Serialize)] struct HealthSnapshot { ok: bool, port: Option, api_prefix: String, cli_available: bool, } let mut last_snapshot: Option = None; let mut last_tick = Instant::now(); loop { if runtime.opencode_manager().is_shutting_down() { break; } let now = Instant::now(); let gap_ms = now.saturating_duration_since(last_tick).as_millis() as u64; last_tick = now; let snapshot = HealthSnapshot { ok: runtime.opencode_manager().is_ready(), port: runtime.opencode_manager().current_port(), api_prefix: runtime.opencode_manager().api_prefix(), cli_available: opencode_manager::check_cli_exists(), }; let changed = match &last_snapshot { Some(prev) => { prev.ok != snapshot.ok || prev.port != snapshot.port || prev.api_prefix != snapshot.api_prefix || prev.cli_available != snapshot.cli_available } None => true, }; if changed { let _ = app_handle.emit("openchamber:health-changed", &snapshot); last_snapshot = Some(snapshot.clone()); } if gap_ms > 15000 { let _ = app_handle.emit("openchamber:wake", ()); } tokio::time::sleep(Duration::from_secs(5)).await; } }); } spawn_assistant_notifications(app.app_handle().clone(), runtime.clone()); spawn_session_activity_tracker(app.app_handle().clone(), runtime.clone()); Ok(()) }) .invoke_handler(tauri::generate_handler![ desktop_server_info, desktop_restart_opencode, #[cfg(feature = "devtools")] desktop_open_devtools, load_settings, save_settings, restart_opencode, list_directory, search_files, create_directory, read_file, write_file, exec_commands, request_directory_access, start_accessing_directory, stop_accessing_directory, pick_directory, restore_bookmarks_on_startup, process_directory_selection, check_is_git_repository, get_git_status, get_git_diff, get_git_file_diff, revert_git_file, is_linked_worktree, get_git_branches, delete_git_branch, delete_remote_branch, list_git_worktrees, add_git_worktree, remove_git_worktree, ensure_openchamber_ignored, create_git_commit, git_push, git_pull, git_fetch, checkout_branch, create_branch, rename_branch, get_git_log, get_commit_files, get_git_identities, create_git_identity, update_git_identity, delete_git_identity, get_current_git_identity, set_git_identity, generate_commit_message, create_terminal_session, send_terminal_input, resize_terminal, close_terminal, restart_terminal_session, force_kill_terminal, fetch_desktop_logs, desktop_notify, ]) .on_menu_event(|app, event| { #[cfg(target_os = "macos")] { let event_id = event.id().as_ref(); // Check for updates if event_id == MENU_ITEM_CHECK_FOR_UPDATES_ID { let _ = app.emit(CHECK_FOR_UPDATES_EVENT, ()); return; } // External links if event_id == MENU_ITEM_REPORT_BUG_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { let _ = app.shell().open(GITHUB_BUG_REPORT_URL, None); } return; } if event_id == MENU_ITEM_REQUEST_FEATURE_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { let _ = app.shell().open(GITHUB_FEATURE_REQUEST_URL, None); } return; } if event_id == MENU_ITEM_JOIN_DISCORD_ID { use tauri_plugin_shell::ShellExt; #[allow(deprecated)] { let _ = app.shell().open(DISCORD_INVITE_URL, None); } return; } // App menu actions if event_id == MENU_ITEM_ABOUT_ID { let _ = app.emit("openchamber:menu-action", "about"); return; } if event_id == MENU_ITEM_SETTINGS_ID { let _ = app.emit("openchamber:menu-action", "settings"); return; } if event_id == MENU_ITEM_COMMAND_PALETTE_ID { let _ = app.emit("openchamber:menu-action", "command-palette"); return; } // File menu actions if event_id == MENU_ITEM_NEW_SESSION_ID { let _ = app.emit("openchamber:menu-action", "new-session"); return; } if event_id == MENU_ITEM_WORKTREE_CREATOR_ID { let _ = app.emit("openchamber:menu-action", "worktree-creator"); return; } if event_id == MENU_ITEM_CHANGE_WORKSPACE_ID { let _ = app.emit("openchamber:menu-action", "change-workspace"); return; } // View menu actions if event_id == MENU_ITEM_OPEN_GIT_TAB_ID { let _ = app.emit("openchamber:menu-action", "open-git-tab"); return; } if event_id == MENU_ITEM_OPEN_DIFF_TAB_ID { let _ = app.emit("openchamber:menu-action", "open-diff-tab"); return; } if event_id == MENU_ITEM_OPEN_TERMINAL_TAB_ID { let _ = app.emit("openchamber:menu-action", "open-terminal-tab"); return; } if event_id == MENU_ITEM_THEME_LIGHT_ID { let _ = app.emit("openchamber:menu-action", "theme-light"); return; } if event_id == MENU_ITEM_THEME_DARK_ID { let _ = app.emit("openchamber:menu-action", "theme-dark"); return; } if event_id == MENU_ITEM_THEME_SYSTEM_ID { let _ = app.emit("openchamber:menu-action", "theme-system"); return; } if event_id == MENU_ITEM_TOGGLE_SIDEBAR_ID { let _ = app.emit("openchamber:menu-action", "toggle-sidebar"); return; } if event_id == MENU_ITEM_TOGGLE_MEMORY_DEBUG_ID { let _ = app.emit("openchamber:menu-action", "toggle-memory-debug"); return; } // Help menu actions if event_id == MENU_ITEM_HELP_DIALOG_ID { let _ = app.emit("openchamber:menu-action", "help-dialog"); return; } if event_id == MENU_ITEM_DOWNLOAD_LOGS_ID { let _ = app.emit("openchamber:menu-action", "download-logs"); return; } } }) .on_window_event(|window, event| { let window_state_manager = window.state::().inner().clone(); match event { tauri::WindowEvent::Focused(true) => { // Clear dock badge and underlying badge state when the window gains focus let _ = window.set_badge_count(None); let _ = window .app_handle() .emit("openchamber:clear-badge-sessions", ()); } tauri::WindowEvent::Moved(position) => { let is_maximized = window.is_maximized().unwrap_or(false); window_state_manager.update_position( position.x as f64, position.y as f64, is_maximized, ); } tauri::WindowEvent::Resized(size) => { let is_maximized = window.is_maximized().unwrap_or(false); window_state_manager.update_size( size.width as f64, size.height as f64, is_maximized, ); #[cfg(target_os = "macos")] if NEEDS_TRAFFIC_LIGHT_FIX.load(Ordering::SeqCst) { if let Some(webview) = window.app_handle().get_webview_window("main") { adjust_traffic_lights_position(&webview, 17.0, 16.0); } } } tauri::WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let runtime = window.state::().inner().clone(); let window_handle = window.clone(); let manager_clone = window_state_manager.clone(); tauri::async_runtime::spawn(async move { if let Err(err) = persist_window_state(&window_handle, &manager_clone).await { warn!("Failed to persist window state: {}", err); } runtime.shutdown().await; let _ = window_handle.app_handle().exit(0); }); } _ => {} } }) .build(tauri::generate_context!()) .expect("failed to build Tauri application"); app.run(|_app_handle, _event| {}); } fn spawn_http_server(port: u16, state: ServerState, shutdown_rx: broadcast::Receiver<()>) { tauri::async_runtime::spawn(async move { if let Err(error) = run_http_server(port, state, shutdown_rx).await { error!("[desktop:http] server stopped: {error:?}"); } }); } async fn run_http_server( port: u16, state: ServerState, mut shutdown_rx: broadcast::Receiver<()>, ) -> Result<()> { let router = Router::new() .route("/health", get(health_handler)) .route( "/api/openchamber/models-metadata", get(models_metadata_handler), ) .route("/api/opencode/directory", post(change_directory_handler)) .route("/api", any(proxy_to_opencode)) .route("/api/{*rest}", any(proxy_to_opencode)) .with_state(state) .layer(CorsLayer::permissive()); let addr = format!("127.0.0.1:{port}"); let listener = TcpListener::bind(&addr).await?; info!("[desktop:http] listening on http://{addr}"); axum::serve(listener, router) .with_graceful_shutdown(async move { let _ = shutdown_rx.recv().await; }) .await?; Ok(()) } async fn health_handler(State(state): State) -> Json { Json(HealthResponse { status: "ok", server_port: state.server_port, opencode_port: state.opencode.current_port(), api_prefix: state.opencode.api_prefix(), is_opencode_ready: state.opencode.is_ready(), cli_available: opencode_manager::check_cli_exists(), }) } async fn models_metadata_handler( State(state): State, ) -> Result, StatusCode> { let now = Instant::now(); let cached_payload: Option = { let cache = state.models_metadata_cache.lock().await; if let (Some(payload), Some(fetched_at)) = (&cache.payload, cache.fetched_at) { if now.duration_since(fetched_at) < MODELS_METADATA_CACHE_TTL { return Ok(Json(payload.clone())); } } cache.payload.clone() }; let response = state .client .get(MODELS_DEV_API_URL) .header(header::ACCEPT, "application/json") .timeout(MODELS_METADATA_REQUEST_TIMEOUT) .send() .await .map_err(|error| { warn!("[desktop:http] Failed to fetch models metadata: {error}"); StatusCode::BAD_GATEWAY })?; if !response.status().is_success() { warn!( "[desktop:http] models.dev responded with status {}", response.status() ); if let Some(payload) = cached_payload { return Ok(Json(payload)); } return Err(StatusCode::BAD_GATEWAY); } let payload = response.json::().await.map_err(|error| { warn!("[desktop:http] Failed to parse models.dev payload: {error}"); StatusCode::BAD_GATEWAY })?; { let mut cache = state.models_metadata_cache.lock().await; cache.payload = Some(payload.clone()); cache.fetched_at = Some(Instant::now()); } Ok(Json(payload)) } #[derive(Deserialize)] struct DirectoryChangeRequest { path: String, } #[derive(Serialize)] struct DirectoryChangeResponse { success: bool, restarted: bool, path: String, } fn json_response(status: StatusCode, payload: T) -> Response { (status, Json(payload)).into_response() } fn config_error_response(status: StatusCode, message: impl Into) -> Response { json_response( status, ConfigErrorResponse { error: message.into(), }, ) } async fn parse_request_payload(req: &mut Request) -> Result, Response> { let body = std::mem::take(req.body_mut()); let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) .await .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Invalid request body"))?; if body_bytes.is_empty() { return Ok(HashMap::new()); } serde_json::from_slice::>(&body_bytes) .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Malformed JSON payload")) } async fn refresh_opencode_after_config_change( state: &ServerState, reason: &str, ) -> Result<(), Response> { info!("[desktop:config] Restarting OpenCode after {}", reason); state.opencode.restart().await.map_err(|err| { config_error_response( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to restart OpenCode: {}", err), ) })?; Ok(()) } fn extract_directory_from_request(req: &Request) -> Option { if let Some(value) = req.headers().get("x-opencode-directory") { if let Ok(text) = value.to_str() { let trimmed = text.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } let query = req.uri().query()?; for pair in query.split('&') { let mut parts = pair.splitn(2, '='); let key = parts.next()?; if key != "directory" { continue; } let value = parts.next().unwrap_or(""); if let Ok(decoded) = urlencoding::decode(value) { let trimmed = decoded.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } None } async fn resolve_directory_candidate(candidate: &str) -> Result { let mut resolved = expand_tilde_path(candidate); if !resolved.is_absolute() { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); resolved = home.join(resolved); } let metadata = fs::metadata(&resolved) .await .map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Directory not found"))?; if !metadata.is_dir() { return Err(config_error_response( StatusCode::BAD_REQUEST, "Specified path is not a directory", )); } if let Ok(canonicalized) = fs::canonicalize(&resolved).await { resolved = canonicalized; } Ok(resolved) } async fn resolve_project_directory_from_settings( settings: &SettingsStore, ) -> Result, Response> { let raw = settings.load().await.map_err(|_| { config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load settings") })?; let active_id = raw .get("activeProjectId") .and_then(|value| value.as_str()) .unwrap_or("") .trim() .to_string(); let projects = raw.get("projects").and_then(|value| value.as_array()); if let Some(projects) = projects { if let Some(entry) = projects.iter().find(|entry| { entry .get("id") .and_then(|value| value.as_str()) .map(|value| value.trim() == active_id) .unwrap_or(false) }) { if let Some(path) = entry.get("path").and_then(|value| value.as_str()) { return resolve_directory_candidate(path).await.map(Some); } } if let Some(entry) = projects.first() { if let Some(path) = entry.get("path").and_then(|value| value.as_str()) { return resolve_directory_candidate(path).await.map(Some); } } } let legacy = raw .get("lastDirectory") .and_then(|value| value.as_str()) .unwrap_or("") .trim(); if !legacy.is_empty() { return resolve_directory_candidate(legacy).await.map(Some); } Ok(None) } async fn resolve_project_directory( state: &ServerState, directory: Option, ) -> Result { if let Some(directory) = directory { return resolve_directory_candidate(&directory).await; } match resolve_project_directory_from_settings(state.settings.as_ref()).await? { Some(path) => Ok(path), None => Err(config_error_response( StatusCode::BAD_REQUEST, "Directory parameter or active project is required", )), } } async fn handle_agent_route( state: &ServerState, method: Method, mut req: Request, name: String, ) -> Result { // Get working directory for project-level agent detection let working_directory = match resolve_project_directory(state, extract_directory_from_request(&req)).await { Ok(directory) => directory, Err(response) => return Ok(response), }; match method { Method::GET => { match opencode_config::get_agent_sources(&name, Some(&working_directory)).await { Ok(sources) => { let resolved_scope = if sources.md.exists { sources.md.scope.clone() } else { sources.json.scope.clone() }; let scope = resolved_scope.map(|s| match s { opencode_config::Scope::User => opencode_config::CommandScope::User, opencode_config::Scope::Project => opencode_config::CommandScope::Project, }); Ok(json_response( StatusCode::OK, ConfigMetadataResponse { name, is_built_in: !sources.md.exists && !sources.json.exists, scope, sources, }, )) } Err(err) => { error!("[desktop:config] Failed to read agent sources: {}", err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, "Failed to read agent configuration", )) } } } Method::POST => { let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; // Extract scope from payload if present let scope = payload .get("scope") .and_then(|v| v.as_str()) .and_then(|s| match s { "project" => Some(opencode_config::AgentScope::Project), "user" => Some(opencode_config::AgentScope::User), _ => None, }); match opencode_config::create_agent(&name, &payload, Some(&working_directory), scope) .await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "agent creation").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Agent {} created successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!("[desktop:config] Failed to create agent {}: {}", name, err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )) } } } Method::PATCH => { let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; match opencode_config::update_agent(&name, &payload, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "agent update").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Agent {} updated successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!("[desktop:config] Failed to update agent {}: {}", name, err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )) } } } Method::DELETE => { match opencode_config::delete_agent(&name, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "agent deletion").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Agent {} deleted successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!("[desktop:config] Failed to delete agent {}: {}", name, err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )) } } } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), } } /// Response type for skill metadata #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SkillMetadataResponse { name: String, exists: bool, #[serde(skip_serializing_if = "Option::is_none")] scope: Option, #[serde(skip_serializing_if = "Option::is_none")] source: Option, sources: opencode_config::SkillConfigSources, } /// Response type for skill list #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SkillListItem { name: String, path: String, scope: opencode_config::Scope, source: opencode_config::SkillSource, sources: opencode_config::SkillConfigSources, } /// Response type for skill file content #[derive(Serialize)] struct SkillFileResponse { path: String, content: String, } async fn handle_skill_list_route( state: &ServerState, req: Request, ) -> Result { let working_directory = match resolve_project_directory(state, extract_directory_from_request(&req)).await { Ok(directory) => directory, Err(response) => return Ok(response), }; let discovered = opencode_config::discover_skills(Some(&working_directory)); let mut skills = Vec::new(); for skill in discovered { match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await { Ok(sources) => { skills.push(SkillListItem { name: skill.name, path: skill.path, scope: skill.scope, source: skill.source, sources, }); } Err(err) => { error!( "[desktop:config] Failed to get skill sources for {}: {}", skill.name, err ); } } } Ok(json_response( StatusCode::OK, serde_json::json!({ "skills": skills }), )) } async fn handle_skill_route( state: &ServerState, method: Method, mut req: Request, name: String, file_path: Option, ) -> Result { let working_directory = match resolve_project_directory(state, extract_directory_from_request(&req)).await { Ok(directory) => directory, Err(response) => return Ok(response), }; // Handle file operations: /api/config/skills/:name/files/* if let Some(ref fp) = file_path { match method { Method::GET => { // Read supporting file match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { if !sources.md.exists { return Ok(config_error_response( StatusCode::NOT_FOUND, "Skill not found", )); } let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; match opencode_config::read_skill_supporting_file( std::path::Path::new(&skill_dir), fp, ) .await { Ok(content) => Ok(json_response( StatusCode::OK, SkillFileResponse { path: fp.clone(), content, }, )), Err(_) => Ok(config_error_response( StatusCode::NOT_FOUND, "File not found", )), } } Err(err) => { error!("[desktop:config] Failed to read skill sources: {}", err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, "Failed to read skill", )) } } } Method::PUT => { // Write supporting file let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; let content = payload .get("content") .and_then(|v| v.as_str()) .unwrap_or(""); match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { if !sources.md.exists { return Ok(config_error_response( StatusCode::NOT_FOUND, "Skill not found", )); } let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; match opencode_config::write_skill_supporting_file( std::path::Path::new(&skill_dir), fp, content, ) .await { Ok(()) => Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: false, message: format!("File {} saved successfully", fp), reload_delay_ms: 0, }, )), Err(err) => Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )), } } Err(err) => Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )), } } Method::DELETE => { // Delete supporting file match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { if !sources.md.exists { return Ok(config_error_response( StatusCode::NOT_FOUND, "Skill not found", )); } let skill_dir = sources.md.dir.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; match opencode_config::delete_skill_supporting_file( std::path::Path::new(&skill_dir), fp, ) .await { Ok(()) => Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: false, message: format!("File {} deleted successfully", fp), reload_delay_ms: 0, }, )), Err(err) => Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )), } } Err(err) => Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )), } } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), } } else { // Handle skill CRUD: /api/config/skills/:name match method { Method::GET => { match opencode_config::get_skill_sources(&name, Some(&working_directory)).await { Ok(sources) => { let scope = sources.md.scope.clone(); let source = sources.md.source.clone(); Ok(json_response( StatusCode::OK, SkillMetadataResponse { name, exists: sources.md.exists, scope, source, sources, }, )) } Err(err) => { error!("[desktop:config] Failed to read skill sources: {}", err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, "Failed to read skill configuration", )) } } } Method::POST => { let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; let scope = payload .get("scope") .and_then(|v| v.as_str()) .and_then(|s| match s { "project" => Some(opencode_config::SkillScope::Project), "user" => Some(opencode_config::SkillScope::User), _ => None, }); match opencode_config::create_skill( &name, &payload, Some(&working_directory), scope, ) .await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "skill creation").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Skill {} created successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!("[desktop:config] Failed to create skill {}: {}", name, err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )) } } } Method::PATCH => { let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; match opencode_config::update_skill(&name, &payload, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "skill update").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Skill {} updated successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!("[desktop:config] Failed to update skill {}: {}", name, err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )) } } } Method::DELETE => { match opencode_config::delete_skill(&name, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "skill deletion").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Skill {} deleted successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!("[desktop:config] Failed to delete skill {}: {}", name, err); let status = if err.to_string().contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; Ok(config_error_response(status, err.to_string())) } } } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), } } } async fn handle_command_route( state: &ServerState, method: Method, mut req: Request, name: String, ) -> Result { // Get working directory for project-level command detection let working_directory = match resolve_project_directory(state, extract_directory_from_request(&req)).await { Ok(directory) => directory, Err(response) => return Ok(response), }; match method { Method::GET => { match opencode_config::get_command_sources(&name, Some(&working_directory)).await { Ok(sources) => { let resolved_scope = if sources.md.exists { sources.md.scope.clone() } else { sources.json.scope.clone() }; let scope = resolved_scope.map(|s| match s { opencode_config::Scope::User => opencode_config::CommandScope::User, opencode_config::Scope::Project => opencode_config::CommandScope::Project, }); Ok(json_response( StatusCode::OK, ConfigMetadataResponse { name, is_built_in: !sources.md.exists && !sources.json.exists, scope, sources, }, )) } Err(err) => { error!("[desktop:config] Failed to read command sources: {}", err); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, "Failed to read command configuration", )) } } } Method::POST => { let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; // Extract scope from payload if present let scope = payload .get("scope") .and_then(|v| v.as_str()) .and_then(|s| match s { "project" => Some(opencode_config::CommandScope::Project), "user" => Some(opencode_config::CommandScope::User), _ => None, }); match opencode_config::create_command(&name, &payload, Some(&working_directory), scope) .await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "command creation").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Command {} created successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!( "[desktop:config] Failed to create command {}: {}", name, err ); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )) } } } Method::PATCH => { let payload = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; match opencode_config::update_command(&name, &payload, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "command update").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Command {} updated successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!( "[desktop:config] Failed to update command {}: {}", name, err ); Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )) } } } Method::DELETE => { match opencode_config::delete_command(&name, Some(&working_directory)).await { Ok(()) => { if let Err(resp) = refresh_opencode_after_config_change(state, "command deletion").await { return Ok(resp); } Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: format!( "Command {} deleted successfully. Reloading interface...", name ), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )) } Err(err) => { error!( "[desktop:config] Failed to delete command {}: {}", name, err ); let status = if err.to_string().contains("not found") { StatusCode::NOT_FOUND } else { StatusCode::INTERNAL_SERVER_ERROR }; Ok(config_error_response(status, err.to_string())) } } } _ => Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()), } } async fn handle_config_routes( state: ServerState, path: &str, method: Method, mut req: Request, ) -> Result { if let Some(name) = path.strip_prefix("/api/config/agents/") { let trimmed = name.trim(); if trimmed.is_empty() { return Ok(config_error_response( StatusCode::BAD_REQUEST, "Agent name is required", )); } return handle_agent_route(&state, method, req, trimmed.to_string()).await; } if let Some(name) = path.strip_prefix("/api/config/commands/") { let trimmed = name.trim(); if trimmed.is_empty() { return Ok(config_error_response( StatusCode::BAD_REQUEST, "Command name is required", )); } return handle_command_route(&state, method, req, trimmed.to_string()).await; } // Skills catalog routes (must be checked before /api/config/skills/:name) if path == "/api/config/skills/catalog" && method == Method::GET { let refresh = req .uri() .query() .map(|q| q.contains("refresh=true")) .unwrap_or(false); let working_directory = match resolve_project_directory(&state, extract_directory_from_request(&req)).await { Ok(directory) => directory, Err(response) => return Ok(response), }; let payload = skills_catalog::get_catalog(&working_directory, refresh).await; return Ok(json_response(StatusCode::OK, payload)); } if path == "/api/config/skills/scan" && method == Method::POST { let payload_map = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); let scan_request = match serde_json::from_value::(payload_value) { Ok(v) => v, Err(_) => { return Ok(json_response( StatusCode::BAD_REQUEST, skills_catalog::SkillsRepoScanResponse { ok: false, items: None, error: Some(skills_catalog::SkillsRepoError { kind: "invalidSource".to_string(), message: "Malformed scan request".to_string(), ssh_only: None, identities: None, conflicts: None, }), }, )) } }; let response = skills_catalog::scan_repository(scan_request).await; let status = if response.ok { StatusCode::OK } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") { StatusCode::UNAUTHORIZED } else { StatusCode::BAD_REQUEST }; return Ok(json_response(status, response)); } if path == "/api/config/skills/install" && method == Method::POST { let payload_map = match parse_request_payload(&mut req).await { Ok(data) => data, Err(resp) => return Ok(resp), }; let payload_value = serde_json::Value::Object(payload_map.into_iter().collect()); let install_request = match serde_json::from_value::(payload_value) { Ok(v) => v, Err(_) => { return Ok(json_response( StatusCode::BAD_REQUEST, skills_catalog::SkillsInstallResponse { ok: false, installed: None, skipped: None, error: Some(skills_catalog::SkillsRepoError { kind: "invalidSource".to_string(), message: "Malformed install request".to_string(), ssh_only: None, identities: None, conflicts: None, }), }, )) } }; let working_directory = if install_request.scope == "project" { match resolve_project_directory(&state, extract_directory_from_request(&req)).await { Ok(directory) => directory, Err(response) => return Ok(response), } } else { dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) }; let response = skills_catalog::install_skills(&working_directory, install_request).await; let status = if response.ok { StatusCode::OK } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") { StatusCode::UNAUTHORIZED } else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") { StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }; return Ok(json_response(status, response)); } // Handle skill routes: /api/config/skills and /api/config/skills/:name if path == "/api/config/skills" && method == Method::GET { return handle_skill_list_route(&state, req).await; } if let Some(rest) = path.strip_prefix("/api/config/skills/") { // Check if it's a file operation: /api/config/skills/:name/files/* if let Some(files_start) = rest.find("/files/") { let name = &rest[..files_start]; let file_path_encoded = &rest[files_start + 7..]; // Skip "/files/" // Decode URL-encoded path (e.g., "docs%2Foptimization.md" -> "docs/optimization.md") let file_path = urlencoding::decode(file_path_encoded) .map(|s| s.into_owned()) .unwrap_or_else(|_| file_path_encoded.to_string()); if name.is_empty() { return Ok(config_error_response( StatusCode::BAD_REQUEST, "Skill name is required", )); } return handle_skill_route(&state, method, req, name.to_string(), Some(file_path)) .await; } let trimmed = rest.trim(); if trimmed.is_empty() { return Ok(config_error_response( StatusCode::BAD_REQUEST, "Skill name is required", )); } return handle_skill_route(&state, method, req, trimmed.to_string(), None).await; } if path == "/api/config/reload" && method == Method::POST { if let Err(resp) = refresh_opencode_after_config_change(&state, "manual configuration reload").await { return Ok(resp); } return Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: "Configuration reloaded successfully. Refreshing interface...".to_string(), reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )); } // Handle provider auth removal: DELETE /api/provider/:providerId/auth if let Some(rest) = path.strip_prefix("/api/provider/") { if let Some(provider_id) = rest.strip_suffix("/auth") { if method == Method::DELETE { let trimmed = provider_id.trim(); if trimmed.is_empty() { return Ok(config_error_response( StatusCode::BAD_REQUEST, "Provider ID is required", )); } match opencode_auth::remove_provider_auth(trimmed).await { Ok(removed) => { if let Err(resp) = refresh_opencode_after_config_change( &state, &format!("provider {} disconnected", trimmed), ) .await { return Ok(resp); } return Ok(json_response( StatusCode::OK, ConfigActionResponse { success: true, requires_reload: true, message: if removed { "Provider disconnected successfully".to_string() } else { "Provider was not connected".to_string() }, reload_delay_ms: CLIENT_RELOAD_DELAY_MS, }, )); } Err(err) => { error!( "[desktop:config] Failed to disconnect provider {}: {}", trimmed, err ); return Ok(config_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), )); } } } } } Ok(StatusCode::NOT_FOUND.into_response()) } async fn change_directory_handler( State(state): State, Json(payload): Json, ) -> Result, StatusCode> { // Acquire lock to prevent concurrent directory changes let _lock = state.directory_change_lock.lock().await; let requested_path = payload.path.trim(); if requested_path.is_empty() { warn!("[desktop:http] ERROR: Empty path provided"); return Err(StatusCode::BAD_REQUEST); } let mut resolved_path = expand_tilde_path(requested_path); if !resolved_path.is_absolute() { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); resolved_path = home.join(resolved_path); } // Validate directory exists and is accessible match fs::metadata(&resolved_path).await { Ok(metadata) => { if !metadata.is_dir() { warn!( "[desktop:http] ERROR: Path is not a directory: {:?}", resolved_path ); return Err(StatusCode::BAD_REQUEST); } } Err(err) => { warn!( "[desktop:http] ERROR: Cannot access path: {:?} - {}", resolved_path, err ); return Err(StatusCode::NOT_FOUND); } } if let Ok(canonicalized) = fs::canonicalize(&resolved_path).await { resolved_path = canonicalized; } let path_value = resolved_path.to_string_lossy().to_string(); state .settings .update(|mut settings| { if !settings.is_object() { settings = Value::Object(Default::default()); } let mut projects = settings .get("projects") .and_then(|value| value.as_array()) .cloned() .unwrap_or_default(); let existing_index = projects.iter().position(|entry| { entry .get("path") .and_then(|value| value.as_str()) .map(|value| value == path_value) .unwrap_or(false) }); let active_project_id = if let Some(index) = existing_index { projects[index] .get("id") .and_then(|value| value.as_str()) .unwrap_or("") .to_string() } else { let id = uuid::Uuid::new_v4().to_string(); let project = serde_json::json!({ "id": id, "path": path_value, "addedAt": chrono::Utc::now().timestamp_millis(), "lastOpenedAt": chrono::Utc::now().timestamp_millis(), }); projects.push(project); id }; let map = settings.as_object_mut().unwrap(); map.insert("projects".to_string(), Value::Array(projects)); map.insert( "activeProjectId".to_string(), Value::String(active_project_id.clone()), ); map.insert("lastDirectory".to_string(), Value::String(path_value.clone())); settings }) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(DirectoryChangeResponse { success: true, restarted: false, path: path_value, })) } async fn proxy_to_opencode( State(state): State, req: Request, ) -> Result { let origin_path = req.uri().path().to_string(); let method = req.method().clone(); // Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth) let is_provider_auth_delete = method == Method::DELETE && origin_path.starts_with("/api/provider/") && origin_path.ends_with("/auth") && origin_path != "/api/provider/auth"; // Exclude GET /api/provider/auth let is_desktop_config_route = origin_path.starts_with("/api/config/agents/") || origin_path.starts_with("/api/config/commands/") || origin_path.starts_with("/api/config/skills") || origin_path == "/api/config/reload" || is_provider_auth_delete; if is_desktop_config_route { return handle_config_routes(state, &origin_path, method, req).await; } let port = state.opencode.current_port().ok_or_else(|| { error!("[desktop:http] PROXY FAILED: OpenCode not running (no port)"); StatusCode::SERVICE_UNAVAILABLE })?; let query = req.uri().query(); let rewritten_path = state.opencode.rewrite_path(&origin_path); let mut target = format!("http://127.0.0.1:{port}{rewritten_path}"); if let Some(q) = query { target.push('?'); target.push_str(q); } let (parts, body) = req.into_parts(); let method = parts.method.clone(); let mut builder = state.client.request(method, &target); let mut headers = parts.headers; headers.insert(header::HOST, format!("127.0.0.1:{port}").parse().unwrap()); if headers .get(header::ACCEPT) .and_then(|v| v.to_str().ok()) .map(|val| val.contains("text/event-stream")) .unwrap_or(false) { headers.insert(header::CONNECTION, "keep-alive".parse().unwrap()); } for (key, value) in headers.iter() { if key == &header::CONTENT_LENGTH { continue; } builder = builder.header(key, value); } let body_bytes = to_bytes(body, PROXY_BODY_LIMIT) .await .map_err(|_| StatusCode::BAD_GATEWAY)?; let response = if body_bytes.is_empty() { builder.send().await.map_err(|_| StatusCode::BAD_GATEWAY)? } else { builder .body(ReqwestBody::from(body_bytes)) .send() .await .map_err(|_| StatusCode::BAD_GATEWAY)? }; let status = response.status(); let mut resp_builder = Response::builder().status(status); for (key, value) in response.headers() { if key.as_str().eq_ignore_ascii_case("connection") { continue; } resp_builder = resp_builder.header(key, value); } let stream = response.bytes_stream().map(|chunk| { chunk .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)) .map(axum::body::Bytes::from) }); let body = Body::from_stream(stream); resp_builder.body(body).map_err(|_| StatusCode::BAD_GATEWAY) } #[derive(Clone)] pub(crate) struct SettingsStore { path: PathBuf, guard: Arc>, } impl SettingsStore { pub(crate) fn new() -> Result { // Use ~/.config/openchamber for consistency with Electron/web versions let home = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?; let mut dir = home; dir.push(".config"); dir.push("openchamber"); std::fs::create_dir_all(&dir).ok(); dir.push("settings.json"); Ok(Self { path: dir, guard: Arc::new(Mutex::new(())), }) } pub(crate) async fn load(&self) -> Result { let _lock = self.guard.lock().await; match fs::read(&self.path).await { Ok(bytes) => { let value = serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())); Ok(value) } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Ok(Value::Object(Default::default())) } Err(err) => Err(err.into()), } } pub(crate) async fn update_with(&self, f: F) -> Result<(Value, R)> where F: FnOnce(Value) -> (Value, R), { let _lock = self.guard.lock().await; let current = match fs::read(&self.path).await { Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Value::Object(Default::default()) } Err(err) => return Err(err.into()), }; let current_snapshot = current.clone(); let (next, result) = f(current); if next != current_snapshot { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent).await.ok(); } let bytes = serde_json::to_vec_pretty(&next)?; fs::write(&self.path, bytes).await?; } Ok((next, result)) } pub(crate) async fn update(&self, f: F) -> Result where F: FnOnce(Value) -> Value, { let (next, _) = self.update_with(|current| (f(current), ())).await?; Ok(next) } pub(crate) async fn last_directory(&self) -> Result> { let settings = self.load().await?; let candidate = settings .get("lastDirectory") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(expand_tilde_path); Ok(candidate) } }