feat: Tauri desktop shell — Rust backend + config files + build scripts
- 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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user