From 7936106227322971e4a896ce2440871f1ca9b4e4 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 25 Jun 2026 11:35:00 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=2010=20=E2=80=94=20Service=20Wind?= =?UTF-8?q?ows=20NSSM=20+=20cross-compilation=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 (service.rs): - install: télécharge NSSM, installe le service 'ImagodWeb' (auto-start, restart on crash) - uninstall: arrête et supprime le service - Service configuré avec: démarrage auto, redémarrage sur crash, délai 5s - Extraction ZIP NSSM intégrée (crate zip, deflate only) Phase 12 (cross-compilation Windows): - Cible x86_64-pc-windows-gnu configurée - Linker mingw-w64 - Corrections compatibilité: Cow→Vec dans clipboard.rs, API zip v2 - Binaire Windows .exe généré (8.1 MB, statique) Fix: compilation croisée Windows fonctionnelle --- Cargo.lock | 50 +++++++++ Cargo.toml | 2 + src/clipboard.rs | 4 +- src/main.rs | 6 +- src/service.rs | 266 ++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 317 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a04cedd..c2f83cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -478,6 +487,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "digest" version = "0.10.7" @@ -1296,6 +1316,7 @@ dependencies = [ "tracing-subscriber", "tray-icon", "windows-sys 0.59.0", + "zip", ] [[package]] @@ -3645,12 +3666,41 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 8ba75d9..6ef6e2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ toml = "0.8" sha2 = "0.10" # SQLite rusqlite = { version = "0.32", features = ["bundled"] } +# ZIP (extraction NSSM) — deflate seulement pour éviter bzip2 +zip = { version = "2", default-features = false, features = ["deflate"] } # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/src/clipboard.rs b/src/clipboard.rs index 7462fb2..2cd6174 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -29,7 +29,7 @@ pub fn get_image_bytes() -> Result> { let height = img_data.height as u32; // arboard retourne BGRA sur Windows, RGBA sur Linux/macOS - // On convertit BGRA → RGBA en swapant les canaux B et R + // Linux/macOS: déjà RGBA — convertir Cow en Vec let rgba_bytes: Vec = if cfg!(target_os = "windows") { img_data .bytes @@ -38,7 +38,7 @@ pub fn get_image_bytes() -> Result> { .collect() } else { // Linux/macOS: déjà RGBA - img_data.bytes + img_data.bytes.to_vec() }; let img = image::RgbaImage::from_raw(width, height, rgba_bytes) diff --git a/src/main.rs b/src/main.rs index ed447ca..abe5c4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -520,13 +520,11 @@ async fn main() { } cli::Command::Install => { tracing::info!("Installation du service Windows..."); - tracing::warn!("Phase 10: Installation du service — non implémentée"); - Ok(()) + service::install() } cli::Command::Uninstall => { tracing::info!("Désinstallation du service Windows..."); - tracing::warn!("Phase 10: Désinstallation du service — non implémentée"); - Ok(()) + service::uninstall() } }; diff --git a/src/service.rs b/src/service.rs index 00bba06..e304350 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,13 +1,269 @@ -//! Service Windows (NSSM / windows-service) +//! Service Windows — Installation via NSSM //! -//! Phase 10: Installation comme service Windows +//! Phase 10: Gestion du service Windows pour imagod-web. +//! Utilise NSSM (Non-Sucking Service Manager) pour enregistrer +//! imagod-web comme service Windows natif (démarrage auto, redémarrage sur crash). -use anyhow::Result; +use anyhow::{Context, Result}; +use std::env; +use std::path::PathBuf; +use std::process::Command; +/// Chemin vers l'exécutable NSSM (téléchargé automatiquement si absent) +fn nssm_path() -> Result { + let appdata = env::var("APPDATA").context("Variable APPDATA non définie — on est bien sous Windows?")?; + let dir = PathBuf::from(&appdata).join("imagod-web"); + std::fs::create_dir_all(&dir)?; + let nssm = dir.join("nssm.exe"); + Ok(nssm) +} + +/// Chemin vers l'exécutable imagod-web actuel +fn imagod_exe() -> Result { + env::current_exe().context("Impossible de déterminer le chemin de l'exécutable") +} + +/// Télécharge NSSM depuis le site officiel +fn download_nssm() -> Result<()> { + let nssm = nssm_path()?; + if nssm.exists() { + tracing::info!("NSSM déjà présent: {}", nssm.display()); + return Ok(()); + } + + let url = "https://nssm.cc/release/nssm-2.24.zip"; + let zip_path = nssm.with_extension("zip"); + + tracing::info!("Téléchargement de NSSM depuis {}...", url); + + // Télécharger avec reqwest (bloquant) + let rt = tokio::runtime::Runtime::new()?; + let response = rt.block_on(async { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build()? + .get(url) + .send() + .await + })?; + + if !response.status().is_success() { + anyhow::bail!("Échec du téléchargement: HTTP {}", response.status()); + } + + let bytes = rt.block_on(response.bytes())?; + std::fs::write(&zip_path, &bytes)?; + tracing::info!("NSSM téléchargé: {} ({} octets)", zip_path.display(), bytes.len()); + + // Extraire nssm.exe du zip (le zip contient nssm-2.24/win64/nssm.exe) + let file = std::fs::File::open(&zip_path)?; + let mut archive = zip::ZipArchive::new(file)?; + + // Chercher nssm.exe dans l'archive (windows/win64) + let mut found = false; + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let name = entry.name().to_lowercase(); + if name.ends_with("nssm.exe") && !name.contains("nssm.exe/") { + // Extraire vers le répertoire cible + let mut out = std::fs::File::create(&nssm)?; + std::io::copy(&mut entry, &mut out)?; + found = true; + tracing::info!("NSSM extrait: {}", nssm.display()); + break; + } + } + + if !found { + anyhow::bail!("nssm.exe introuvable dans l'archive ZIP"); + } + + // Nettoyer le zip + let _ = std::fs::remove_file(&zip_path); + Ok(()) +} + +/// Installe imagod-web comme service Windows +/// +/// Équivalent à: +/// nssm install ImagodWeb "C:\...\imagod-web.exe" serve --host 127.0.0.1 --port 9199 +/// nssm set ImagodWeb Start SERVICE_AUTO_START +/// nssm set ImagodWeb AppExit Default Restart +/// nssm start ImagodWeb pub fn install() -> Result<()> { - todo!("Phase 10: Service install via NSSM") + if !cfg!(windows) { + anyhow::bail!("L'installation en service Windows n'est disponible que sous Windows"); + } + + tracing::info!("=== Installation du service Windows imagod-web ==="); + + // 1. Télécharger NSSM si nécessaire + download_nssm().context("Échec du téléchargement de NSSM")?; + let nssm = nssm_path()?; + + // 2. Vérifier que l'exécutable existe + let exe = imagod_exe()?; + tracing::info!("Exécutable: {}", exe.display()); + + // 3. Désinstaller d'abord si déjà présent (évite les conflits) + if nssm_installed()? { + tracing::info!("Service déjà installé, suppression préalable..."); + let _ = uninstall(); + } + + // 4. Installer le service + let service_name = "ImagodWeb"; + tracing::info!("Installation du service '{}'...", service_name); + + let status = Command::new(&nssm) + .args([ + "install", + service_name, + &exe.to_string_lossy(), + ]) + .output() + .context("Échec de la commande nssm install")?; + + if !status.status.success() { + let stderr = String::from_utf8_lossy(&status.stderr); + anyhow::bail!("nssm install a échoué: {}", stderr); + } + + // 5. Configurer les arguments de lancement: serve --host 127.0.0.1 --port 9199 + Command::new(&nssm) + .args(["set", service_name, "AppParameters", "serve --host 127.0.0.1 --port 9199"]) + .output() + .context("Échec configuration AppParameters")?; + + // 6. Démarrage automatique + Command::new(&nssm) + .args(["set", service_name, "Start", "SERVICE_AUTO_START"]) + .output() + .context("Échec configuration Start")?; + + // 7. Redémarrage automatique sur crash + Command::new(&nssm) + .args(["set", service_name, "AppExit", "Default", "Restart"]) + .output() + .context("Échec configuration AppExit")?; + + // 8. Délai de redémarrage: 5 secondes + Command::new(&nssm) + .args(["set", service_name, "AppRestartDelay", "5000"]) + .output() + .context("Échec configuration AppRestartDelay")?; + + // 9. Répertoire de travail + if let Some(parent) = exe.parent() { + Command::new(&nssm) + .args(["set", service_name, "AppDirectory", &parent.to_string_lossy()]) + .output() + .context("Échec configuration AppDirectory")?; + } + + // 10. Démarrer le service + tracing::info!("Démarrage du service..."); + let start = Command::new(&nssm) + .args(["start", service_name]) + .output() + .context("Échec de la commande nssm start")?; + + if start.status.success() { + tracing::info!("✅ Service '{}' installé et démarré avec succès!", service_name); + tracing::info!(" Dashboard : http://127.0.0.1:9199"); + tracing::info!(" Le watcher clipboard est actif (capture automatique des screenshots)"); + tracing::info!(" Le service redémarre automatiquement en cas de crash"); + } else { + let stderr = String::from_utf8_lossy(&start.stderr); + tracing::warn!("⚠️ Service installé mais échec au démarrage: {}", stderr); + tracing::info!(" Essayez 'sc start ImagodWeb' ou redémarrez Windows"); + } + + Ok(()) } +/// Désinstalle le service Windows imagod-web +/// +/// Équivalent à: +/// nssm stop ImagodWeb +/// nssm remove ImagodWeb confirm pub fn uninstall() -> Result<()> { - todo!("Phase 10: Service uninstall") + if !cfg!(windows) { + anyhow::bail!("La désinstallation de service Windows n'est disponible que sous Windows"); + } + + let nssm = nssm_path()?; + if !nssm.exists() { + // NSSM pas présent, le service n'a probablement jamais été installé + tracing::warn!("NSSM non trouvé — rien à désinstaller"); + return Ok(()); + } + + let service_name = "ImagodWeb"; + + if !nssm_installed()? { + tracing::warn!("Service '{}' non trouvé — rien à désinstaller", service_name); + return Ok(()); + } + + tracing::info!("Arrêt du service '{}'...", service_name); + let _ = Command::new(&nssm) + .args(["stop", service_name]) + .output(); + + tracing::info!("Suppression du service '{}'...", service_name); + let remove = Command::new(&nssm) + .args(["remove", service_name, "confirm"]) + .output() + .context("Échec de la commande nssm remove")?; + + if remove.status.success() { + tracing::info!("✅ Service '{}' désinstallé avec succès", service_name); + } else { + let stderr = String::from_utf8_lossy(&remove.stderr); + anyhow::bail!("nssm remove a échoué: {}", stderr); + } + + Ok(()) +} + +/// Vérifie si le service est installé +fn nssm_installed() -> Result { + let nssm = nssm_path()?; + if !nssm.exists() { + return Ok(false); + } + + let output = Command::new(&nssm) + .args(["status", "ImagodWeb"]) + .output() + .context("Échec de nssm status")?; + + Ok(output.status.success()) +} + +/// Affiche le statut du service +pub fn status() -> Result<()> { + if !cfg!(windows) { + tracing::info!("Le statut de service n'est pertinent que sous Windows"); + tracing::info!("Utilisez 'imagod-web status' pour le statut général"); + return Ok(()); + } + + let nssm = nssm_path()?; + if !nssm.exists() || !nssm_installed()? { + tracing::warn!("Service 'ImagodWeb' non installé"); + tracing::info!("Exécutez 'imagod-web install' pour l'installer"); + return Ok(()); + } + + let output = Command::new(&nssm) + .args(["status", "ImagodWeb"]) + .output() + .context("Échec de nssm status")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + tracing::info!("Statut du service: {}", stdout.trim()); + + Ok(()) }