feat: watcher (phase 3+6) + navigation dashboard (version, changelog, aide)

Phase 3 — Clipboard Watcher (Windows):
- clipboard.rs: détection d'images via arboard, conversion BGRA→PNG
- Gère les doublons consécutifs (même image dans le presse-papier)

Phase 6 — Watcher Loop:
- Windows: polling clipboard → hash → dédoublonnage → upload
- Linux/macOS: polling de dossier configurable (watch_dir)
- Ajout champ watch_dir dans WatcherConfig
- Client Imago cloné avant await (évite problème Send/Sync)

Dashboard:
- Badge version v0.1.0 dans le header
- Barre de navigation: Galerie / Changelog / Aide
- Pages changelog.html et help.html (chargement dynamique)
- Endpoint GET /api/version avec infos build
- Correction import delete inutilisé dans routes.rs
This commit is contained in:
2026-06-25 09:41:51 -04:00
parent 9b06df737d
commit 3bab62733d
11 changed files with 608 additions and 36 deletions
Generated
+1
View File
@@ -1276,6 +1276,7 @@ dependencies = [
"clap",
"dashmap",
"futures-util",
"image",
"libc",
"notify",
"open",
+3
View File
@@ -47,6 +47,9 @@ dashmap = "6"
# Time formatting
chrono = { version = "0.4", features = ["serde"] }
# Image encoding (PNG conversion pour clipboard → upload)
image = { version = "0.25", default-features = false, features = ["png"] }
# Platform-specific — Windows
[target.'cfg(windows)'.dependencies]
# Clipboard access
+2 -2
View File
@@ -9,8 +9,8 @@ use clap::{Parser, Subcommand};
about = "Imago Daemon + Dashboard — Capture automatique de screenshots + Dashboard web embarqué",
long_about = "Service de capture automatique de screenshots vers Imago avec dashboard web embarqué.\n\n\
Modes :\n \
Windows : imagod-web start → watcher clipboard + dashboard :9090\n \
Linux : imagod-web serve --port 9090 --no-watcher → dashboard uniquement"
Windows : imagod-web start → watcher clipboard + dashboard\n \
Linux : imagod-web serve → watcher dossier + dashboard"
)]
pub struct Args {
#[command(subcommand)]
+53 -7
View File
@@ -1,16 +1,62 @@
//! Clipboard — Abstraction arboard (Windows)
//! Clipboard — Détection et extraction d'images du presse-papier
//!
//! Phase 3: Lecture du clipboard Windows, détection d'images
//! Phase 3: Lecture du clipboard via arboard (Windows).
//! Sur Linux, ce module n'est pas compilé — le watcher utilise notify.
use anyhow::Result;
use anyhow::{Context, Result};
use std::io::Cursor;
/// Vérifie si le clipboard contient une image
pub fn has_image() -> Result<bool> {
// Phase 3: Implémentation arboard
todo!("Phase 3: Clipboard detection")
let mut clipboard = arboard::Clipboard::new()
.context("Impossible d'accéder au presse-papier")?;
Ok(clipboard.get_image().is_ok())
}
/// Récupère l'image du clipboard en bytes
/// Récupère l'image du clipboard en bytes PNG
///
/// arboard retourne l'image au format BGRA sur Windows.
/// On convertit en RGBA puis on encode en PNG via le crate `image`.
pub fn get_image_bytes() -> Result<Vec<u8>> {
todo!("Phase 3: Clipboard image extraction")
let mut clipboard = arboard::Clipboard::new()
.context("Impossible d'accéder au presse-papier")?;
let img_data = clipboard
.get_image()
.context("Aucune image dans le presse-papier")?;
let width = img_data.width as u32;
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
let rgba_bytes: Vec<u8> = if cfg!(target_os = "windows") {
img_data
.bytes
.chunks_exact(4)
.flat_map(|chunk| [chunk[2], chunk[1], chunk[0], chunk[3]])
.collect()
} else {
// Linux/macOS: déjà RGBA
img_data.bytes
};
let img = image::RgbaImage::from_raw(width, height, rgba_bytes)
.context("Impossible de créer le buffer image")?;
let mut png_bytes = Vec::new();
img.write_to(
&mut Cursor::new(&mut png_bytes),
image::ImageFormat::Png,
)
.context("Encodage PNG échoué")?;
tracing::debug!(
"Image clipboard capturée : {}×{} ({} octets PNG)",
width,
height,
png_bytes.len()
);
Ok(png_bytes)
}
+5
View File
@@ -37,6 +37,10 @@ pub struct WatcherConfig {
pub capture_images: bool,
#[serde(default = "default_poll_interval")]
pub poll_interval_ms: u64,
/// Dossier à surveiller pour les nouveaux screenshots (Linux/macOS)
/// Si vide, utilise le clipboard (Windows uniquement)
#[serde(default)]
pub watch_dir: Option<String>,
#[serde(default)]
pub filters: WatcherFilters,
}
@@ -46,6 +50,7 @@ impl Default for WatcherConfig {
Self {
capture_images: true,
poll_interval_ms: 500,
watch_dir: None,
filters: WatcherFilters::default(),
}
}
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Changelog</title></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;padding:24px;line-height:1.6;max-width:900px;margin:0 auto">
<style>
a { color:#58a6ff }
h1 { border-bottom:1px solid #30363d;padding-bottom:8px }
h2 { margin-top:24px;color:#58a6ff }
h3 { margin-top:16px;color:#79c0ff }
li { margin:4px 0 }
code { background:#161b22;padding:2px 6px;border-radius:4px;font-size:13px }
table { border-collapse:collapse;width:100%;margin:12px 0 }
td,th { border:1px solid #30363d;padding:8px 12px;text-align:left;font-size:13px }
th { background:#161b22 }
.tag { font-size:10px;padding:2px 6px;border-radius:8px;margin-right:4px }
.tag-feat { background:rgba(88,166,255,0.15);color:#58a6ff }
.tag-fix { background:rgba(63,185,80,0.15);color:#3fb950 }
.tag-docs { background:rgba(210,153,34,0.15);color:#d29922 }
</style>
<h1>📋 Changelog — imagod-web</h1>
<h2>v0.1.0 — 2026-06-25</h2>
<h3><span class="tag tag-feat">feat</span> Dashboard Web</h3>
<ul>
<li>Authentification par clé API Imago (login/logout, localStorage)</li>
<li>Galerie responsive (grille adaptative desktop/mobile, breakpoints 768px/400px)</li>
<li>Upload par drag & drop avec feedback visuel</li>
<li>Raccourcis clavier : Ctrl+U (upload), Ctrl+V (coller)</li>
<li>Recherche plein texte (nom, tags, description)</li>
<li>Pagination par pages de 24 images</li>
<li>Modale détail : full résolution, EXIF, OCR, description AI</li>
<li>Suppression avec boîte de confirmation</li>
<li>Gestion des tags : ajout, suppression, édition inline</li>
<li>Notifications toast (succès/erreur)</li>
<li>FAB mobile pour upload</li>
<li>Connexion WebSocket temps réel</li>
<li>Statistiques (nombre d'images, stockage, tags, uptime)</li>
</ul>
<h3><span class="tag tag-feat">feat</span> Client API Imago v2</h3>
<ul>
<li>8 endpoints : list, search, get, upload, thumbnail, download, delete, tags</li>
<li>Proxy des URLs signées (thumbnail-url / download-url)</li>
<li>Auth par header X-API-Key</li>
</ul>
<h3><span class="tag tag-feat">feat</span> Watcher (Phase 3 + 6)</h3>
<ul>
<li>Windows : polling clipboard via arboard, conversion PNG automatique</li>
<li>Linux/macOS : surveillance de dossier configurable (polling)</li>
<li>Pipeline : détection → hash SHA-256 → dédoublonnage → upload → log</li>
<li>Configuration : intervalle de polling, dossier à surveiller, filtres</li>
</ul>
<h3><span class="tag tag-feat">feat</span> CLI</h3>
<ul>
<li>Commandes : start, serve, stop, status, config, install, uninstall, logs</li>
<li>Flags : --host, --port, --api-key, --api-url, --no-watcher</li>
<li>Gestion PID pour éviter les doublons d'instance</li>
</ul>
<h3><span class="tag tag-feat">feat</span> Dédoublonnage</h3>
<ul>
<li>Hash SHA-256 des images</li>
<li>Base SQLite locale (imagod.db)</li>
<li>Cache LRU configurable</li>
</ul>
<h3><span class="tag tag-fix">fix</span> Corrections</h3>
<ul>
<li>Structs Imago v2 (ExifData, OcrData, AiData) match l'API réelle</li>
<li>Affichage via URLs signées (thumbnail-url/download-url)</li>
<li>Fallback placeholder DOM quand pas de thumbnail</li>
<li>Import delete inutilisé retiré</li>
</ul>
<h3><span class="tag tag-docs">docs</span> Documentation</h3>
<ul>
<li>CHANGELOG.md dans le repo</li>
<li>ROADMAP.md avec planification des phases</li>
<li>Navigation in-app : Galerie / Changelog / Aide</li>
</ul>
<h2>Architecture</h2>
<table>
<tr><th>Composant</th><th>Technologie</th><th>Statut</th></tr>
<tr><td>Daemon</td><td>Rust (tokio, clap)</td><td>✅</td></tr>
<tr><td>Serveur web</td><td>axum 0.8 + tower-http</td><td>✅</td></tr>
<tr><td>Frontend</td><td>Vanilla JS + rust-embed</td><td>✅</td></tr>
<tr><td>Client API</td><td>reqwest (rustls)</td><td>✅</td></tr>
<tr><td>Dédoublonnage</td><td>SHA-256 + SQLite</td><td>✅</td></tr>
<tr><td>Temps réel</td><td>WebSocket (broadcast)</td><td>✅</td></tr>
</table>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Aide</title></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;padding:24px;line-height:1.7;max-width:900px;margin:0 auto">
<style>
a { color:#58a6ff }
h1 { border-bottom:1px solid #30363d;padding-bottom:8px }
h2 { margin-top:28px;color:#58a6ff }
h3 { margin-top:20px;color:#79c0ff }
li { margin:6px 0 }
code { background:#161b22;padding:2px 6px;border-radius:4px;font-size:13px }
pre { background:#161b22;padding:12px 16px;border-radius:8px;overflow-x:auto;font-size:13px;border:1px solid #30363d }
.highlight { background:rgba(88,166,255,0.1);border-left:3px solid #58a6ff;padding:8px 16px;border-radius:0 8px 8px 0;margin:12px 0 }
.tip { background:rgba(63,185,80,0.1);border-left:3px solid #3fb950;padding:8px 16px;border-radius:0 8px 8px 0;margin:12px 0 }
</style>
<h1>❓ À propos d'imagod-web</h1>
<h2>Qu'est-ce que c'est ?</h2>
<p>
<strong>imagod-web</strong> est une application de bureau (daemon + dashboard web)
qui capture automatiquement vos screenshots et les envoie vers
<a href="https://git.dracodev.net/Projets/Imago" target="_blank">Imago</a>,
un backend intelligent de gestion d'images.
</p>
<div class="highlight">
<strong>🎯 Objectif :</strong> ne plus jamais perdre un screenshot. Chaque capture
est automatiquement analysée (OCR, vision IA, EXIF) et devient consultable
via ce dashboard web.
</div>
<h2>Fonctionnalités</h2>
<ul>
<li><strong>📸 Capture automatique</strong> — Windows : détection clipboard, Linux : surveillance de dossier</li>
<li><strong>🧠 Analyse IA</strong> — description du contenu, tags automatiques, texte OCR</li>
<li><strong>🔍 Recherche puissante</strong> — par nom, tags, description, texte extrait</li>
<li><strong>🏷️ Tags personnalisés</strong> — ajoutez, modifiez, supprimez des tags</li>
<li><strong>📊 Dashboard web</strong> — galerie responsive, upload drag & drop, modale détail</li>
<li><strong>🔄 Temps réel</strong> — WebSocket pour voir les nouvelles captures instantanément</li>
<li><strong>📱 Mobile-friendly</strong> — interface adaptative desktop et mobile</li>
</ul>
<h2>Modes de fonctionnement</h2>
<h3>Windows</h3>
<pre>imagod-web start</pre>
<p>Lance le watcher (surveillance du presse-papier) + dashboard sur <code>http://127.0.0.1:9090</code>.</p>
<h3>Linux / macOS / Serveur</h3>
<pre>imagod-web serve --host 0.0.0.0 --port 9199</pre>
<p>Lance le dashboard + watcher de dossier. Configurez le dossier à surveiller dans <code>~/.config/imagod-web/imagod.toml</code> :</p>
<pre>[watcher]
poll_interval_ms = 1000
watch_dir = "/home/user/Pictures/Screenshots"</pre>
<div class="tip">
<strong>💡 Astuce :</strong> utilisez <code>--no-watcher</code> pour lancer uniquement le dashboard sans surveillance.
</div>
<h2>Raccourcis clavier</h2>
<ul>
<li><code>Ctrl+U</code> — Ouvrir le sélecteur de fichiers</li>
<li><code>Ctrl+V</code> — Coller une image depuis le presse-papier</li>
<li><code>Escape</code> — Fermer la modale</li>
<li><code>← →</code> — Navigation dans la pagination</li>
</ul>
<h2>Architecture</h2>
<pre>
┌─────────────┐ ┌──────────────┐ ┌──────────┐
│ Screenshot │────▶│ imagod-web │────▶│ Imago │
│ (Windows/ │ │ (Rust) │ │ (Python)│
│ Linux) │ │ │ │ │
└─────────────┘ │ • Watcher │ │ • Stock │
│ • Hash │ │ • OCR │
│ • Upload │ │ • IA │
│ • Dashboard │ │ • EXIF │
└──────────────┘ └──────────┘
</pre>
<h2>Liens</h2>
<ul>
<li><a href="https://git.dracodev.net/Projets/imagod-web" target="_blank">📦 Code source imagod-web</a></li>
<li><a href="https://git.dracodev.net/Projets/Imago" target="_blank">🖼️ Backend Imago</a></li>
<li><a href="http://dev.lab.home:3001" target="_blank">⚙️ Admin Imago (clés API)</a></li>
</ul>
<div class="tip">
<strong>📝 Besoin d'aide ?</strong> Contactez Bruno Charest via le repo Gitea pour toute question ou suggestion.
</div>
</body>
</html>
+145
View File
@@ -47,6 +47,59 @@
.ws-dot.off { background: var(--red); }
.ws-status { font-size: 12px; color: var(--fg-dim); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
/* ─── Version ─── */
.version-badge {
font-size: 10px; padding: 2px 8px; border-radius: 10px;
background: rgba(88,166,255,0.1); color: var(--accent);
font-weight: 500; white-space: nowrap;
border: 1px solid rgba(88,166,255,0.2);
}
/* ─── Nav ─── */
.nav-bar {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--bg); padding: 0 24px;
position: sticky; top: 56px; z-index: 99;
}
.nav-tab {
padding: 10px 20px; font-size: 13px; font-weight: 500;
color: var(--fg-dim); cursor: pointer; border: none;
background: none; border-bottom: 2px solid transparent;
transition: all 0.15s; white-space: nowrap;
font-family: inherit;
}
.nav-tab:hover { color: var(--fg); }
.nav-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* ─── Pages ─── */
.page { display: none; }
.page.active { display: block; }
.page-content {
max-width: 900px; margin: 0 auto; padding: 24px;
line-height: 1.7;
}
.page-content h1 { border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 16px; }
.page-content h2 { margin-top: 24px; color: var(--accent); }
.page-content h3 { margin-top: 16px; color: var(--accent-hover); }
.page-content ul, .page-content ol { padding-left: 24px; }
.page-content li { margin: 4px 0; }
.page-content pre {
background: var(--bg-card); padding: 12px 16px; border-radius: var(--radius);
overflow-x: auto; font-size: 13px; border: 1px solid var(--border);
}
.page-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.page-content td, .page-content th { border: 1px solid var(--border); padding: 8px 12px; font-size: 13px; }
.page-content th { background: var(--bg-card); }
.page-content a { color: var(--accent); }
.page-content .callout {
border-left: 3px solid var(--accent); padding: 8px 16px;
background: rgba(88,166,255,0.05); border-radius: 0 var(--radius) var(--radius) 0;
margin: 12px 0;
}
.page-content .callout.tip {
border-left-color: var(--green); background: rgba(63,185,80,0.05);
}
/* ─── Search ─── */
.search-bar { display: flex; gap: 8px; flex: 1; min-width: 180px; }
.search-bar input {
@@ -361,13 +414,24 @@
<button class="btn" id="clear-btn" style="display:none">✕</button>
</div>
<button class="btn accent" id="header-upload-btn" onclick="document.getElementById('drop-zone').scrollIntoView({behavior:'smooth'}); document.getElementById('file-input').click()">📤 Upload</button>
<span class="version-badge" id="version-badge">v0.1.0</span>
</div>
<!-- ─── Navigation ─── -->
<nav class="nav-bar" id="nav-bar">
<button class="nav-tab active" data-page="gallery">🖼️ Galerie</button>
<button class="nav-tab" data-page="changelog">📋 Changelog</button>
<button class="nav-tab" data-page="help">❓ Aide</button>
</nav>
<!-- ─── Toast Container ─── -->
<div class="toast-container" id="toast-container"></div>
<!-- ─── Main Content ─── -->
<div class="main">
<!-- Page: Galerie -->
<div class="page active" id="page-gallery">
<!-- Stats -->
<div class="stats-row" id="stats-row">
<div class="stat-card"><div class="stat-label">Images</div><div class="stat-value" id="stat-images">…</div></div>
@@ -392,6 +456,27 @@
<div style="font-size:48px;opacity:0.35">🔍</div>
<p>Aucun résultat trouvé</p>
</div>
</div><!-- /#page-gallery -->
<!-- Page: Changelog -->
<div class="page" id="page-changelog">
<div class="page-content" id="changelog-content">
<div style="text-align:center;padding:40px;color:var(--fg-dim)">
<div style="font-size:32px;margin-bottom:12px">📋</div>
Chargement du changelog…
</div>
</div>
</div>
<!-- Page: Aide -->
<div class="page" id="page-help">
<div class="page-content" id="help-content">
<div style="text-align:center;padding:40px;color:var(--fg-dim)">
<div style="font-size:32px;margin-bottom:12px">❓</div>
Chargement de l'aide…
</div>
</div>
</div>
</div>
<!-- ─── Modal ─── -->
@@ -409,6 +494,66 @@ let currentQuery = '';
let ws = null;
let apiKey = null;
let userName = null;
let currentNavPage = 'gallery';
// ─── Navigation ───
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => switchPage(tab.dataset.page));
});
});
function switchPage(page) {
currentNavPage = page;
document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.page === page));
document.querySelectorAll('.page').forEach(p => {
p.classList.toggle('active', p.id === 'page-' + page);
});
// Cacher/montrer les éléments spécifiques à la galerie
const fabUpload = document.getElementById('fab-upload');
if (fabUpload) fabUpload.style.display = page === 'gallery' ? '' : 'none';
// Charger le contenu si première fois
if (page === 'changelog') loadChangelog();
if (page === 'help') loadHelp();
}
let changelogLoaded = false;
let helpLoaded = false;
async function loadChangelog() {
if (changelogLoaded) return;
const container = document.getElementById('changelog-content');
try {
const r = await fetch('/changelog.html');
if (r.ok) {
let html = await r.text();
// Extraire le contenu du body (les pages sont des HTML complets)
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
container.innerHTML = m ? m[1] : html;
changelogLoaded = true;
}
} catch(e) {
container.innerHTML = '<div style="color:var(--red)">Erreur de chargement du changelog.</div>';
}
}
async function loadHelp() {
if (helpLoaded) return;
const container = document.getElementById('help-content');
try {
const r = await fetch('/help.html');
if (r.ok) {
let html = await r.text();
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
container.innerHTML = m ? m[1] : html;
helpLoaded = true;
}
} catch(e) {
container.innerHTML = '<div style="color:var(--red)">Erreur de chargement de l\'aide.</div>';
}
}
// ─── Auth Helpers ───
function getAuthHeaders() {
+12 -5
View File
@@ -1,8 +1,8 @@
//! imagod-web — Imago Daemon + Dashboard
//!
//! Point d'entrée CLI. Dispatch vers les sous-commandes :
//! - `start` : Lance le watcher clipboard + dashboard web (Windows)
//! - `serve` : Lance le dashboard web uniquement (Linux/serveur)
//! - `start` : Lance le watcher + dashboard web
//! - `serve` : Lance le dashboard web (+ watcher dossier sur Linux)
//! - `stop` : Arrête le service
//! - `status`: Affiche l'état
//! - `config show/set/reset`: Gère la configuration
@@ -235,10 +235,17 @@ async fn cmd_serve(host: &str, port: u16, no_watcher: bool, open_browser: bool)
// Optionnellement lancer le watcher
let _watcher_task = if !no_watcher {
// Sur Linux, le clipboard n'est pas supporté — avertir mais continuer
// Sur Linux/macOS, le watcher surveille un dossier (config.watcher.watch_dir)
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 watch_dir = cfg
.watcher
.watch_dir
.clone()
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
format!("{}/Pictures/Screenshots", home)
});
tracing::info!(" Dossier surveillé : {}", watch_dir);
}
let cfg = cfg.clone();
Some(tokio::spawn(async move {
+16 -1
View File
@@ -1,7 +1,7 @@
//! Routes API REST du dashboard — proxy vers Imago v2
use axum::{
Router, routing::{get, post, put, delete},
Router, routing::{get, post, put},
extract::{Multipart, Path, Query, State},
http::HeaderMap,
response::IntoResponse,
@@ -101,6 +101,7 @@ pub fn api_routes(state: AppState) -> Router {
.route("/api/images/{id}/tags", put(update_tags))
.route("/api/images/{id}/thumbnail", get(get_thumbnail))
.route("/api/stats", get(stats))
.route("/api/version", get(version))
.with_state(state)
}
@@ -110,6 +111,20 @@ async fn health_check() -> &'static str {
"OK"
}
/// Retourne la version de l'application et des infos utiles
async fn version() -> Json<serde_json::Value> {
Json(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"name": env!("CARGO_PKG_NAME"),
"description": env!("CARGO_PKG_DESCRIPTION"),
"repository": env!("CARGO_PKG_REPOSITORY"),
"rustc": option_env!("RUSTC_VERSION").unwrap_or("unknown"),
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
"target_os": std::env::consts::OS,
"target_arch": std::env::consts::ARCH,
}))
}
/// Valide une clé API utilisateur contre l'API Imago.
/// Si valide, retourne le nom du client (trouvé via les images existantes).
async fn validate_api_key(
+180 -21
View File
@@ -1,12 +1,16 @@
//! Watcher — Boucle principale de polling du clipboard
//! Watcher — Boucle principale de surveillance
//!
//! Surveille le clipboard à intervalle régulier, détecte les nouvelles images,
//! les dédoublonne et les envoie à Imago.
//! Deux modes selon la plateforme :
//! - **Windows** : polling du clipboard via arboard (clipboard.rs)
//! - **Linux/macOS** : polling d'un dossier de screenshots
//!
//! Pipeline commun : détection → hash → dédoublonnage → upload Imago → log
use anyhow::Result;
use anyhow::{Context, Result};
use crate::config::Config;
use crate::dedup::Dedup;
use crate::imago_client::ImagoClient;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
@@ -18,7 +22,7 @@ pub struct Watcher {
impl Watcher {
pub async fn new(config: Config) -> Result<Self> {
let db_path = PathBuf::from("imagod.db");
let db_path = data_dir()?.join("imagod.db");
let dedup = Dedup::open(&db_path, config.dedup.cache_size)?;
let imago = ImagoClient::new(
config.imago.url.clone(),
@@ -28,27 +32,182 @@ impl Watcher {
Ok(Self { config, dedup, imago })
}
/// Lance la boucle de polling
pub async fn run(self) -> Result<()> {
let _interval = Duration::from_millis(self.config.watcher.poll_interval_ms);
// ─── Point d'entrée (dispatch par plateforme) ───────────────────────────
/// Lance la boucle de surveillance (Windows : polling clipboard)
#[cfg(target_os = "windows")]
pub async fn run(mut self) -> Result<()> {
let interval = Duration::from_millis(self.config.watcher.poll_interval_ms);
tracing::info!(
"Watcher démarré — polling toutes les {}ms",
"🪟 Watcher Windows démarré — polling clipboard toutes les {}ms",
self.config.watcher.poll_interval_ms
);
// TODO: Phase 6 — Boucle principale
// loop {
// 1. Vérifier le clipboard (Windows: arboard, Linux: N/A)
// 2. Si nouvelle image → hash SHA-256
// 3. Vérifier dédoublonnage
// 4. Si nouveau → upload vers Imago
// 5. Marquer comme vu
// sleep(interval)
// }
tracing::warn!("Watcher::run() — implémentation Phase 6 à venir");
tokio::time::sleep(Duration::from_secs(1)).await;
let mut last_hash: Vec<u8> = Vec::new();
loop {
match crate::clipboard::has_image() {
Ok(true) => {
match crate::clipboard::get_image_bytes() {
Ok(png_bytes) => {
let hash = Dedup::hash_bytes(&png_bytes);
if hash == last_hash {
tokio::time::sleep(interval).await;
continue;
}
last_hash = hash.clone();
if self.dedup.already_seen(&hash)? {
tracing::debug!("Image déjà connue, ignorée");
tokio::time::sleep(interval).await;
continue;
}
let imago = self.imago.clone();
if let Some(image_id) = upload_to_imago(&imago, &png_bytes).await {
if let Err(e) = self.dedup.mark_seen(&hash, &image_id.to_string()) {
tracing::warn!("Erreur dédoublonnage : {}", e);
}
}
}
Err(e) => {
tracing::warn!("Erreur extraction clipboard : {:#}", e);
}
}
}
Ok(false) => {}
Err(e) => {
tracing::warn!("Erreur accès clipboard : {:#}", e);
}
}
tokio::time::sleep(interval).await;
}
}
Ok(())
/// Lance la boucle de surveillance (Linux/macOS : polling de dossier)
#[cfg(not(target_os = "windows"))]
pub async fn run(mut self) -> Result<()> {
let interval = Duration::from_millis(self.config.watcher.poll_interval_ms);
let watch_dir = self
.config
.watcher
.watch_dir
.clone()
.unwrap_or_else(default_screenshots_dir);
let path = PathBuf::from(&watch_dir);
std::fs::create_dir_all(&path)
.with_context(|| format!("Impossible de créer le dossier {}", watch_dir))?;
let mut known: HashSet<PathBuf> = list_image_files(&path);
tracing::info!(
"🐧 Watcher Linux démarré — polling {} (toutes les {}ms, {} existants ignorés)",
path.display(),
interval.as_millis(),
known.len()
);
loop {
let current = list_image_files(&path);
for f in &current {
if !known.contains(f) {
tracing::info!("📸 Nouveau fichier : {}", f.display());
tokio::time::sleep(Duration::from_millis(200)).await;
match std::fs::read(f) {
Ok(data) => {
let hash = Dedup::hash_bytes(&data);
if !self.dedup.already_seen(&hash).unwrap_or(false) {
let imago = self.imago.clone();
if let Some(image_id) = upload_to_imago(&imago, &data).await {
if let Err(e) = self.dedup.mark_seen(&hash, &image_id.to_string()) {
tracing::warn!("Erreur dédoublonnage : {}", e);
}
}
} else {
tracing::debug!("Image déjà connue : {}", f.display());
}
}
Err(e) => {
tracing::warn!("Erreur lecture {} : {}", f.display(), e);
}
}
known.insert(f.clone());
}
}
known.retain(|f| f.exists());
tokio::time::sleep(interval).await;
}
}
}
// ─── Pipeline d'upload (fonction libre, pas de borrow problématique) ──────
/// Upload l'image vers Imago. Retourne l'ID si succès.
/// Ne prend PAS `&Dedup` pour éviter le problème `!Sync` au-dessus du await.
async fn upload_to_imago(imago: &ImagoClient, data: &[u8]) -> Option<i64> {
let filename = format!(
"screenshot_{}.png",
chrono::Utc::now().format("%Y%m%d_%H%M%S")
);
tracing::info!("⬆️ Upload : {} ({} octets)", filename, data.len());
match imago
.upload_image(data.to_vec(), &filename, None)
.await
{
Ok(response) => {
tracing::info!(
"✅ Upload réussi : {} (ID: {}) — l'IA va analyser...",
filename,
response.id
);
Some(response.id)
}
Err(e) => {
tracing::error!("❌ Échec upload {} : {:#}", filename, e);
None
}
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
fn data_dir() -> 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"))
}
fn default_screenshots_dir() -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
format!("{}/Pictures/Screenshots", home)
}
fn is_image_file(path: &std::path::Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => matches!(
ext.to_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tiff"
),
None => false,
}
}
fn list_image_files(dir: &std::path::Path) -> HashSet<PathBuf> {
let mut files = HashSet::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_image_file(&path) {
files.insert(path);
}
}
}
files
}