Files
openchamber/packages/desktop/src-tauri/src/main.rs
T
Bohdan Triapitsyn 74511abfda Multi-account GitHub auth + UI polish (model logos, markdown, scroll behavior) (#219)
* feat: display provider logos for favorite/recent models

Show provider logo next to model name in favorites and recents
Render provider logos in ModelControls, ModelMultiSelect, and ModelSelector lists
Maintain zero-logo state for other sections to avoid clutter

* feat: render user message as markdown instead of plain text

Render agent mentions as markdown links in user text
Apply inside list style for chat content to fix list rendering
Rely on SimpleMarkdownRenderer for consistent rendering

* fix(openchamber): adjust layout and overscroll behavior

Enable overscroll-auto on overlay containers for smoother scrolling
Move page content to full-width wrapper and preserve section borders
Show AboutSettings inside its own bordered block when visible

* feat: integrate GitHub auth status store and UI

Introduce GitHubAuthStore to track connection status and polling
Show GitHub avatar in header when connected
Guard issue/pr dialogs behind GitHub auth status and show notices

* feat: add GitHub multi-account support

Add API and UI flow to activate a GitHub account
Show and switch between multiple GitHub accounts in header
Persist and normalize accounts list with current selection
2026-01-26 11:28:55 +02:00

2764 lines
97 KiB
Rust

#![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, delete_path, exec_commands, list_directory, read_file, read_file_binary,
rename_path, search_files, write_file,
};
use commands::git::{
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
discover_git_credentials, 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, get_global_git_identity, get_remote_url,
git_fetch, git_pull, git_push, has_local_identity, is_linked_worktree, list_git_worktrees,
remove_git_worktree, rename_branch, revert_git_file, set_git_identity, update_git_identity,
generate_pr_description,
};
use commands::logs::fetch_desktop_logs;
use commands::github::{
github_auth_activate, github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
github_issue_comments, github_issue_get, github_issues_list,
github_pr_context, github_prs_list,
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
};
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")]
static NEEDS_TRAFFIC_LIGHT_FIX: AtomicBool = AtomicBool::new(false);
const PROXY_BODY_LIMIT: usize = 50 * 1024 * 1024; // 50MB
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<OpenCodeManager>,
settings: Arc<SettingsStore>,
}
impl DesktopRuntime {
fn initialize_sync() -> Result<Self> {
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<OpenCodeManager> {
self.opencode.clone()
}
}
#[derive(Clone)]
struct ServerState {
client: Client,
opencode: Arc<OpenCodeManager>,
settings: Arc<SettingsStore>,
server_port: u16,
directory_change_lock: Arc<Mutex<()>>,
models_metadata_cache: Arc<Mutex<ModelsMetadataCache>>,
}
#[derive(Default)]
struct ModelsMetadataCache {
payload: Option<Value>,
fetched_at: Option<Instant>,
}
#[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<opencode_config::CommandScope>,
is_built_in: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HealthResponse {
status: &'static str,
server_port: u16,
opencode_port: Option<u16>,
api_prefix: String,
is_opencode_ready: bool,
cli_available: bool,
}
#[derive(Serialize)]
struct ServerInfoPayload {
server_port: u16,
opencode_port: Option<u16>,
api_prefix: String,
cli_available: bool,
has_last_directory: bool,
}
#[tauri::command]
async fn desktop_server_info(
state: tauri::State<'_, DesktopRuntime>,
) -> Result<ServerInfoPayload, String> {
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<R: tauri::Runtime>(
window: &tauri::WebviewWindow<R>,
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 optimize_webview_layer<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) {
use objc2::msg_send;
use objc2::runtime::AnyObject;
if let Ok(ns_view) = window.ns_view() {
unsafe {
let view: *mut AnyObject = ns_view.cast();
if view.is_null() {
warn!("[macos:layer] NSView is null");
return;
}
// Enable layer-backing for GPU compositing
let _: () = msg_send![view, setWantsLayer: true];
// Get the layer
let layer: *mut AnyObject = msg_send![view, layer];
if !layer.is_null() {
// Enable asynchronous drawing for better scroll performance
let _: () = msg_send![layer, setDrawsAsynchronously: true];
// Disable implicit animations that can cause jitter
let _: () = msg_send![layer, setActions: std::ptr::null::<AnyObject>()];
info!("[macos:layer] WebView layer optimizations applied");
} else {
warn!("[macos:layer] Layer is null after setWantsLayer");
}
}
} else {
warn!("[macos:layer] Failed to get NSView");
}
}
#[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<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
) -> tauri::Result<tauri::menu::Menu<R>> {
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);
if macos_version < 26 {
NEEDS_TRAFFIC_LIGHT_FIX.store(true, Ordering::SeqCst);
adjust_traffic_lights_position(&window, 17.0, 16.0);
}
// Apply layer optimizations for smoother scrolling
optimize_webview_layer(&window);
}
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::<DesktopRuntime>().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<u16>,
api_prefix: String,
cli_available: bool,
}
let mut last_snapshot: Option<HealthSnapshot> = 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,
delete_path,
rename_path,
read_file,
read_file_binary,
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,
has_local_identity,
get_global_git_identity,
get_remote_url,
set_git_identity,
discover_git_credentials,
generate_commit_message,
generate_pr_description,
create_terminal_session,
send_terminal_input,
resize_terminal,
close_terminal,
restart_terminal_session,
force_kill_terminal,
fetch_desktop_logs,
desktop_notify,
github_auth_status,
github_auth_start,
github_auth_complete,
github_auth_disconnect,
github_auth_activate,
github_me,
github_pr_status,
github_pr_create,
github_pr_merge,
github_pr_ready,
github_prs_list,
github_pr_context,
github_issues_list,
github_issue_get,
github_issue_comments,
])
.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::<WindowStateManager>().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::<DesktopRuntime>().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<ServerState>) -> Json<HealthResponse> {
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<ServerState>,
) -> Result<Json<Value>, StatusCode> {
let now = Instant::now();
let cached_payload: Option<Value> = {
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::<Value>().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<T: Serialize>(status: StatusCode, payload: T) -> Response {
(status, Json(payload)).into_response()
}
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response {
json_response(
status,
ConfigErrorResponse {
error: message.into(),
},
)
}
async fn parse_request_payload(req: &mut Request) -> Result<HashMap<String, Value>, 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::<HashMap<String, Value>>(&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<String> {
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<PathBuf, Response> {
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<Option<PathBuf>, 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<String>,
) -> Result<PathBuf, Response> {
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<Response, StatusCode> {
// 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<opencode_config::Scope>,
#[serde(skip_serializing_if = "Option::is_none")]
source: Option<opencode_config::SkillSource>,
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<Response, StatusCode> {
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<String>,
) -> Result<Response, StatusCode> {
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<Response, StatusCode> {
// 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<Response, StatusCode> {
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::<skills_catalog::SkillsScanRequest>(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::<skills_catalog::SkillsInstallRequest>(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 source lookup: GET /api/provider/:providerId/source
if let Some(rest) = path.strip_prefix("/api/provider/") {
if let Some(provider_id) = rest.strip_suffix("/source") {
if method == Method::GET {
let trimmed = provider_id.trim();
if trimmed.is_empty() {
return Ok(config_error_response(
StatusCode::BAD_REQUEST,
"Provider ID is required",
));
}
let requested_directory = extract_directory_from_request(&req);
let working_directory = if let Some(ref value) = requested_directory {
match resolve_project_directory(&state, Some(value.to_string())).await {
Ok(directory) => Some(directory),
Err(resp) => return Ok(resp),
}
} else {
resolve_project_directory(&state, None).await.ok()
};
match opencode_config::get_provider_sources(trimmed, working_directory.as_deref())
.await
{
Ok(mut sources) => {
let auth = opencode_auth::get_provider_auth(trimmed).await;
sources.auth.exists = auth.ok().flatten().is_some();
return Ok(json_response(
StatusCode::OK,
serde_json::json!({
"providerId": trimmed,
"sources": sources
}),
));
}
Err(err) => {
error!(
"[desktop:config] Failed to get provider sources {}: {}",
trimmed, err
);
return Ok(config_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
));
}
}
}
}
}
// 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",
));
}
let scope = req
.uri()
.query()
.and_then(|query| {
query
.split('&')
.find(|pair| pair.starts_with("scope="))
.and_then(|pair| pair.split('=').nth(1))
})
.unwrap_or("auth");
let requested_directory = extract_directory_from_request(&req);
let working_directory = if scope == "project" {
match resolve_project_directory(&state, requested_directory.clone()).await {
Ok(directory) => Some(directory),
Err(resp) => return Ok(resp),
}
} else if let Some(ref value) = requested_directory {
match resolve_project_directory(&state, Some(value.to_string())).await {
Ok(directory) => Some(directory),
Err(resp) => return Ok(resp),
}
} else {
resolve_project_directory(&state, None).await.ok()
};
let removal_result = if scope == "auth" {
opencode_auth::remove_provider_auth(trimmed).await
} else if scope == "user" {
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::User,
)
.await
} else if scope == "project" {
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Project,
)
.await
} else if scope == "custom" {
opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Custom,
)
.await
} else if scope == "all" {
let auth_removed = opencode_auth::remove_provider_auth(trimmed)
.await
.unwrap_or(false);
let user_removed = opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::User,
)
.await
.unwrap_or(false);
let project_removed = if let Some(ref directory) = working_directory {
opencode_config::remove_provider_config(
trimmed,
Some(directory),
opencode_config::ProviderScope::Project,
)
.await
.unwrap_or(false)
} else {
false
};
let custom_removed = opencode_config::remove_provider_config(
trimmed,
working_directory.as_deref(),
opencode_config::ProviderScope::Custom,
)
.await
.unwrap_or(false);
Ok(auth_removed || user_removed || project_removed || custom_removed)
} else {
return Ok(config_error_response(
StatusCode::BAD_REQUEST,
"Invalid scope",
));
};
match removal_result {
Ok(removed) => {
if 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: removed,
message: if removed {
"Provider disconnected successfully".to_string()
} else {
"Provider was not connected".to_string()
},
reload_delay_ms: if removed { CLIENT_RELOAD_DELAY_MS } else { 0 },
},
));
}
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<ServerState>,
Json(payload): Json<DirectoryChangeRequest>,
) -> Result<Json<DirectoryChangeResponse>, 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<ServerState>,
req: Request,
) -> Result<Response, axum::http::StatusCode> {
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_provider_source_get = method == Method::GET
&& origin_path.starts_with("/api/provider/")
&& origin_path.ends_with("/source");
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
|| is_provider_source_get;
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<Mutex<()>>,
}
impl SettingsStore {
pub(crate) fn new() -> Result<Self> {
// 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<Value> {
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<R, F>(&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<F>(&self, f: F) -> Result<Value>
where
F: FnOnce(Value) -> Value,
{
let (next, _) = self.update_with(|current| (f(current), ())).await?;
Ok(next)
}
pub(crate) async fn last_directory(&self) -> Result<Option<PathBuf>> {
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)
}
}