Files
ObsiGate/desktop/src/main.rs
T
bruno 73d798015d
CI / lint (push) Successful in 24s
CI / security (push) Successful in 13s
CI / test (push) Successful in 29s
CI / build (push) Successful in 12s
CI / e2e (push) Failing after 14s
Desktop Build / build-windows (push) Has been cancelled
Desktop Build / build-linux (push) Has been cancelled
feat: config vaults desktop — VaultConfig, add_vault, remove_vault, list_vaults
- AppConfig: nouveau champ vaults: Vec<VaultConfig> (name + path)
- spawn_backend: passe VAULT_N_NAME / VAULT_N_PATH en variables d'env
- Commandes Tauri: add_vault, remove_vault, list_vaults
- Nécessite restart_backend() pour appliquer les changements
2026-07-26 17:32:32 -04:00

627 lines
21 KiB
Rust

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<Option<Child>>,
exe_dir: PathBuf,
restarting: Mutex<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct VaultConfig {
name: String,
path: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct AppConfig {
vault_path: Option<String>,
vaults: Vec<VaultConfig>,
window_x: Option<f64>,
window_y: Option<f64>,
window_width: Option<f64>,
window_height: Option<f64>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
vault_path: None,
vaults: Vec::new(),
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<PathBuf> {
#[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<Child, String> {
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);
// Pass vault config as env vars (VAULT_1_NAME, VAULT_1_PATH, etc.)
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);
}
let child = cmd
.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<Option<String>, String> {
Ok(load_config().vault_path)
}
#[tauri::command]
async fn pick_vault_folder(app: tauri::AppHandle) -> Result<String, String> {
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 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<VaultConfig> {
load_config().vaults
}
#[tauri::command]
fn restart_backend(state: tauri::State<BackendProcess>) -> Result<String, String> {
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<String, String> {
// 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,
add_vault,
remove_vault,
list_vaults,
])
.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 = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: http://127.0.0.1:17890 | Logs: {}</p></div>'"#,
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::<BackendProcess>();
// 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::<BackendProcess>();
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<dyn std::error::Error>> {
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(())
}