feat: Phase 7 — CLI complet (start/serve/stop/status/config/logs)

- 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)
This commit is contained in:
2026-06-22 16:57:41 -04:00
parent 38f274c3d5
commit b680c0fff7
6 changed files with 544 additions and 28 deletions
Generated
+39 -1
View File
@@ -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]]
+4
View File
@@ -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
+30 -3
View File
@@ -72,7 +72,7 @@ fn default_exclude_apps() -> Vec<String> {
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()
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! Client HTTP pour l'API Imago
use anyhow::{Context, Result};
use anyhow::Result;
use reqwest::{Client, multipart};
use serde::Deserialize;
+469 -22
View File
@@ -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<u32> {
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<PathBuf> {
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<u32> {
read_pid().ok().filter(|&pid| process_exists(pid))
}
fn data_dir() -> Result<PathBuf> {
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);
}
}
+1 -1
View File
@@ -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!(