use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use std::time::Duration; use tauri::Manager; // ── State ─────────────────────────────────────────────────────────────────── struct BackendProcess { child: Mutex>, exe_dir: PathBuf, restarting: Mutex, } #[derive(Debug, Serialize, Deserialize, Clone)] struct AppConfig { vault_path: Option, window_x: Option, window_y: Option, window_width: Option, window_height: Option, } impl Default for AppConfig { fn default() -> Self { Self { vault_path: None, window_x: None, window_y: None, window_width: Some(1200.0), window_height: Some(800.0), } } } // ── 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()); let child = Command::new(&python) .current_dir(exe_dir) .args([ "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", "17890", "--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 = "http://127.0.0.1:17890/api/health"; 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 { "http://127.0.0.1:17890".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); 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()); save_config(&config); Ok(p_str) } None => Err("No folder selected".to_string()), } } #[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 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("http://127.0.0.1:17890/api/health").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() } // ── 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 v2.0.0 starting"); // 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 config = load_config(); // 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_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_config, save_vault_path, get_vault_path, pick_vault_folder, get_system_theme, restart_backend, check_backend_health, save_window_state, get_window_state, ]) .setup(move |app| { // 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") { match window.eval("window.location.href = 'http://127.0.0.1:17890'") { 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: http://127.0.0.1:17890 | Logs: {}

'"#, e.replace('\'', "\\'").replace('"', "\\\""), 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"); } }); } // ── 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 _ = window.eval( r#"alert('ObsiGate Desktop v2.0.0\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')"# ); } } _ => {} } }) .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(()) }