//! ObsiGate Desktop — Native Tauri shell for ObsiGate web app. //! //! Spawns a Python FastAPI backend, then opens the frontend in a //! native webview. No terminal window on Windows. #![windows_subsystem = "windows"] mod jumplist; use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::net::TcpListener; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::Mutex; use std::time::Duration; // ── Backend port (ROADMAP #77 B: auto-increment if 17890 is busy) ──── const DEFAULT_BACKEND_PORT: u16 = 17890; const PORT_SCAN_ATTEMPTS: u16 = 10; static BACKEND_PORT: AtomicU16 = AtomicU16::new(DEFAULT_BACKEND_PORT); fn backend_url() -> String { format!("http://127.0.0.1:{}", BACKEND_PORT.load(Ordering::Relaxed)) } /// First free TCP port starting at DEFAULT_BACKEND_PORT. fn pick_free_port() -> u16 { for offset in 0..PORT_SCAN_ATTEMPTS { let port = DEFAULT_BACKEND_PORT + offset; if TcpListener::bind(("127.0.0.1", port)).is_ok() { if offset > 0 { info!("Port {} busy — using {} instead", DEFAULT_BACKEND_PORT, port); } return port; } } warn!("No free port in {}..{}, falling back to default", DEFAULT_BACKEND_PORT, DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS - 1); DEFAULT_BACKEND_PORT } #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use tauri::Manager; // ── State ─────────────────────────────────────────────────────────────────── struct BackendProcess { child: Mutex>, exe_dir: PathBuf, restarting: Mutex, } #[derive(Debug, Serialize, Deserialize, Clone)] struct VaultConfig { name: String, path: String, } #[derive(Debug, Serialize, Deserialize, Clone)] struct DirConfig { name: String, path: String, } #[derive(Debug, Serialize, Deserialize, Clone)] struct AppConfig { vault_path: Option, vaults: Vec, dirs: Vec, window_x: Option, window_y: Option, window_width: Option, window_height: Option, /// True once the user has picked a vault or dismissed the first-run wizard. /// `#[serde(default)]` keeps existing config files (written before this /// field existed) parseable instead of falling back to a full reset. #[serde(default)] wizard_done: bool, } impl Default for AppConfig { fn default() -> Self { Self { vault_path: None, vaults: Vec::new(), dirs: Vec::new(), window_x: None, window_y: None, window_width: Some(1200.0), window_height: Some(800.0), wizard_done: false, } } } // ── Helpers ───────────────────────────────────────────────────────────────── fn app_data_dir() -> PathBuf { app_data_dir_impl().unwrap_or_else(|| PathBuf::from(".")) } fn app_data_dir_impl() -> Option { #[cfg(target_os = "windows")] { std::env::var("APPDATA") .ok() .map(|p| PathBuf::from(p).join("ObsiGate")) } #[cfg(not(target_os = "windows"))] { std::env::var("XDG_CONFIG_HOME") .ok() .map(|p| PathBuf::from(p).join("obsigate")) .or_else(|| { std::env::var("HOME") .ok() .map(|p| PathBuf::from(p).join(".config").join("obsigate")) }) } } fn config_path() -> PathBuf { app_data_dir().join("config.json") } fn logs_dir() -> PathBuf { app_data_dir().join("logs") } fn backend_log_path() -> PathBuf { logs_dir().join("backend.log") } fn load_config() -> AppConfig { let path = config_path(); if path.exists() { fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } else { AppConfig::default() } } fn save_config(config: &AppConfig) { let path = config_path(); if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } if let Ok(json) = serde_json::to_string_pretty(config) { let _ = fs::write(&path, json); } } // ── Backend lifecycle ─────────────────────────────────────────────────────── fn spawn_backend(exe_dir: &PathBuf) -> Result { let python = if cfg!(target_os = "windows") { exe_dir.join("python-embed").join("python.exe") } else { exe_dir.join("python-embed").join("bin").join("python3") }; // Ensure log directory exists let log_file = backend_log_path(); if let Some(parent) = log_file.parent() { let _ = fs::create_dir_all(parent); } let log = OpenOptions::new() .create(true) .append(true) .open(&log_file) .map_err(|e| format!("Cannot open log file: {}", e))?; info!("Starting backend: {}", python.display()); // Use app data dir as CWD so 'data/' writes go to a writable location (%APPDATA%\ObsiGate) let data_dir = app_data_dir(); let _ = fs::create_dir_all(data_dir.join("data")); let mut cmd = Command::new(&python); cmd.current_dir(&data_dir) .env("PYTHONPATH", exe_dir); // Hide console window on Windows #[cfg(target_os = "windows")] { cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW } // Pass vault config as env vars (VAULT_N_NAME, VAULT_N_PATH, DIR_N_NAME, DIR_N_PATH) let config = load_config(); for (i, vault) in config.vaults.iter().enumerate() { let n = i + 1; cmd.env(format!("VAULT_{}_NAME", n), &vault.name); cmd.env(format!("VAULT_{}_PATH", n), &vault.path); } for (i, dir) in config.dirs.iter().enumerate() { let n = i + 1; cmd.env(format!("DIR_{}_NAME", n), &dir.name); cmd.env(format!("DIR_{}_PATH", n), &dir.path); } let port = pick_free_port(); BACKEND_PORT.store(port, Ordering::Relaxed); let child = cmd .args([ "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", &port.to_string(), "--log-level", "info", ]) .stdout(Stdio::from(log.try_clone().map_err(|e| format!("{}", e))?)) .stderr(Stdio::from(log)) .spawn() .map_err(|e| format!("Failed to start Python backend: {}", e))?; info!("Backend PID: {}", child.id()); Ok(child) } async fn wait_for_backend() -> Result<(), String> { let client = reqwest::Client::new(); let url = format!("{}/api/health", backend_url()); for i in 0..30 { match client.get(&url).timeout(Duration::from_secs(2)).send().await { Ok(resp) if resp.status().is_success() => { info!("Backend ready (attempt {})", i + 1); return Ok(()); } _ => { tokio::time::sleep(Duration::from_millis(500)).await; } } } Err("Backend did not become ready within 15 seconds".to_string()) } fn kill_backend(child: &mut Child) { info!("Stopping backend PID {}...", child.id()); #[cfg(target_os = "windows")] { let _ = child.kill(); } #[cfg(not(target_os = "windows"))] { // Send SIGTERM first, wait, then SIGKILL let pid = child.id(); let _ = Command::new("kill").arg("-TERM").arg(pid.to_string()).status(); for _ in 0..10 { match child.try_wait() { Ok(Some(_)) => break, _ => std::thread::sleep(Duration::from_millis(500)), } } let _ = child.kill(); } let _ = child.wait(); info!("Backend stopped"); } // ── Tauri Commands ────────────────────────────────────────────────────────── #[tauri::command] fn get_backend_url() -> String { backend_url() } #[tauri::command] fn get_version() -> String { env!("GIT_VERSION").to_string() } #[tauri::command] fn get_config() -> AppConfig { load_config() } #[tauri::command] fn save_vault_path(path: String) -> Result<(), String> { let mut config = load_config(); config.vault_path = Some(path); config.wizard_done = true; save_config(&config); info!("Vault path saved: {}", config.vault_path.as_deref().unwrap_or("none")); Ok(()) } #[tauri::command] fn get_vault_path() -> Result, String> { Ok(load_config().vault_path) } #[tauri::command] async fn pick_vault_folder(app: tauri::AppHandle) -> Result { use tauri_plugin_dialog::DialogExt; let path = app.dialog().file().blocking_pick_folder(); match path { Some(p) => { let p_str = p.to_string(); let mut config = load_config(); config.vault_path = Some(p_str.clone()); config.wizard_done = true; save_config(&config); Ok(p_str) } None => Err("No folder selected".to_string()), } } #[tauri::command] fn get_wizard_state() -> bool { load_config().wizard_done } #[tauri::command] fn complete_wizard() -> Result<(), String> { let mut config = load_config(); config.wizard_done = true; save_config(&config); info!("First-run wizard marked as completed"); Ok(()) } #[tauri::command] fn get_system_theme(app: tauri::AppHandle) -> String { use tauri::Theme; match app.get_webview_window("main").unwrap().theme() { Ok(Theme::Dark) => "dark".to_string(), _ => "light".to_string(), } } #[tauri::command] fn add_vault(name: String, path: String) -> Result<(), String> { let mut config = load_config(); // Avoid duplicates config.vaults.retain(|v| v.name != name); config.vaults.push(VaultConfig { name: name.clone(), path }); save_config(&config); info!("Vault added: {} — restart backend to apply", name); Ok(()) } #[tauri::command] fn remove_vault(name: String) -> Result<(), String> { let mut config = load_config(); config.vaults.retain(|v| v.name != name); save_config(&config); info!("Vault removed: {} — restart backend to apply", name); Ok(()) } #[tauri::command] fn list_vaults() -> Vec { load_config().vaults } #[tauri::command] fn add_dir(name: String, path: String) -> Result<(), String> { let mut config = load_config(); config.dirs.retain(|d| d.name != name); config.dirs.push(DirConfig { name: name.clone(), path }); save_config(&config); info!("Dir added: {} — restart backend to apply", name); Ok(()) } #[tauri::command] fn remove_dir(name: String) -> Result<(), String> { let mut config = load_config(); config.dirs.retain(|d| d.name != name); save_config(&config); info!("Dir removed: {} — restart backend to apply", name); Ok(()) } #[tauri::command] fn list_dirs() -> Vec { load_config().dirs } #[tauri::command] fn restart_backend(state: tauri::State) -> Result { let mut restarting = state.restarting.lock().map_err(|e| e.to_string())?; if *restarting { return Ok("already_restarting".to_string()); } *restarting = true; drop(restarting); info!("User requested backend restart"); // Kill existing let mut child_guard = state.child.lock().map_err(|e| e.to_string())?; if let Some(ref mut c) = *child_guard { kill_backend(c); } *child_guard = None; drop(child_guard); // Spawn new match spawn_backend(&state.exe_dir) { Ok(new_child) => { let mut guard = state.child.lock().map_err(|e| e.to_string())?; *guard = Some(new_child); drop(guard); // Fire and forget — the monitoring loop will detect when it's ready info!("Backend restart initiated"); Ok("restarting".to_string()) } Err(e) => { let mut r = state.restarting.lock().map_err(|e| e.to_string())?; *r = false; Err(format!("Failed to restart backend: {}", e)) } } } #[tauri::command] async fn check_backend_health(state: tauri::State<'_, BackendProcess>) -> Result { // Check if the backend is actually alive (drop guard before await) let is_alive = { let mut child_guard = state.child.lock().map_err(|e| e.to_string())?; match &mut *child_guard { Some(child) => matches!(child.try_wait(), Ok(None)), None => false, } }; if !is_alive { return Ok("dead".to_string()); } // Health check HTTP match reqwest::get(format!("{}/api/health", backend_url())).await { Ok(resp) if resp.status().is_success() => Ok("healthy".to_string()), _ => Ok("unhealthy".to_string()), } } #[tauri::command] fn save_window_state( _app: tauri::AppHandle, x: f64, y: f64, width: f64, height: f64, ) -> Result<(), String> { let mut config = load_config(); config.window_x = Some(x); config.window_y = Some(y); config.window_width = Some(width); config.window_height = Some(height); save_config(&config); Ok(()) } #[tauri::command] fn get_window_state() -> AppConfig { load_config() } // Build About message once at compile time const ABOUT_MSG: &str = concat!( "ObsiGate Desktop ", env!("GIT_VERSION"), "\\n\\nPorte d\\'entrée native pour vos vaults Obsidian.", "\\nConstruit avec Tauri + Rust.", "\\n\\n© Bruno Charest — Licence MIT", "\\nhttps://git.dracodev.net/Projets/ObsiGate" ); // ── Entry Point ───────────────────────────────────────────────────────────── fn main() { // Initialize logging let log_dir = logs_dir(); let _ = fs::create_dir_all(&log_dir); let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format(|buf, record| { writeln!( buf, "{} [{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), record.level(), record.args() ) }) .try_init(); info!("ObsiGate Desktop {} starting", env!("GIT_VERSION")); // Resolve exe directory let exe_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|p| p.to_path_buf())) .unwrap_or_else(|| PathBuf::from(".")); info!("Exe dir: {}", exe_dir.display()); // Load config for window state let mut config = load_config(); // First run: create sensible defaults per platform if config.vaults.is_empty() { info!("First run — creating default vault configuration"); #[cfg(target_os = "windows")] let home = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\Default".to_string()); #[cfg(not(target_os = "windows"))] let home = std::env::var("HOME").unwrap_or_else(|_| "/home/bruno".to_string()); let vault_dir = std::path::PathBuf::from(&home).join("voute_obsidian"); // Create the vault directory if it doesn't exist if !vault_dir.exists() { if let Err(e) = fs::create_dir_all(&vault_dir) { error!("Failed to create default vault dir {}: {}", vault_dir.display(), e); } else { info!("Created default vault dir: {}", vault_dir.display()); } } config.dirs = vec![ DirConfig { name: "bruno".to_string(), path: home.clone() }, ]; config.vaults = vec![ VaultConfig { name: "Obsidian".to_string(), path: vault_dir.to_string_lossy().to_string(), }, ]; config.vault_path = Some(home); save_config(&config); info!("Default vault config saved"); } // Spawn Python backend let backend = match spawn_backend(&exe_dir) { Ok(child) => { info!("Backend started PID {}", child.id()); child } Err(e) => { error!("Failed to start backend: {}", e); // We still launch the UI so the user sees the error page std::process::exit(1); } }; let backend_state = BackendProcess { child: Mutex::new(Some(backend)), exe_dir: exe_dir.clone(), restarting: Mutex::new(false), }; // Build Tauri app let app = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { // Focus existing window when second instance launched if let Some(window) = app.get_webview_window("main") { let _ = window.set_focus(); let _ = window.show(); } })) .manage(backend_state) .invoke_handler(tauri::generate_handler![ get_backend_url, get_version, get_config, save_vault_path, get_vault_path, get_wizard_state, complete_wizard, pick_vault_folder, get_system_theme, restart_backend, check_backend_health, save_window_state, get_window_state, add_vault, remove_vault, list_vaults, add_dir, remove_dir, list_dirs, ]) .setup(move |app| { // Build native menu bar (File / Edit / Help) let _ = build_native_menu(app); // Windows taskbar jump list — recent vaults (best-effort). let jumplist_vaults = jumplist::build_vault_args(&config.vaults); jumplist::update_jumplist(&jumplist_vaults); // Restore window position/size if let Some(window) = app.get_webview_window("main") { if let (Some(x), Some(y)) = (config.window_x, config.window_y) { let _ = window.set_position(tauri::PhysicalPosition::new(x, y)); } if let (Some(w), Some(h)) = (config.window_width, config.window_height) { let _ = window.set_size(tauri::PhysicalSize::new(w, h)); } } // Build tray menu let _ = build_tray_menu(app); // Wait for backend health check (non-blocking) let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { match wait_for_backend().await { Ok(()) => { if let Some(window) = handle.get_webview_window("main") { // Met à jour le splash inline avant la redirection let _ = window.eval( r#"if (window.__setBootStatus) window.__setBootStatus("Backend prêt — chargement de l'interface…")"#, ); match window.eval(&format!("window.location.href = '{}'", backend_url())) { Ok(_) => info!("Redirect to backend OK"), Err(e) => error!("Redirect eval failed: {}", e), } } } Err(e) => { error!("Backend failed: {}", e); if let Some(window) = handle.get_webview_window("main") { let _ = window.eval(&format!( r#"document.body.innerHTML = '

ObsiGate n\'a pas pu démarrer

{}

Backend: {} | Logs: {}

'"#, e.replace('\'', "\\'").replace('"', "\\\""), backend_url(), backend_log_path().display().to_string().replace('\\', "\\\\"), )); } } } }); // Background monitor: detect backend crash let handle2 = app.handle().clone(); tauri::async_runtime::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(5)).await; let state = handle2.state::(); // Drop guard before any await let died = { let mut child_guard = match state.child.lock() { Ok(g) => g, Err(_) => break, }; match &mut *child_guard { Some(child) => child.try_wait().ok().flatten().is_some(), None => true, } }; if died { warn!("Backend process died unexpectedly!"); if let Some(window) = handle2.get_webview_window("main") { let _ = window.eval( r#"if (typeof showBackendCrashBanner === 'function') showBackendCrashBanner();"#, ); } } // If restarting, check if new backend is ready let restarting = state.restarting.lock().ok().map(|r| *r).unwrap_or(false); if restarting { if wait_for_backend().await.is_ok() { let _ = state.restarting.lock().map(|mut r| *r = false); if let Some(window) = handle2.get_webview_window("main") { let _ = window.eval("window.location.reload()"); } } } } }); Ok(()) }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { .. } = event { // Save window state before closing if let Ok(pos) = window.outer_position() { if let Ok(size) = window.outer_size() { let mut config = load_config(); config.window_x = Some(pos.x as f64); config.window_y = Some(pos.y as f64); config.window_width = Some(size.width as f64); config.window_height = Some(size.height as f64); save_config(&config); } } // Kill backend let state = window.state::(); if let Ok(mut guard) = state.child.lock() { if let Some(ref mut child) = *guard { kill_backend(child); } }; } }) .build(tauri::generate_context!()) .expect("error while running ObsiGate Desktop"); app.run(|_app_handle, event| { if let tauri::RunEvent::Exit = event { info!("ObsiGate Desktop exiting"); } }); } // ── Native Menu Bar ────────────────────────────────────────────────────────── fn build_native_menu(app: &tauri::App) -> Result<(), Box> { use tauri::menu::{ MenuBuilder, MenuItemBuilder, SubmenuBuilder, PredefinedMenuItem, }; // File menu let new_window = MenuItemBuilder::with_id("file_new_window", "Nouvelle fenêtre") .accelerator("CmdOrCtrl+N") .build(app)?; let close_window = MenuItemBuilder::with_id("file_close", "Fermer") .accelerator("CmdOrCtrl+W") .build(app)?; let file_sep = PredefinedMenuItem::separator(app)?; let quit = MenuItemBuilder::with_id("file_quit", "Quitter") .accelerator("CmdOrCtrl+Q") .build(app)?; let file_menu = SubmenuBuilder::new(app, "Fichier") .item(&new_window) .item(&close_window) .item(&file_sep) .item(&quit) .build()?; // Edit menu let undo = MenuItemBuilder::with_id("edit_undo", "Annuler") .accelerator("CmdOrCtrl+Z") .build(app)?; let redo = MenuItemBuilder::with_id("edit_redo", "Rétablir") .accelerator("CmdOrCtrl+Shift+Z") .build(app)?; let edit_sep = PredefinedMenuItem::separator(app)?; let cut = MenuItemBuilder::with_id("edit_cut", "Couper") .accelerator("CmdOrCtrl+X") .build(app)?; let copy = MenuItemBuilder::with_id("edit_copy", "Copier") .accelerator("CmdOrCtrl+C") .build(app)?; let paste = MenuItemBuilder::with_id("edit_paste", "Coller") .accelerator("CmdOrCtrl+V") .build(app)?; let select_all = MenuItemBuilder::with_id("edit_select_all", "Tout sélectionner") .accelerator("CmdOrCtrl+A") .build(app)?; let edit_menu = SubmenuBuilder::new(app, "Édition") .item(&undo) .item(&redo) .item(&edit_sep) .item(&cut) .item(©) .item(&paste) .item(&edit_sep.clone()) .item(&select_all) .build()?; // Help menu let about = MenuItemBuilder::with_id("help_about", "À propos d'ObsiGate") .build(app)?; let help_menu = SubmenuBuilder::new(app, "Aide") .item(&about) .build()?; let menu = MenuBuilder::new(app) .item(&file_menu) .item(&edit_menu) .item(&help_menu) .build()?; app.set_menu(menu)?; // Handle menu events app.on_menu_event(move |app, event| { match event.id().as_ref() { "file_quit" => { info!("Quit from menu"); app.exit(0); } "file_close" => { if let Some(window) = app.get_webview_window("main") { let _ = window.close(); } } "help_about" => { if let Some(window) = app.get_webview_window("main") { let js = format!("alert('{}')", ABOUT_MSG); let _ = window.eval(&js); } } "edit_undo" | "edit_redo" | "edit_cut" | "edit_copy" | "edit_paste" | "edit_select_all" => { // Let the webview handle these natively } _ => {} } }); Ok(()) } // ── Tray Menu ─────────────────────────────────────────────────────────────── fn build_tray_menu(app: &tauri::App) -> Result<(), Box> { use tauri::{ menu::{MenuBuilder, MenuItemBuilder}, tray::TrayIconBuilder, }; let quit = MenuItemBuilder::with_id("quit", "Quitter") .accelerator("CmdOrCtrl+Q") .build(app)?; let open = MenuItemBuilder::with_id("open", "Ouvrir ObsiGate") .build(app)?; let about = MenuItemBuilder::with_id("about", "À propos") .build(app)?; let separator = tauri::menu::PredefinedMenuItem::separator(app)?; let menu = MenuBuilder::new(app) .item(&open) .item(&separator) .item(&about) .item(&quit) .build()?; let _tray = TrayIconBuilder::new() .icon(app.default_window_icon().cloned().unwrap()) .tooltip("ObsiGate") .menu(&menu) .on_menu_event(move |app, event| { match event.id().as_ref() { "open" => { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } "quit" => { info!("Quit from tray menu"); app.exit(0); } "about" => { if let Some(window) = app.get_webview_window("main") { let js = format!("alert('{}')", ABOUT_MSG); let _ = window.eval(&js); } } _ => {} } }) .on_tray_icon_event(|tray, event| { if let tauri::tray::TrayIconEvent::Click { button, .. } = event { if button == tauri::tray::MouseButton::Left { // Left click: toggle window visibility let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { if window.is_visible().unwrap_or(false) { let _ = window.hide(); } else { let _ = window.show(); let _ = window.set_focus(); } } } } }) .build(app)?; Ok(()) } // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use std::fs; use std::sync::Mutex; use tempfile::TempDir; /// Serializes the `pick_free_port` tests: they bind real TCP ports, so /// running them in parallel makes them clobber each other's port. static PORT_TEST_LOCK: Mutex<()> = Mutex::new(()); /// Helper: write config to a temp dir and read it back fn roundtrip_config(config: &AppConfig) -> AppConfig { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("config.json"); let json = serde_json::to_string_pretty(config).unwrap(); fs::write(&path, &json).unwrap(); let raw = fs::read_to_string(&path).unwrap(); serde_json::from_str(&raw).unwrap() } #[test] fn test_default_config() { let c = AppConfig::default(); assert!(c.vault_path.is_none()); assert!(c.vaults.is_empty()); assert!(c.dirs.is_empty()); assert_eq!(c.window_width, Some(1200.0)); assert_eq!(c.window_height, Some(800.0)); assert!(!c.wizard_done); } #[test] fn test_config_roundtrip() { let c = AppConfig { vault_path: Some("/test/vault".into()), vaults: vec![ VaultConfig { name: "Personal".into(), path: "/v/personal".into() }, VaultConfig { name: "Work".into(), path: "/v/work".into() }, ], dirs: vec![DirConfig { name: "home".into(), path: "/home".into() }], window_x: Some(100.0), window_y: Some(200.0), window_width: Some(1400.0), window_height: Some(900.0), wizard_done: true, }; let loaded = roundtrip_config(&c); assert_eq!(loaded.vault_path, Some("/test/vault".into())); assert_eq!(loaded.vaults.len(), 2); assert_eq!(loaded.vaults[0].name, "Personal"); assert_eq!(loaded.vaults[1].path, "/v/work"); assert_eq!(loaded.dirs.len(), 1); assert_eq!(loaded.window_x, Some(100.0)); assert!(loaded.wizard_done); } #[test] fn test_config_without_wizard_done_parses() { // Backward compatibility: config files written before `wizard_done` // existed must still load (instead of resetting to defaults). let json = r#"{ "vault_path": "/old/vault", "vaults": [{"name": "Old", "path": "/old"}], "dirs": [], "window_x": null, "window_y": null, "window_width": 1000.0, "window_height": 700.0 }"#; let c: AppConfig = serde_json::from_str(json).unwrap(); assert_eq!(c.vaults.len(), 1); assert_eq!(c.vaults[0].name, "Old"); assert!(!c.wizard_done); } #[test] fn test_wizard_done_roundtrip() { let mut c = AppConfig::default(); c.wizard_done = true; assert!(roundtrip_config(&c).wizard_done); } #[test] fn test_config_empty_json() { // serde requires vaults/dirs fields — missing fields use unwrap_or_default let c: AppConfig = serde_json::from_str("{}").unwrap_or_default(); assert!(c.vault_path.is_none()); assert!(c.vaults.is_empty()); } #[test] fn test_config_partial_json() { // serde requires all fields — partial JSON uses unwrap_or_default let c: AppConfig = serde_json::from_str( r#"{"vaults":[{"name":"t","path":"/t"}]}"# ).unwrap_or_default(); // Will fall to default since dirs is missing assert!(c.vaults.is_empty() || c.vaults.len() == 1); } #[test] fn test_config_corrupted_json_fallback() { let result: Result = serde_json::from_str("not json"); assert!(result.is_err()); // Caller should use unwrap_or_default() let c = result.unwrap_or_default(); assert!(c.vaults.is_empty()); } #[test] fn test_vault_dedup() { let mut c = AppConfig::default(); c.vaults.retain(|v| v.name != "test"); c.vaults.push(VaultConfig { name: "test".into(), path: "/first".into() }); c.vaults.retain(|v| v.name != "test"); c.vaults.push(VaultConfig { name: "test".into(), path: "/second".into() }); assert_eq!(c.vaults.len(), 1); assert_eq!(c.vaults[0].path, "/second"); } #[test] fn test_dir_remove() { let mut c = AppConfig::default(); c.dirs.push(DirConfig { name: "a".into(), path: "/a".into() }); c.dirs.push(DirConfig { name: "b".into(), path: "/b".into() }); c.dirs.retain(|d| d.name != "a"); assert_eq!(c.dirs.len(), 1); assert_eq!(c.dirs[0].name, "b"); } #[test] fn test_backend_url() { let port = BACKEND_PORT.load(Ordering::Relaxed); assert_eq!(get_backend_url(), format!("http://127.0.0.1:{}", port)); } #[test] fn test_pick_free_port_is_free() { let _guard = PORT_TEST_LOCK.lock().unwrap(); let port = pick_free_port(); assert!((DEFAULT_BACKEND_PORT..DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS).contains(&port)); assert!(TcpListener::bind(("127.0.0.1", port)).is_ok()); } #[test] fn test_pick_free_port_skips_busy_port() { let _guard = PORT_TEST_LOCK.lock().unwrap(); let blocker = match TcpListener::bind(("127.0.0.1", DEFAULT_BACKEND_PORT)) { Ok(l) => l, Err(_) => return, // default port already busy on this machine — skip }; let port = pick_free_port(); assert_ne!(port, DEFAULT_BACKEND_PORT); assert!(port < DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS); drop(blocker); } #[test] fn test_backend_url_reflects_port() { let saved = BACKEND_PORT.load(Ordering::Relaxed); BACKEND_PORT.store(17893, Ordering::Relaxed); assert_eq!(backend_url(), "http://127.0.0.1:17893"); BACKEND_PORT.store(saved, Ordering::Relaxed); } #[test] fn test_about_msg_contains_branding() { assert!(ABOUT_MSG.contains("ObsiGate Desktop")); assert!(ABOUT_MSG.contains("Bruno Charest")); assert!(ABOUT_MSG.contains("Tauri")); } #[test] fn test_app_data_dir_nonempty() { let dir = app_data_dir(); assert!(!dir.to_string_lossy().is_empty()); } #[test] fn test_logs_dir_under_app_data() { let logs = logs_dir(); assert!(logs.starts_with(app_data_dir())); assert_eq!(logs.file_name().unwrap(), "logs"); } #[test] fn test_backend_log_path() { let p = backend_log_path(); assert_eq!(p.file_name().unwrap(), "backend.log"); } #[test] fn test_json_format_keys() { let c = AppConfig::default(); let json = serde_json::to_string(&c).unwrap(); assert!(json.contains("\"vaults\"")); assert!(json.contains("\"dirs\"")); assert!(json.contains("\"window_width\"")); } #[test] fn test_backend_health_check_url() { // Verify the health check targets the correct endpoint let url = "http://127.0.0.1:17890/api/health"; assert!(url.contains("17890")); assert!(url.contains("/api/health")); } #[test] fn test_vault_config_clone() { let v = VaultConfig { name: "test".into(), path: "/t".into() }; let v2 = v.clone(); assert_eq!(v.name, v2.name); assert_eq!(v.path, v2.path); } #[test] fn test_dir_config_debug() { let d = DirConfig { name: "x".into(), path: "/x".into() }; let dbg = format!("{:?}", d); assert!(dbg.contains("x")); } }