chore: initialiser le projet imagod-web (Rust)

- Structure du projet Cargo avec dépendances (clap, axum, reqwest, rusqlite, arboard, tray-icon)
- CLI avec sous-commandes start, serve, stop, status, config, install, uninstall, logs
- Module config: parsing TOML, chemins Windows/Linux
- Module dedup: dédoublonnage SHA-256 avec cache SQLite
- Module imago_client: client HTTP pour API Imago (upload, search, get)
- Module watcher: boucle de polling stub
- Module server: serveur web axum avec routes API REST stub
- Platform-specific: clipboard, tray, service, window_info (Windows)
- Frontend placeholder HTML
- Config par défaut (imagod.toml)
- README.md, LICENSE (MIT), .gitignore
This commit is contained in:
2026-06-22 16:44:47 -04:00
commit a3b853de04
19 changed files with 923 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
//! Parsing CLI avec clap
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "imagod-web",
version = env!("CARGO_PKG_VERSION"),
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"
)]
pub struct Args {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Lancer le watcher + dashboard (Windows uniquement)
Start {
/// Port du dashboard web
#[arg(long, default_value = "9090")]
port: u16,
/// Ouvrir le dashboard dans le navigateur
#[arg(long)]
open_browser: bool,
},
/// Lancer le dashboard web uniquement (Linux/serveur)
Serve {
/// Port du dashboard web
#[arg(long, default_value = "9090")]
port: u16,
/// Interface d'écoute
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// Désactiver le watcher clipboard
#[arg(long)]
no_watcher: bool,
/// Ouvrir le dashboard dans le navigateur
#[arg(long)]
open_browser: bool,
},
/// Arrêter le service
Stop,
/// Afficher l'état du service
Status,
/// Gérer la configuration
Config {
#[command(subcommand)]
action: ConfigAction,
},
/// Installer comme service Windows
Install,
/// Désinstaller le service Windows
Uninstall,
/// Afficher les derniers logs
Logs,
}
#[derive(Subcommand)]
pub enum ConfigAction {
/// Afficher la configuration actuelle
Show,
/// Modifier un paramètre
Set {
/// Clé de configuration (ex: imago.url)
key: String,
/// Valeur
value: String,
},
/// Réinitialiser la configuration par défaut
Reset,
}
pub fn parse() -> Args {
Args::parse()
}
+16
View File
@@ -0,0 +1,16 @@
//! Clipboard — Abstraction arboard (Windows)
//!
//! Phase 3: Lecture du clipboard Windows, détection d'images
use anyhow::Result;
/// Vérifie si le clipboard contient une image
pub fn has_image() -> Result<bool> {
// Phase 3: Implémentation arboard
todo!("Phase 3: Clipboard detection")
}
/// Récupère l'image du clipboard en bytes
pub fn get_image_bytes() -> Result<Vec<u8>> {
todo!("Phase 3: Clipboard image extraction")
}
+132
View File
@@ -0,0 +1,132 @@
//! Gestion de la configuration TOML
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Configuration complète d'imagod-web
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub imago: ImagoConfig,
#[serde(default)]
pub watcher: WatcherConfig,
#[serde(default)]
pub dedup: DedupConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub logging: LoggingConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImagoConfig {
pub url: String,
pub api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatcherConfig {
pub capture_images: bool,
pub poll_interval_ms: u64,
pub filters: WatcherFilters,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatcherFilters {
pub only_apps: Vec<String>,
pub exclude_apps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DedupConfig {
pub method: String,
pub cache_size: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
pub show_notifications: bool,
pub show_tray_icon: bool,
pub notification_timeout_sec: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub level: String,
}
impl Default for Config {
fn default() -> Self {
Self {
imago: ImagoConfig {
url: "http://imago.dracodev.net:8000".into(),
api_key: String::new(),
},
watcher: WatcherConfig {
capture_images: true,
poll_interval_ms: 500,
filters: WatcherFilters {
only_apps: vec![],
exclude_apps: vec!["mstsc".into()],
},
},
dedup: DedupConfig {
method: "sha256".into(),
cache_size: 10000,
},
ui: UiConfig {
show_notifications: true,
show_tray_icon: true,
notification_timeout_sec: 3,
},
logging: LoggingConfig {
level: "info".into(),
},
}
}
}
impl Config {
/// Charge la configuration depuis `%APPDATA%/imagod-web/imagod.toml`
pub fn load() -> Result<Self> {
let path = Self::config_path()?;
if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Impossible de lire {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| "Erreur de parsing du fichier de configuration")?;
Ok(config)
} else {
// Premier lancement — créer la config par défaut
let config = Config::default();
config.save()?;
Ok(config)
}
}
/// Sauvegarde la configuration
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
/// Chemin du fichier de configuration
fn config_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.toml"))
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Dédoublonnage des images par hash SHA-256
use anyhow::Result;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use std::path::Path;
/// Gestionnaire de dédoublonnage
pub struct Dedup {
conn: Connection,
cache_size: usize,
}
impl Dedup {
/// Ouvre ou crée la base de dédoublonnage locale
pub fn open(db_path: &Path, cache_size: usize) -> Result<Self> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS hashes (
hash BLOB PRIMARY KEY,
image_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_hashes_created ON hashes(created_at);"
)?;
Ok(Self { conn, cache_size })
}
/// Calcule le hash SHA-256 d'un buffer
pub fn hash_bytes(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
/// Vérifie si un hash a déjà été vu
pub fn already_seen(&self, hash: &[u8]) -> Result<bool> {
let exists: bool = self.conn.query_row(
"SELECT COUNT(*) > 0 FROM hashes WHERE hash = ?1",
rusqlite::params![hash],
|row| row.get(0),
)?;
Ok(exists)
}
/// Marque un hash comme vu
pub fn mark_seen(&self, hash: &[u8], image_id: &str) -> Result<()> {
self.conn.execute(
"INSERT OR IGNORE INTO hashes (hash, image_id) VALUES (?1, ?2)",
rusqlite::params![hash, image_id],
)?;
// Nettoyage périodique si dépassement du cache
self.conn.execute(
"DELETE FROM hashes WHERE rowid NOT IN (
SELECT rowid FROM hashes ORDER BY created_at DESC LIMIT ?1
)",
rusqlite::params![self.cache_size],
)?;
Ok(())
}
/// Nombre de hashes en cache
pub fn count(&self) -> Result<usize> {
let count: usize = self.conn.query_row(
"SELECT COUNT(*) FROM hashes",
[],
|row| row.get(0),
)?;
Ok(count)
}
}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Imago Dashboard</title>
<style>
/* Phase 9: Dashboard Svelte — ici juste un placeholder */
:root {
--bg: #0d1117;
--fg: #c9d1d9;
--accent: #58a6ff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.placeholder {
text-align: center;
padding: 2rem;
}
h1 { color: var(--accent); margin-bottom: 1rem; }
p { color: #8b949e; }
</style>
</head>
<body>
<div class="placeholder">
<h1>🖼️ Imago Dashboard</h1>
<p>Dashboard web en construction — Phase 9</p>
</div>
</body>
</html>
+113
View File
@@ -0,0 +1,113 @@
//! Client HTTP pour l'API Imago
use anyhow::{Context, Result};
use reqwest::{Client, multipart};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct UploadResponse {
pub id: String,
pub filename: String,
pub status: String,
}
#[derive(Debug, Deserialize)]
pub struct ImageInfo {
pub id: String,
pub filename: String,
pub tags: Vec<String>,
pub description: Option<String>,
pub ocr_text: Option<String>,
pub created_at: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub size_bytes: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct SearchResponse {
pub results: Vec<ImageInfo>,
pub total: usize,
}
/// Client pour l'API Imago
pub struct ImagoClient {
client: Client,
base_url: String,
api_key: String,
}
impl ImagoClient {
pub fn new(base_url: String, api_key: String) -> Result<Self> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(Self { client, base_url, api_key })
}
/// Upload une image vers Imago
pub async fn upload_image(
&self,
data: Vec<u8>,
filename: &str,
window_title: Option<&str>,
) -> Result<UploadResponse> {
let part = multipart::Part::bytes(data)
.file_name(filename.to_string())
.mime_str("image/png")?;
let mut form = multipart::Form::new()
.part("file", part);
if let Some(title) = window_title {
form = form.text("window_title", title.to_string());
}
let response = self.client
.post(format!("{}/images/upload", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.multipart(form)
.send()
.await?;
let upload: UploadResponse = response
.error_for_status()?
.json()
.await?;
Ok(upload)
}
/// Recherche des images
pub async fn search(&self, query: &str, limit: usize) -> Result<SearchResponse> {
let response = self.client
.get(format!("{}/images/search", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.query(&[("q", query), ("limit", &limit.to_string())])
.send()
.await?;
let result: SearchResponse = response
.error_for_status()?
.json()
.await?;
Ok(result)
}
/// Récupère les infos d'une image
pub async fn get_image(&self, id: &str) -> Result<ImageInfo> {
let response = self.client
.get(format!("{}/images/{}", self.base_url, id))
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await?;
let info: ImageInfo = response
.error_for_status()?
.json()
.await?;
Ok(info)
}
}
+87
View File
@@ -0,0 +1,87 @@
//! 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)
//! - `stop` : Arrête le service
//! - `status`: Affiche l'état
//! - `config`: Gère la configuration
//! - `install` / `uninstall`: Service Windows
mod cli;
mod config;
mod dedup;
mod imago_client;
mod watcher;
#[cfg(target_os = "windows")]
mod clipboard;
#[cfg(target_os = "windows")]
mod tray;
#[cfg(target_os = "windows")]
mod service;
#[cfg(target_os = "windows")]
mod window_info;
mod server;
use anyhow::Result;
use tracing_subscriber::{fmt, EnvFilter};
fn setup_logging() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));
fmt()
.with_env_filter(filter)
.with_target(true)
.init();
}
#[tokio::main]
async fn main() -> Result<()> {
setup_logging();
let args = cli::parse();
match args.command {
cli::Command::Start { port, open_browser } => {
tracing::info!("Démarrage imagod-web — watcher + dashboard");
// Lance le watcher et le serveur web en parallèle
// ...
todo!("Phase 6-7: Watcher + serveur web")
}
cli::Command::Serve { port, host, no_watcher, open_browser } => {
tracing::info!("Démarrage dashboard web uniquement sur {}:{}", host, port);
// Lance uniquement le serveur web
// ...
todo!("Phase 9: Dashboard web")
}
cli::Command::Stop => {
tracing::info!("Arrêt du service");
todo!("Phase 7: Stop")
}
cli::Command::Status => {
tracing::info!("Statut du service");
todo!("Phase 7: Status")
}
cli::Command::Config { action } => {
tracing::info!("Configuration: {:?}", action);
todo!("Phase 2: Config")
}
cli::Command::Install => {
tracing::info!("Installation comme service Windows");
todo!("Phase 10: Service install")
}
cli::Command::Uninstall => {
tracing::info!("Désinstallation du service Windows");
todo!("Phase 10: Service uninstall")
}
cli::Command::Logs => {
tracing::info!("Logs");
todo!("Phase 7: Logs")
}
}
Ok(())
}
+24
View File
@@ -0,0 +1,24 @@
//! Serveur web — Dashboard embarqué (axum)
//!
//! Phase 9: API REST + WebSocket + frontend Svelte embarqué via rust-embed
pub mod routes;
pub mod ws;
use axum::Router;
use std::net::SocketAddr;
/// Démarre le serveur web
pub async fn serve(host: &str, port: u16) -> anyhow::Result<()> {
let app = Router::new()
.merge(routes::api_routes())
.merge(routes::static_routes());
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
tracing::info!("Dashboard web démarré sur http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
+37
View File
@@ -0,0 +1,37 @@
//! Routes API REST du dashboard
use axum::{Router, routing::get};
/// Routes de l'API REST
pub fn api_routes() -> Router {
Router::new()
.route("/api/health", get(health_check))
.route("/api/images", get(list_images))
.route("/api/images/search", get(search_images))
.route("/api/stats", get(stats))
}
/// Routes statiques (frontend embarqué)
pub fn static_routes() -> Router {
// Phase 9: Servir le frontend Svelte via rust-embed
Router::new()
}
async fn health_check() -> &'static str {
"OK"
}
async fn list_images() -> &'static str {
// Phase 9: Liste paginée des images
"[]"
}
async fn search_images() -> &'static str {
// Phase 9: Recherche full-text
"[]"
}
async fn stats() -> &'static str {
// Phase 9: Statistiques
r#"{"total_images":0,"total_storage_bytes":0}"#
}
+5
View File
@@ -0,0 +1,5 @@
//! WebSocket — Mise à jour en temps réel du dashboard
//!
//! Phase 9: Notifie le frontend quand un nouveau screenshot est uploadé
// Stub — à implémenter dans Phase 9
+13
View File
@@ -0,0 +1,13 @@
//! Service Windows (NSSM / windows-service)
//!
//! Phase 10: Installation comme service Windows
use anyhow::Result;
pub fn install() -> Result<()> {
todo!("Phase 10: Service install via NSSM")
}
pub fn uninstall() -> Result<()> {
todo!("Phase 10: Service uninstall")
}
+10
View File
@@ -0,0 +1,10 @@
//! System Tray (Windows)
//!
//! Phase 8: Icône system tray avec menu contextuel
use anyhow::Result;
pub async fn run() -> Result<()> {
// Phase 8: Icône tray avec Start/Stop/Status/Quit
todo!("Phase 8: System tray")
}
+54
View File
@@ -0,0 +1,54 @@
//! Watcher — Boucle principale de polling du clipboard
//!
//! Surveille le clipboard à intervalle régulier, détecte les nouvelles images,
//! les dédoublonne et les envoie à Imago.
use anyhow::Result;
use crate::config::Config;
use crate::dedup::Dedup;
use crate::imago_client::ImagoClient;
use std::path::PathBuf;
use std::time::Duration;
pub struct Watcher {
config: Config,
dedup: Dedup,
imago: ImagoClient,
}
impl Watcher {
pub async fn new(config: Config) -> Result<Self> {
let db_path = PathBuf::from("imagod.db");
let dedup = Dedup::open(&db_path, config.dedup.cache_size)?;
let imago = ImagoClient::new(
config.imago.url.clone(),
config.imago.api_key.clone(),
)?;
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);
tracing::info!(
"Watcher démarré — polling 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;
Ok(())
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Window Info — Titre de la fenêtre active (Windows API)
//!
//! Phase 6: Capture du contexte (titre fenêtre) pour enrichir les tags
use anyhow::Result;
/// Récupère le titre de la fenêtre active
pub fn get_active_window_title() -> Result<Option<String>> {
// Phase 6: Windows API — GetForegroundWindow + GetWindowText
todo!("Phase 6: Active window title")
}