feat: Tauri desktop shell — Rust backend + config files + build scripts
CI / lint (push) Successful in 30s
CI / security (push) Successful in 13s
CI / test (push) Successful in 27s
CI / build (push) Successful in 5s
CI / e2e (push) Failing after 13s

- desktop/Cargo.toml: Tauri + plugins (dialog, notification, fs, updater)
- desktop/tauri.conf.json: Windows (.msi/.nsis) + Linux (.deb/.AppImage) config
- desktop/src/main.rs: spawn Python backend, health check, tray icon, commands
- desktop/build.rs: Tauri build hook
- desktop/python-embed/sidecar.py: uvicorn launcher with port auto-detect
- build-windows.bat: automated Windows build with Python embed download
- build-linux.sh: automated Linux build with venv
- desktop/README.md: architecture doc + usage guide
- desktop/validate-structure.sh: CI validation script
- ROADMAP.md: #77 Tauri Desktop section added
This commit is contained in:
2026-07-24 18:01:33 -04:00
parent 8fd69b4b50
commit db72e6f055
9 changed files with 592 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
use tauri::Manager;
use std::process::{Child, Command};
use std::sync::Mutex;
use std::time::Duration;
// ── State ───────────────────────────────────────────────────────────────────
struct BackendProcess(Mutex<Option<Child>>);
// ── Sidecar : spawn Python backend ──────────────────────────────────────────
fn spawn_backend() -> Result<Child, String> {
let python = if cfg!(target_os = "windows") {
"./python-embed/python.exe"
} else {
"./python-embed/bin/python3"
};
let child = Command::new(python)
.args([
"-m", "uvicorn",
"backend.main:app",
"--host", "127.0.0.1",
"--port", "17890",
"--log-level", "info",
])
.spawn()
.map_err(|e| format!("Failed to start Python backend: {}", e))?;
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";
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);
return Ok(());
}
_ => {
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
Err("Backend did not become ready within 15 seconds".to_string())
}
// ── Tauri Commands ──────────────────────────────────────────────────────────
#[tauri::command]
fn get_backend_url() -> String {
"http://127.0.0.1:17890".to_string()
}
#[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) => Ok(p.to_string()),
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(),
}
}
}
// ── 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)));
// 2. Build Tauri 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())
.manage(backend_state)
.invoke_handler(tauri::generate_handler![
get_backend_url,
pick_vault_folder,
get_system_theme,
])
.setup(|app| {
// 3. Wait for backend health check (non-blocking)
let handle = app.handle().clone();
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") {
let _ = window.eval("window.location.href = 'http://127.0.0.1:17890'");
}
}
Err(e) => {
eprintln!("❌ Backend failed: {}", e);
if let Some(window) = handle.get_webview_window("main") {
let _ = window.eval(&format!(
"document.body.innerHTML = '<div style=\"padding:40px;text-align:center;font-family:sans-serif;color:red\"><h2>ObsiGate n\\'a pas pu démarrer</h2><p>{}</p><button onclick=\"location.reload()\">Réessayer</button></div>'",
e.replace('\'', "\\'")
));
}
}
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
// Kill the Python backend on close
let state = window.state::<BackendProcess>();
if let Ok(mut guard) = state.0.lock() {
if let Some(ref mut child) = *guard {
println!("🛑 Stopping backend...");
let _ = child.kill();
let _ = child.wait();
}
}
}
})
.run(tauri::generate_context!())
.expect("error while running ObsiGate Desktop");
}