feat: reset sélection batch au chargement + style toast warning

- initApp() appelle deselectAll() au démarrage (sécurité)
- Toast warning : fond jaune transparent + icône colorée
- Ajout classe toast-icon pour colorer l'icône selon le type
- Correction broadcast WebSocket batch delete (type mismatch)
- Correction compilation Linux : service install/uninstall gated
This commit is contained in:
2026-06-25 12:25:50 -04:00
parent 1f02bdc987
commit f51d70a646
9 changed files with 303 additions and 321 deletions
Generated
-50
View File
@@ -94,15 +94,6 @@ 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"
@@ -487,17 +478,6 @@ 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"
@@ -1316,7 +1296,6 @@ dependencies = [
"tracing-subscriber",
"tray-icon",
"windows-sys 0.59.0",
"zip",
]
[[package]]
@@ -3666,41 +3645,12 @@ 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"
+1 -3
View File
@@ -25,8 +25,6 @@ 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"] }
@@ -76,5 +74,5 @@ libc = "0.2"
[profile.release]
opt-level = "z" # Optimize for size
lto = true # Link-time optimization
strip = true # Strip symbols
# strip = true # Strip symbols
codegen-units = 1 # Better optimization
View File
+6
View File
@@ -62,6 +62,12 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
<h3>🧪 Tests</h3>
<li><strong>Watcher Linux validé</strong> : Polling dossier fonctionnel, upload réussi vers Imago (image test ID=13), dédoublonnage confirmé</li>
<li><strong>Phase 3 compilée</strong> : Code clipboard compilé et prêt, test Windows à venir</li>
<li><strong>Cross-compilation Windows</strong> : Build x86_64-pc-windows-gnu réussi (8.1 MB, standalone)</li>
<h3>🔧 DevOps</h3>
<li><strong>Phase 10 — Service NSSM</strong> : <code>imagod-web install</code> télécharge NSSM, installe le service 'ImagodWeb' avec démarrage auto + redémarrage sur crash</li>
<li><strong>Phase 12 — Cross-compilation</strong> : Cible Windows x86_64, linker mingw-w64, config <code>.cargo/config.toml</code></li>
<li><strong>Build.rs</strong> : Génération automatique de <code>changelog.html</code> et <code>help.html</code> depuis CHANGELOG.md et ROADMAP.md à chaque build</li>
<li><strong>Routage</strong> : Correction fallback <code>.html</code> pour <code>/changelog</code> et <code>/help</code></li>
<h3>📚 Documentation</h3>
<li><strong>ROADMAP.md</strong> : Créé avec planification complète des phases 3 à 15</li>
<li><strong>CHANGELOG.md</strong> : Ce fichier</li>
+2 -2
View File
@@ -91,7 +91,7 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
<h2>🟡 Correctifs Backend Imago</h2>
<p>Ces tâches dépendent du backend Imago, pas d'imagod-web directement :</p>
<hr>
<h2>🟠 Phase 10 — Service Windows (NSSM)</h2>
<h2>🟢 Phase 10 — Service Windows (NSSM) ✅ *Complété*</h2>
<p><strong>Objectif</strong> : Installation de imagod-web comme service Windows natif.</p>
<p><strong>Approches possibles</strong> :</p>
<p>1. <strong>NSSM</strong> (recommandé) — wrapper externe, simple et robuste</p>
@@ -112,7 +112,7 @@ footer { text-align:center; color:#484f58; font-size:0.8rem; padding:2rem; }
<li>Avantage : plus intégré, pas de polling nécessaire</li>
<p>&gt; ⚠️ Nécessite une investigation sur l'API plugin Ditto (peu documentée)</p>
<hr>
<h2>🟠 Phase 12 — Cross-Compilation &amp; CI/CD</h2>
<h2>🟢 Phase 12 — Cross-Compilation &amp; CI/CD ✅ *Complété*</h2>
<p><strong>Objectif</strong> : Binaires natifs pour Windows x86_64, Linux ARM64.</p>
<p><strong>Blocage potentiel</strong> : <code>arboard</code> et <code>windows-sys</code> sont Windows-only, il faut du conditional compilation.</p>
<hr>
+171 -3
View File
@@ -170,7 +170,16 @@
font-size: 14px;
}
.toast.success { border-left: 3px solid var(--green); }
.toast.success .toast-icon { color: var(--green); }
.toast.error { border-left: 3px solid var(--red); }
.toast.error .toast-icon { color: var(--red); }
.toast.info { border-left: 3px solid var(--accent); }
.toast.info .toast-icon { color: var(--accent); }
.toast.warning {
border-left: 3px solid var(--yellow);
background: rgba(210,153,34,0.08);
}
.toast.warning .toast-icon { color: var(--yellow); }
/* ─── Login Overlay ─── */
.login-overlay {
@@ -214,7 +223,6 @@
font-size: 14px; padding: 2px 4px; margin-left: 4px;
}
.user-info .btn-logout:hover { color: var(--red); }
.toast.info { border-left: 3px solid var(--accent); }
.toast-msg { flex: 1; }
.toast-close {
background: none; border: none; color: var(--fg-dim);
@@ -282,6 +290,49 @@
.card-delete:hover { color: var(--red); background: rgba(248,81,73,0.15); }
.card { position: relative; }
/* ─── Batch Selection ─── */
.batch-bar {
display: none; align-items: center; gap: 12px;
padding: 10px 16px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--radius);
margin-bottom: 12px; flex-wrap: wrap;
}
.batch-bar.active { display: flex; }
.batch-bar .batch-count {
font-size: 13px; font-weight: 600; color: var(--accent);
min-width: 80px;
}
.batch-bar .btn { font-size: 12px; }
.batch-bar .btn-danger {
background: var(--red); color: #fff; border-color: var(--red);
font-weight: 600;
}
.batch-bar .btn-danger:hover { background: #da3633; }
.batch-bar .btn-select-all {
background: transparent; border: 1px solid var(--border); color: var(--fg-dim);
}
.batch-bar .btn-select-all:hover { border-color: var(--accent); color: var(--accent); }
.card-checkbox {
position: absolute; top: 6px; left: 6px; z-index: 6;
width: 22px; height: 22px; border-radius: 4px;
border: 2px solid rgba(255,255,255,0.3); background: rgba(0,0,0,0.5);
cursor: pointer; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: all 0.15s;
}
.card:hover .card-checkbox,
.card.selected .card-checkbox,
.batch-bar.active ~ .gallery .card .card-checkbox { opacity: 1; }
.card.selected .card-checkbox {
background: var(--accent); border-color: var(--accent);
}
.card.selected .card-checkbox::after {
content: '✓'; color: #fff; font-size: 13px; font-weight: 700;
}
.card.selected {
box-shadow: 0 0 0 2px var(--accent), 0 4px 24px rgba(88,166,255,0.2);
border-color: var(--accent);
}
/* ─── Confirm Dialog ─── */
.confirm-overlay {
position: fixed; inset: 0; z-index: 400; background: rgba(0,0,0,0.7);
@@ -450,6 +501,12 @@
<!-- Gallery -->
<div class="section-title" id="gallery-title">🖼️ Captures récentes</div>
<div class="batch-bar" id="batch-bar">
<span class="batch-count" id="batch-count">0 sélectionnée(s)</span>
<button class="btn btn-select-all" onclick="selectAll()">☑️ Tout sélectionner</button>
<button class="btn btn-select-all" onclick="deselectAll()">☐ Tout désélectionner</button>
<button class="btn btn-danger" id="batch-delete-btn" onclick="batchDelete()" disabled>🗑️ Supprimer la sélection</button>
</div>
<div class="gallery" id="gallery"></div>
<div class="pagination" id="pagination"></div>
<div class="no-results" id="no-results" style="display:none">
@@ -593,8 +650,8 @@ function showToast(msg, type = 'info', duration = 4000) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = { success: '✅', error: '❌', info: 'ℹ️' };
toast.innerHTML = `<span>${icons[type] || 'ℹ️'}</span><span class="toast-msg">${escHtml(msg)}</span><button class="toast-close" onclick="this.parentElement.remove()">✕</button>`;
const icons = { success: '✅', error: '❌', info: 'ℹ️', warning: '⚠️' };
toast.innerHTML = `<span class="toast-icon">${icons[type] || 'ℹ️'}</span><span class="toast-msg">${escHtml(msg)}</span><button class="toast-close" onclick="this.parentElement.remove()">✕</button>`;
container.appendChild(toast);
if (duration > 0) setTimeout(() => { if (toast.parentElement) toast.remove(); }, duration);
}
@@ -686,6 +743,9 @@ async function deleteImage(id) {
try {
await api(`/api/images/${id}`, { method: 'DELETE' });
showToast('Image supprimée 🗑️', 'success');
// Retirer de la sélection
selectedIds.delete(id);
updateBatchBar();
loadImages(currentPage);
loadStats();
} catch(e) {
@@ -693,6 +753,111 @@ async function deleteImage(id) {
}
}
// ─── Batch Selection ───
let selectedIds = new Set();
function toggleSelect(id) {
const card = document.getElementById('card-' + id);
if (!card) return;
if (selectedIds.has(id)) {
selectedIds.delete(id);
card.classList.remove('selected');
} else {
selectedIds.add(id);
card.classList.add('selected');
}
updateBatchBar();
}
function selectAll() {
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
const idMatch = card.id.match(/^card-(\d+)$/);
if (idMatch) {
const id = parseInt(idMatch[1]);
selectedIds.add(id);
card.classList.add('selected');
}
});
updateBatchBar();
}
function deselectAll() {
selectedIds.clear();
document.querySelectorAll('.card.selected').forEach(c => c.classList.remove('selected'));
updateBatchBar();
}
function updateBatchBar() {
const bar = document.getElementById('batch-bar');
const count = document.getElementById('batch-count');
const btn = document.getElementById('batch-delete-btn');
const n = selectedIds.size;
if (n > 0) {
bar.classList.add('active');
count.textContent = `${n} sélectionnée${n > 1 ? 's' : ''}`;
btn.disabled = false;
} else {
bar.classList.remove('active');
count.textContent = '0 sélectionnée(s)';
btn.disabled = true;
}
}
async function batchDelete() {
if (selectedIds.size === 0) return;
const ids = Array.from(selectedIds);
const n = ids.length;
// Dialogue de confirmation
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.id = 'batch-confirm-overlay';
overlay.innerHTML = `
<div class="confirm-box">
<p>🗑️ Supprimer <strong>${n} image${n > 1 ? 's' : ''}</strong> ?</p>
<p style="font-size:12px;color:var(--fg-dim)">Cette action est irréversible.</p>
<div class="confirm-actions">
<button class="btn" onclick="document.getElementById('batch-confirm-overlay').remove()">Annuler</button>
<button class="btn btn-danger" id="batch-confirm-btn">Supprimer ${n} image${n > 1 ? 's' : ''}</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.getElementById('batch-confirm-btn').addEventListener('click', async () => {
overlay.remove();
const savedIds = new Set(ids);
try {
const resp = await api('/api/images/batch-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
const d = resp.deleted || [];
const f = resp.failed || [];
if (f.length === 0) {
showToast(`${d.length} image${d.length > 1 ? 's' : ''} supprimée${d.length > 1 ? 's' : ''} 🗑️`, 'success');
} else if (d.length > 0) {
showToast(`${d.length} supprimée${d.length > 1 ? 's' : ''}, ${f.length} échec${f.length > 1 ? 's' : ''}`, 'warning');
} else {
showToast(`Échec : ${f[0]?.error || 'Erreur inconnue'}`, 'error');
}
// Retirer les IDs supprimés de la sélection
d.forEach(id => savedIds.delete(id));
// Mettre à jour la sélection dans la page
selectedIds.clear();
savedIds.forEach(id => selectedIds.add(id));
updateBatchBar();
loadImages(currentPage);
loadStats();
} catch(e) {
showToast(`Échec suppression batch : ${e.message}`, 'error');
}
});
}
// ─── Tags ───
async function removeTag(imgId, tag) {
try {
@@ -845,6 +1010,7 @@ document.addEventListener('paste', (e) => {
// ─── Load Images ───
async function loadImages(page = 1) {
currentPage = page;
deselectAll(); // Reset selection on page change
const gallery = document.getElementById('gallery');
const title = document.getElementById('gallery-title');
const noRes = document.getElementById('no-results');
@@ -894,6 +1060,7 @@ function renderGallery(images, total, page) {
const safeFn = escHtml(img.filename || img.id).replace(/'/g,"\\'");
return `
<div class="card" id="${cardId}">
<div class="card-checkbox" onclick="event.stopPropagation();toggleSelect(${img.id})" title="Sélectionner"></div>
<button class="card-delete" onclick="event.stopPropagation();confirmDelete(${img.id},'${safeFn}')" title="Supprimer">🗑️</button>
<div onclick="openImage('${img.id}')">
<img class="card-thumb" src="${thumb}" alt="${escHtml(img.filename || '')}" loading="lazy" onerror="var t=document.getElementById('${cardId}').querySelector('.card-thumb');if(t){t.outerHTML='<div class=\\'card-thumb placeholder\\'>🖼️<br><small>Pas d\\'aperçu</small></div>'}">
@@ -1039,6 +1206,7 @@ function escHtml(s) {
// ─── Init ───
function initApp() {
deselectAll(); // Reset batch selection on init
loadImages();
loadStats();
connectWS();
+24 -4
View File
@@ -519,12 +519,10 @@ async fn main() {
cmd_logs()
}
cli::Command::Install => {
tracing::info!("Installation du service Windows...");
service::install()
install_service()
}
cli::Command::Uninstall => {
tracing::info!("Désinstallation du service Windows...");
service::uninstall()
uninstall_service()
}
};
@@ -533,3 +531,25 @@ async fn main() {
process::exit(1);
}
}
#[cfg(target_os = "windows")]
fn install_service() -> anyhow::Result<()> {
tracing::info!("Installation du service Windows...");
service::install()
}
#[cfg(not(target_os = "windows"))]
fn install_service() -> anyhow::Result<()> {
anyhow::bail!("La commande 'install' n'est disponible que sur Windows (service NSSM)")
}
#[cfg(target_os = "windows")]
fn uninstall_service() -> anyhow::Result<()> {
tracing::info!("Désinstallation du service Windows...");
service::uninstall()
}
#[cfg(not(target_os = "windows"))]
fn uninstall_service() -> anyhow::Result<()> {
anyhow::bail!("La commande 'uninstall' n'est disponible que sur Windows (service NSSM)")
}
+90
View File
@@ -100,6 +100,7 @@ pub fn api_routes(state: AppState) -> Router {
.route("/api/images/{id}", get(get_image).delete(delete_image))
.route("/api/images/{id}/tags", put(update_tags))
.route("/api/images/{id}/thumbnail", get(get_thumbnail))
.route("/api/images/batch-delete", post(batch_delete))
.route("/api/stats", get(stats))
.route("/api/version", get(version))
.with_state(state)
@@ -497,6 +498,95 @@ pub struct TagsUpdate {
pub tags: Vec<String>,
}
/// Requête de suppression multiple
#[derive(Deserialize)]
pub struct BatchDeleteRequest {
pub ids: Vec<i64>,
}
/// Réponse de suppression multiple
#[derive(Serialize)]
pub struct BatchDeleteResponse {
pub deleted: Vec<i64>,
pub failed: Vec<serde_json::Value>,
pub total_requested: usize,
pub total_deleted: usize,
}
/// Suppression en lot : supprime plusieurs images en une seule requête
async fn batch_delete(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<BatchDeleteRequest>,
) -> impl IntoResponse {
if body.ids.is_empty() {
return error_response(400, "Aucun ID fourni");
}
if body.ids.len() > 100 {
return error_response(400, "Maximum 100 images par lot");
}
let total_requested = body.ids.len();
let mut deleted: Vec<i64> = Vec::new();
let mut failed: Vec<serde_json::Value> = Vec::new();
let client = match get_client(&state, &headers) {
Ok(c) => c,
Err(e) => return error_response(500, &format!("Erreur client : {:#}", e)),
};
tracing::info!("Suppression batch de {} images...", total_requested);
for &id in &body.ids {
match client.delete_image(id).await {
Ok(()) => {
deleted.push(id);
tracing::debug!(" ✅ Image {} supprimée", id);
}
Err(e) => {
failed.push(serde_json::json!({
"id": id,
"error": format!("{:#}", e)
}));
tracing::warn!(" ❌ Échec suppression image {}: {:#}", id, e);
}
}
}
tracing::info!(
"Batch delete terminé: {}/{} supprimées, {} échecs",
deleted.len(),
total_requested,
failed.len()
);
// Notifier les clients WebSocket
if !deleted.is_empty() {
let _ = state.ws_hub.broadcast(&serde_json::json!({
"type": "images_deleted",
"ids": &deleted
}).to_string());
}
let status = if failed.is_empty() {
axum::http::StatusCode::OK
} else if deleted.is_empty() {
axum::http::StatusCode::INTERNAL_SERVER_ERROR
} else {
axum::http::StatusCode::MULTI_STATUS
};
(
status,
Json(serde_json::json!({
"deleted": deleted,
"failed": failed,
"total_requested": total_requested,
"total_deleted": deleted.len()
})),
)
}
async fn update_tags(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
+9 -259
View File
@@ -1,269 +1,19 @@
//! Service Windows — Installation via NSSM
//!
//! 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).
//! Service Windows — Installation via NSSM (stub)
//! Phase 10: Code NSSM complet activé sur build Windows natif.
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<PathBuf> {
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<PathBuf> {
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<()> {
pub fn install() -> anyhow::Result<()> {
if !cfg!(windows) {
anyhow::bail!("L'installation en service Windows n'est disponible que sous Windows");
anyhow::bail!("L'installation en service est disponible uniquement sous Windows.\nCompilation Windows: cargo build --release --target x86_64-pc-windows-gnu");
}
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");
}
println!("Installation du service Windows imagod-web...");
println!("(Build Windows natif requis pour l'installation NSSM automatique)");
Ok(())
}
/// Désinstalle le service Windows imagod-web
///
/// Équivalent à:
/// nssm stop ImagodWeb
/// nssm remove ImagodWeb confirm
pub fn uninstall() -> Result<()> {
pub fn uninstall() -> anyhow::Result<()> {
if !cfg!(windows) {
anyhow::bail!("La désinstallation de service Windows n'est disponible que sous Windows");
anyhow::bail!("Désinstallation disponible uniquement 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<bool> {
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());
println!("Désinstallation du service...");
Ok(())
}