From b680c0fff7980a8c917ee74e98f272ef3d365f4b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 22 Jun 2026 16:57:41 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=207=20=E2=80=94=20CLI=20complet?= =?UTF-8?q?=20(start/serve/stop/status/config/logs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gestion PID (write/read/remove + verification processus) - cmd_start: lance watcher + dashboard avec Ctrl+C graceful shutdown - cmd_serve: dashboard seul (Linux) avec --no-watcher - cmd_stop: kill processus via SIGTERM/OpenProcess + nettoyage PID - cmd_status: affiche PID, dashboard URL, config, polling - cmd_config show/set/reset: gestion dotted keys (imago.url, watcher.*, etc.) - cmd_logs: affiche derniers logs - Fix Default derives: ImagoConfig, DedupConfig, UiConfig, LoggingConfig - Ajout dependances: open, libc (Unix) - Watcher::run(self) au lieu de &self pour Send - tests: start → API /api/health OK → stop → status OK - 0 errors, 0 warnings (cargo check + cargo build --release) --- Cargo.lock | 40 +++- Cargo.toml | 4 + src/config.rs | 33 ++- src/imago_client.rs | 2 +- src/main.rs | 491 ++++++++++++++++++++++++++++++++++++++++++-- src/watcher.rs | 2 +- 6 files changed, 544 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f179edc..e50247e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1196,7 +1196,9 @@ dependencies = [ "arboard", "axum", "clap", + "libc", "notify", + "open", "reqwest", "rusqlite", "rust-embed", @@ -1259,6 +1261,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1755,6 +1776,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1809,6 +1841,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1988,7 +2026,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a0b29b0..ca74300 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } # Error handling anyhow = "1" thiserror = "2" +# CLI utilities +open = "5" # Web server (axum + tower) axum = { version = "0.8", features = ["ws"] } @@ -57,6 +59,8 @@ windows-sys = { version = "0.59", features = [ [target.'cfg(unix)'.dependencies] # Notify pour watcher filesystem (Linux) notify = { version = "7", features = ["macos_kqueue"] } +# Signal handling (PID file, process checks) +libc = "0.2" [profile.release] opt-level = "z" # Optimize for size diff --git a/src/config.rs b/src/config.rs index b3cac36..27e6f53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,7 +72,7 @@ fn default_exclude_apps() -> Vec { vec!["mstsc".into()] } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct DedupConfig { #[serde(default = "default_dedup_method")] pub method: String, @@ -80,6 +80,15 @@ pub struct DedupConfig { pub cache_size: usize, } +impl Default for DedupConfig { + fn default() -> Self { + Self { + method: default_dedup_method(), + cache_size: default_cache_size(), + } + } +} + fn default_dedup_method() -> String { "sha256".into() } @@ -88,7 +97,7 @@ fn default_cache_size() -> usize { 10000 } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiConfig { #[serde(default = "default_true")] pub show_notifications: bool, @@ -98,16 +107,34 @@ pub struct UiConfig { pub notification_timeout_sec: u64, } +impl Default for UiConfig { + fn default() -> Self { + Self { + show_notifications: true, + show_tray_icon: true, + notification_timeout_sec: default_notification_timeout(), + } + } +} + fn default_notification_timeout() -> u64 { 3 } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoggingConfig { #[serde(default = "default_log_level")] pub level: String, } +impl Default for LoggingConfig { + fn default() -> Self { + Self { + level: default_log_level(), + } + } +} + fn default_log_level() -> String { "info".into() } diff --git a/src/imago_client.rs b/src/imago_client.rs index 494ea5b..ad93604 100644 --- a/src/imago_client.rs +++ b/src/imago_client.rs @@ -1,6 +1,6 @@ //! Client HTTP pour l'API Imago -use anyhow::{Context, Result}; +use anyhow::Result; use reqwest::{Client, multipart}; use serde::Deserialize; diff --git a/src/main.rs b/src/main.rs index 3453ae6..8411299 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,10 +5,11 @@ //! - `serve` : Lance le dashboard web uniquement (Linux/serveur) //! - `stop` : Arrête le service //! - `status`: Affiche l'état -//! - `config`: Gère la configuration +//! - `config show/set/reset`: Gère la configuration //! - `install` / `uninstall`: Service Windows +//! - `logs`: Affiche les derniers logs -#![allow(unused_imports, dead_code, unreachable_code)] +#![allow(dead_code, unreachable_code)] mod cli; mod config; @@ -27,9 +28,13 @@ mod window_info; mod server; -use anyhow::Result; +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process; use tracing_subscriber::{fmt, EnvFilter}; +// ─── Logging ─────────────────────────────────────────────────────────────── + fn setup_logging() { let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info")); @@ -40,38 +45,480 @@ fn setup_logging() { .init(); } +// ─── PID File ────────────────────────────────────────────────────────────── + +/// Écrit le PID du processus courant dans le fichier PID +fn write_pid() -> Result<()> { + let pid_path = pid_path()?; + if let Some(parent) = pid_path.parent() { + std::fs::create_dir_all(parent)?; + } + let pid = std::process::id(); + std::fs::write(&pid_path, pid.to_string())?; + tracing::info!("PID {} écrit dans {}", pid, pid_path.display()); + Ok(()) +} + +/// Supprime le fichier PID +fn remove_pid() -> Result<()> { + let pid_path = pid_path()?; + if pid_path.exists() { + std::fs::remove_file(&pid_path)?; + tracing::info!("Fichier PID supprimé : {}", pid_path.display()); + } + Ok(()) +} + +/// Lit le PID depuis le fichier PID +fn read_pid() -> Result { + let pid_path = pid_path()?; + let content = std::fs::read_to_string(&pid_path) + .with_context(|| format!("Aucun fichier PID trouvé à {}. imagod-web n'est probablement pas lancé.", pid_path.display()))?; + let pid: u32 = content + .trim() + .parse() + .with_context(|| format!("PID invalide dans {}", pid_path.display()))?; + Ok(pid) +} + +/// Vérifie si un processus avec ce PID existe +fn process_exists(pid: u32) -> bool { + // Sur Unix, envoyer le signal 0 vérifie l'existence + #[cfg(unix)] + unsafe { + libc::kill(pid as i32, 0) == 0 + } + #[cfg(not(unix))] + { + // Windows : on utilise OpenProcess + use std::ffi::c_void; + extern "system" { + fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn CloseHandle(handle: *mut c_void) -> i32; + } + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + false + } else { + CloseHandle(handle); + true + } + } + } +} + +/// Chemin du fichier PID +fn pid_path() -> Result { + let base = if cfg!(target_os = "windows") { + let appdata = std::env::var("APPDATA").context("Variable APPDATA non définie")?; + PathBuf::from(appdata) + } else { + let home = std::env::var("HOME").context("Variable HOME non définie")?; + PathBuf::from(home).join(".config") + }; + Ok(base.join("imagod-web").join("imagod.pid")) +} + +/// Vérifie si le serveur est en cours d'exécution +fn is_running() -> Option { + read_pid().ok().filter(|&pid| process_exists(pid)) +} + +fn data_dir() -> Result { + let base = if cfg!(target_os = "windows") { + let appdata = std::env::var("APPDATA").context("APPDATA")?; + PathBuf::from(appdata) + } else { + let home = std::env::var("HOME").context("HOME")?; + PathBuf::from(home).join(".config") + }; + Ok(base.join("imagod-web")) +} + +// ─── Command Handlers ────────────────────────────────────────────────────── + +/// `start` — Lance le watcher + dashboard web +async fn cmd_start(port: u16, open_browser: bool) -> Result<()> { + // Vérifier qu'il n'y a pas déjà une instance + if let Some(pid) = is_running() { + anyhow::bail!( + "imagod-web est déjà en cours d'exécution (PID {}).\n\ + Utilisez `imagod-web stop` pour l'arrêter, ou `imagod-web status` pour voir son état.", + pid + ); + } + + // Charger la configuration + let cfg = config::Config::load()?; + + // Écrire le PID + write_pid()?; + + tracing::info!( + "🚀 imagod-web v{} — Démarrage watcher + dashboard", + env!("CARGO_PKG_VERSION") + ); + tracing::info!(" Dashboard : http://127.0.0.1:{}", port); + tracing::info!(" Polling : {}ms", cfg.watcher.poll_interval_ms); + tracing::info!(" Imago API : {}", cfg.imago.url); + + // Lancer le watcher dans une tâche séparée + let _watcher_task = { + let cfg = cfg.clone(); + tokio::spawn(async move { + match watcher::Watcher::new(cfg).await { + Ok(w) => { + if let Err(e) = w.run().await { + tracing::error!("Erreur watcher : {:#}", e); + } + } + Err(e) => tracing::error!("Échec création watcher : {:#}", e), + } + }) + }; + + // Ouvrir le navigateur si demandé + if open_browser { + if let Err(e) = open::that(format!("http://127.0.0.1:{}", port)) { + tracing::warn!("Impossible d'ouvrir le navigateur : {}", e); + } + } + + // Lancer le serveur web (bloquant, graceful shutdown sur Ctrl+C) + let server_task = tokio::spawn(server::serve("127.0.0.1", port)); + + // Attendre Ctrl+C + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("⏹️ Ctrl+C reçu — arrêt propre..."); + } + res = server_task => { + if let Err(e) = res { + tracing::error!("Erreur serveur : {}", e); + } + } + } + + // Nettoyage + remove_pid()?; + tracing::info!("✅ imagod-web arrêté."); + + Ok(()) +} + +/// `serve` — Lance le dashboard web uniquement +async fn cmd_serve(host: &str, port: u16, no_watcher: bool, open_browser: bool) -> Result<()> { + // Vérifier qu'il n'y a pas déjà une instance + if let Some(pid) = is_running() { + anyhow::bail!( + "imagod-web est déjà en cours d'exécution (PID {}).\n\ + Utilisez `imagod-web stop` pour l'arrêter.", + pid + ); + } + + let cfg = config::Config::load()?; + + // Écrire le PID + write_pid()?; + + tracing::info!( + "🚀 imagod-web v{} — Dashboard web uniquement", + env!("CARGO_PKG_VERSION") + ); + tracing::info!(" Dashboard : http://{}:{}", host, port); + + // Optionnellement lancer le watcher + let _watcher_task = if !no_watcher { + // Sur Linux, le clipboard n'est pas supporté — avertir mais continuer + if !cfg!(target_os = "windows") { + tracing::warn!("⚠️ Le watcher clipboard n'est disponible que sur Windows."); + tracing::warn!(" Utilisez --no-watcher pour désactiver ce message."); + } + let cfg = cfg.clone(); + Some(tokio::spawn(async move { + match watcher::Watcher::new(cfg).await { + Ok(w) => { + if let Err(e) = w.run().await { + tracing::error!("Erreur watcher : {:#}", e); + } + } + Err(e) => tracing::error!("Échec création watcher : {:#}", e), + } + })) + } else { + None + }; + + // Ouvrir le navigateur si demandé + if open_browser { + if let Err(e) = open::that(format!("http://{}:{}", host, port)) { + tracing::warn!("Impossible d'ouvrir le navigateur : {}", e); + } + } + + // Lancer le serveur web + let host = host.to_string(); + let server_task = tokio::spawn(async move { server::serve(&host, port).await }); + + // Attendre Ctrl+C + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("⏹️ Ctrl+C reçu — arrêt propre..."); + } + res = server_task => { + if let Err(e) = res { + tracing::error!("Erreur serveur : {:#}", e); + } + } + } + + remove_pid()?; + tracing::info!("✅ imagod-web arrêté."); + + Ok(()) +} + +/// `stop` — Arrête le service +fn cmd_stop() -> Result<()> { + let pid = read_pid().context( + "imagod-web n'est pas en cours d'exécution. Aucun fichier PID trouvé." + )?; + + if !process_exists(pid) { + tracing::warn!("PID {} trouvé mais le processus n'existe pas. Nettoyage du fichier PID...", pid); + remove_pid()?; + anyhow::bail!( + "Le processus imagod-web (PID {}) n'existe plus.\n\ + Le fichier PID a été nettoyé. Vous pouvez relancer avec `imagod-web start`.", + pid + ); + } + + tracing::info!("Arrêt du processus imagod-web (PID {})...", pid); + + #[cfg(unix)] + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + + #[cfg(windows)] + { + use std::ffi::c_void; + extern "system" { + fn OpenProcess(desired_access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn TerminateProcess(handle: *mut c_void, exit_code: u32) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + const PROCESS_TERMINATE: u32 = 0x0001; + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); + if handle.is_null() { + anyhow::bail!("Impossible d'ouvrir le processus PID {} pour l'arrêter.", pid); + } + TerminateProcess(handle, 0); + CloseHandle(handle); + } + } + + // Attendre un peu que le processus se termine et nettoie son PID + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Nettoyer le PID si le processus d'origine ne l'a pas fait + if process_exists(pid) { + tracing::warn!("Le processus PID {} ne répond pas au signal. Utilisez le gestionnaire de tâches.", pid); + } else { + remove_pid().ok(); + tracing::info!("✅ imagod-web arrêté (PID {}).", pid); + } + + Ok(()) +} + +/// `status` — Affiche l'état du service +fn cmd_status() -> Result<()> { + let pid = match read_pid() { + Ok(p) => p, + Err(_) => { + println!("📴 imagod-web n'est pas en cours d'exécution."); + println!(); + println!(" Lancez avec : imagod-web start"); + return Ok(()); + } + }; + + let alive = process_exists(pid); + + if alive { + println!("🟢 imagod-web est en cours d'exécution."); + println!(" PID : {}", pid); + println!(" Dashboard : http://127.0.0.1:9090"); + + // Essayer de charger la config pour afficher plus d'infos + if let Ok(cfg) = config::Config::load() { + println!(" Imago API : {}", cfg.imago.url); + println!(" Polling : {}ms", cfg.watcher.poll_interval_ms); + } + } else { + println!("🔴 Le fichier PID indique {} mais le processus est mort.", pid); + println!(" Nettoyage automatique du fichier PID..."); + remove_pid()?; + println!(" Vous pouvez relancer avec : imagod-web start"); + } + + Ok(()) +} + +/// `config` — Gère la configuration +fn cmd_config(action: &cli::ConfigAction) -> Result<()> { + match action { + cli::ConfigAction::Show => { + let cfg = config::Config::load()?; + let toml_str = toml::to_string_pretty(&cfg)?; + println!("{}", toml_str); + } + cli::ConfigAction::Set { key, value } => { + let mut cfg = config::Config::load()?; + + // Parse le chemin dotted (ex: "imago.url") + match key.as_str() { + "imago.url" => cfg.imago.url = value.clone(), + "imago.api_key" => cfg.imago.api_key = value.clone(), + "watcher.capture_images" => cfg.watcher.capture_images = value.parse() + .context("Valeur booléenne attendue (true/false)")?, + "watcher.poll_interval_ms" => cfg.watcher.poll_interval_ms = value.parse() + .context("Valeur numérique attendue (ms)")?, + "dedup.method" => cfg.dedup.method = value.clone(), + "dedup.cache_size" => cfg.dedup.cache_size = value.parse() + .context("Valeur numérique attendue")?, + "ui.show_notifications" => cfg.ui.show_notifications = value.parse() + .context("Valeur booléenne attendue")?, + "ui.show_tray_icon" => cfg.ui.show_tray_icon = value.parse() + .context("Valeur booléenne attendue")?, + "ui.notification_timeout_sec" => cfg.ui.notification_timeout_sec = value.parse() + .context("Valeur numérique attendue (secondes)")?, + "logging.level" => cfg.logging.level = value.clone(), + _ => anyhow::bail!( + "Clé inconnue : '{}'.\n\ + Clés disponibles :\n \ + imago.url, imago.api_key,\n \ + watcher.capture_images, watcher.poll_interval_ms,\n \ + dedup.method, dedup.cache_size,\n \ + ui.show_notifications, ui.show_tray_icon, ui.notification_timeout_sec,\n \ + logging.level", + key + ), + } + + cfg.save()?; + println!("✅ {} = {}", key, value); + + // Avertir que les changements prennent effet au prochain redémarrage + if is_running().is_some() { + println!("⚠️ imagod-web est en cours d'exécution. Les changements prendront effet au prochain redémarrage."); + } + } + cli::ConfigAction::Reset => { + let cfg = config::Config::default(); + cfg.save()?; + println!("✅ Configuration réinitialisée aux valeurs par défaut."); + if is_running().is_some() { + println!("⚠️ imagod-web est en cours d'exécution. Les changements prendront effet au prochain redémarrage."); + } + } + } + + Ok(()) +} + +/// `logs` — Affiche les derniers logs +fn cmd_logs() -> Result<()> { + let log_path = data_dir()?.join("imagod.log"); + + if !log_path.exists() { + // Essayer de trouver des logs dans le répertoire courant aussi + let local_log = PathBuf::from("imagod.log"); + if local_log.exists() { + print_log_file(&local_log)?; + } else { + println!("📭 Aucun fichier de log trouvé."); + println!(" Les logs sont écrits sur stderr par défaut."); + println!(" Fichier de log attendu : {}", log_path.display()); + } + } else { + print_log_file(&log_path)?; + } + + Ok(()) +} + +fn print_log_file(path: &PathBuf) -> Result<()> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Impossible de lire {}", path.display()))?; + + let lines: Vec<&str> = content.lines().collect(); + let total = lines.len(); + + // Afficher les 50 dernières lignes + let start = if total > 50 { total - 50 } else { 0 }; + let recent = &lines[start..]; + + println!("📋 {} derniers logs ({} total) :", recent.len(), total); + println!("{}", "─".repeat(60)); + for line in recent { + println!("{}", line); + } + if total > 50 { + println!("{}", "─".repeat(60)); + println!("... {} lignes supplémentaires. Lancez `imagod-web logs` pour voir les dernières.", total - 50); + } + + Ok(()) +} + +// ─── Main ────────────────────────────────────────────────────────────────── + #[tokio::main] -async fn main() -> Result<()> { +async fn main() { setup_logging(); let args = cli::parse(); - match args.command { - cli::Command::Start { port: _, open_browser: _ } => { - tracing::info!("Démarrage imagod-web — watcher + dashboard"); - todo!("Phase 6-7: Watcher + serveur web") + let result = match args.command { + cli::Command::Start { port, open_browser } => { + cmd_start(port, open_browser).await } - cli::Command::Serve { port: _, host: _, no_watcher: _, open_browser: _ } => { - tracing::info!("Démarrage dashboard web uniquement"); - todo!("Phase 9: Dashboard web") + cli::Command::Serve { port, host, no_watcher, open_browser } => { + cmd_serve(&host, port, no_watcher, open_browser).await } cli::Command::Stop => { - todo!("Phase 7: Stop") + cmd_stop() } cli::Command::Status => { - todo!("Phase 7: Status") + cmd_status() } - cli::Command::Config { action: _ } => { - todo!("Phase 2: Config") - } - cli::Command::Install => { - todo!("Phase 10: Service install") - } - cli::Command::Uninstall => { - todo!("Phase 10: Service uninstall") + cli::Command::Config { action } => { + cmd_config(&action) } cli::Command::Logs => { - todo!("Phase 7: Logs") + cmd_logs() } + cli::Command::Install => { + tracing::info!("Installation du service Windows..."); + tracing::warn!("Phase 10: Installation du service — non implémentée"); + Ok(()) + } + cli::Command::Uninstall => { + tracing::info!("Désinstallation du service Windows..."); + tracing::warn!("Phase 10: Désinstallation du service — non implémentée"); + Ok(()) + } + }; + + if let Err(e) = result { + eprintln!("❌ Erreur : {:#}", e); + process::exit(1); } } diff --git a/src/watcher.rs b/src/watcher.rs index 99c55ec..910c5df 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -29,7 +29,7 @@ impl Watcher { } /// Lance la boucle de polling - pub async fn run(&self) -> Result<()> { + pub async fn run(self) -> Result<()> { let _interval = Duration::from_millis(self.config.watcher.poll_interval_ms); tracing::info!(