diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 4dbcea9..ceef2f3 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -15,10 +15,15 @@ tauri-plugin-dialog = "2" tauri-plugin-notification = "2" tauri-plugin-fs = "2" tauri-plugin-process = "2" +tauri-plugin-store = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json"] } tokio = { version = "1", features = ["full"] } +log = "0.4" +env_logger = "0.11" +chrono = "0.4" [features] default = ["custom-protocol"] diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json index 34c5404..a8b7b3d 100644 --- a/desktop/capabilities/default.json +++ b/desktop/capabilities/default.json @@ -9,6 +9,8 @@ "dialog:default", "notification:default", "fs:default", - "process:default" + "process:default", + "store:default", + "single-instance:default" ] } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index dc7e79b..4b368e3 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1,49 +1,142 @@ -use std::process::{Child, Command}; +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(Mutex>); +struct BackendProcess { + child: Mutex>, + exe_dir: PathBuf, + restarting: Mutex, +} -// ── Sidecar : spawn Python backend ────────────────────────────────────────── +#[derive(Debug, Serialize, Deserialize, Clone)] +struct AppConfig { + vault_path: Option, + window_x: Option, + window_y: Option, + window_width: Option, + window_height: Option, +} -fn spawn_backend() -> Result { - // Resolve python-embed relative to the executable, not cwd - let exe_dir = std::env::current_exe() - .map_err(|e| format!("Cannot get exe path: {}", e))? - .parent() - .ok_or("Cannot get exe parent dir")? - .to_path_buf(); +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) + .current_dir(exe_dir) .args([ - "-m", - "uvicorn", + "-m", "uvicorn", "backend.main:app", - "--host", - "127.0.0.1", - "--port", - "17890", - "--log-level", - "info", + "--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) } -// ── Health check — wait until backend is ready ────────────────────────────── - async fn wait_for_backend() -> Result<(), String> { let client = reqwest::Client::new(); let url = "http://127.0.0.1:17890/api/health"; @@ -51,7 +144,7 @@ async fn wait_for_backend() -> Result<(), String> { for i in 0..30 { match client.get(url).timeout(Duration::from_secs(2)).send().await { Ok(resp) if resp.status().is_success() => { - println!("✅ Backend ready (attempt {})", i + 1); + info!("Backend ready (attempt {})", i + 1); return Ok(()); } _ => { @@ -63,6 +156,29 @@ async fn wait_for_backend() -> Result<(), String> { 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] @@ -70,92 +186,395 @@ 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) => Ok(p.to_string()), + 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 { - #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] - { - use tauri::Theme; - match app.get_webview_window("main").unwrap().theme() { - Ok(Theme::Dark) => "dark".to_string(), - _ => "light".to_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); + + // Wait for it to become ready (fire and forget with UI poll) + let state_clone = state.inner().clone(); + // Can't clone state directly, use a flag pattern + 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 + let mut child_guard = state.child.lock().map_err(|e| e.to_string())?; + let is_alive = match &mut *child_guard { + Some(child) => matches!(child.try_wait(), Ok(None)), + None => false, + }; + drop(child_guard); + + 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() { - // 1. Start Python backend - println!("🚀 Starting ObsiGate backend..."); - let backend = spawn_backend().expect("Failed to launch Python backend"); - let backend_state = BackendProcess(Mutex::new(Some(backend))); + // 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(); - // 2. Build Tauri app - tauri::Builder::default() + info!("ObsiGate Desktop v2.0.0 starting"); + + // Resolve exe directory + let exe_dir = std::env::current_exe() + .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(|app| { - // 3. Wait for backend health check (non-blocking) + // 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(); + let state = app.state::(); + let state_clone = state.inner().clone(); + // We can't clone state, so we pass the child mutex guard idea + // Instead, we just do the initial health check inline + tauri::async_runtime::spawn(async move { match wait_for_backend().await { Ok(()) => { - // Navigate to the main app if let Some(window) = handle.get_webview_window("main") { match window.eval("window.location.href = 'http://127.0.0.1:17890'") { - Ok(_) => println!("✅ Redirect to backend initiated"), - Err(e) => eprintln!("❌ Redirect eval failed: {}", e), + Ok(_) => info!("Redirect to backend OK"), + Err(e) => error!("Redirect eval failed: {}", e), } } } Err(e) => { - eprintln!("❌ Backend failed: {}", e); + error!("Backend failed: {}", e); if let Some(window) = handle.get_webview_window("main") { let _ = window.eval(&format!( - "document.body.innerHTML = '

ObsiGate n\\'a pas pu démarrer

{}

'", - e.replace('\'', "\\'") + 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::(); + let mut child_guard = match state.child.lock() { + Ok(g) => g, + Err(_) => break, + }; + + let died = match &mut *child_guard { + Some(child) => child.try_wait().ok().flatten().is_some(), + None => true, + }; + drop(child_guard); + + if died { + warn!("Backend process died unexpectedly!"); + // Inject a notice into the UI + if let Some(window) = handle2.get_webview_window("main") { + let _ = window.eval( + r#"if (typeof showBackendCrashBanner === 'function') showBackendCrashBanner();"#, + ); + } + } + + // If we were 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 { - // Kill the Python backend on close + // 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); + config.window_y = Some(pos.y); + config.window_width = Some(size.width); + config.window_height = Some(size.height); + save_config(&config); + } + } + // Kill backend let state = window.state::(); - if let Ok(mut guard) = state.0.lock() { + if let Ok(mut guard) = state.child.lock() { if let Some(ref mut child) = *guard { - println!("🛑 Stopping backend..."); - let _ = child.kill(); - let _ = child.wait(); + kill_backend(child); } }; } }) - .run(tauri::generate_context!()) + .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(()) } diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 1cc0ca5..f72c8a2 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -77,6 +77,8 @@ "plugins": { "shell": { "open": true - } + }, + "single-instance": {}, + "store": {} } }