CI / lint (push) Successful in 1m11s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m22s
CI / build (push) Successful in 1m41s
CI / e2e (push) Successful in 10m50s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
- Signature des mises a jour Tauri : paire de cles minisign generee, cle publique dans tauri.conf.json, createUpdaterArtifacts actif, secrets CI exposes (repli build non signe si secret absent). - Wizard 1er lancement : etat persistant wizard_done cote Rust (get_wizard_state/complete_wizard) pour ne plus reafficher la banniere, retro-compatible. - Protocole des 6 tests E2E manuels : docs/DESKTOP_E2E_CHECKLIST.md. - CI : desktop.test.mjs ajoute au job lint. - Docs : ROADMAP, fiche desktop-tauri, CHANGELOG, README desktop, guide releases. - fix(build): retirer les libs WeasyPrint inutiles du stage builder Docker.
1126 lines
38 KiB
Rust
1126 lines
38 KiB
Rust
//! 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<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 DirConfig {
|
|
name: String,
|
|
path: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
struct AppConfig {
|
|
vault_path: Option<String>,
|
|
vaults: Vec<VaultConfig>,
|
|
dirs: Vec<DirConfig>,
|
|
window_x: Option<f64>,
|
|
window_y: Option<f64>,
|
|
window_width: Option<f64>,
|
|
window_height: Option<f64>,
|
|
/// 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<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);
|
|
|
|
// 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<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());
|
|
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<VaultConfig> {
|
|
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<DirConfig> {
|
|
load_config().dirs
|
|
}
|
|
|
|
#[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(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 = '<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: {} | Logs: {}</p></div>'"#,
|
|
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::<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");
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Native Menu Bar ──────────────────────────────────────────────────────────
|
|
|
|
fn build_native_menu(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|
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<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 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<AppConfig, _> = 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"));
|
|
}
|
|
}
|