Skip to main content

app_lib/
commands.rs

1use std::collections::HashMap;
2use std::net::SocketAddr;
3use std::path::PathBuf;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6use tauri::{Emitter, State, WebviewWindow};
7use toole_core::sender::start_sender;
8use toole_core::{Peer, ToolError, UI};
9
10// ───────────────────────────────────────────────
11// UI unique
12// ───────────────────────────────────────────────
13
14pub struct AppUI {
15    pub peers: Arc<Mutex<Vec<Peer>>>,
16    pub window: WebviewWindow,
17}
18
19impl UI for AppUI {
20    fn log(&self, msg: &str) {
21        let _ = self.window.emit("tool://log", msg);
22    }
23
24    fn peer_found(&self, peer: &Peer) {
25        let mut peers = self.peers.lock().unwrap_or_else(|e| e.into_inner());
26        if !peers.iter().any(|p| p.id == peer.id) {
27            peers.push(peer.clone());
28            let _ = self.window.emit("tool://peer_found", peer);
29        }
30    }
31
32    fn peer_lost(&self, id: &str) {
33        let mut peers = self.peers.lock().unwrap_or_else(|e| e.into_inner());
34        peers.retain(|p| p.id != id);
35        let _ = self.window.emit("tool://peer_lost", id);
36    }
37
38    fn show_progress_bar(&self, transfer_id: &str) {
39        let _ = self.window.emit("tool://transfer/start", transfer_id);
40    }
41
42    fn update_progress_bar(&self, transfer_id: &str, bytes_sent: u64, total_bytes: u64) {
43        let percent = if total_bytes > 0 {
44            (bytes_sent as f64 / total_bytes as f64 * 100.0).min(100.0) as u8
45        } else {
46            0
47        };
48        let payload = serde_json::json!({
49            "transfer_id": transfer_id,
50            "bytes_sent": bytes_sent,
51            "total_bytes": total_bytes,
52            "percent": percent
53        });
54        let _ = self.window.emit("tool://transfer/progress", payload);
55    }
56
57    fn file_progress_bar(
58        &self,
59        transfer_id: &str,
60        file_name: &str,
61        file_bytes_sent: u64,
62        file_total_bytes: u64,
63    ) {
64        let percent = if file_total_bytes > 0 {
65            (file_bytes_sent as f64 / file_total_bytes as f64 * 100.0).min(100.0) as u8
66        } else {
67            0
68        };
69        let payload = serde_json::json!({
70            "transfer_id": transfer_id,
71            "file_name": file_name,
72            "file_bytes_sent": file_bytes_sent,
73            "file_total_bytes": file_total_bytes,
74            "percent": percent
75        });
76        let _ = self.window.emit("tool://transfer/file_progress", payload);
77    }
78
79    fn transfert_incoming(
80        &self,
81        transfer_id: &str,
82        sender: &str,
83        total_bytes: u64,
84        files: Vec<String>,
85    ) {
86        let payload = serde_json::json!({
87            "transfer_id": transfer_id,
88            "sender": sender,
89            "total_bytes": total_bytes,
90            "files": files
91        });
92        let _ = self.window.emit("tool://transfer/incoming", payload);
93    }
94
95    fn transfert_refused(&self, transfer_id: &str) {
96        let _ = self.window.emit("tool://transfer/refused", transfer_id);
97    }
98
99    fn transfert_cancel(&self, transfer_id: &str) {
100        let _ = self.window.emit("tool://transfer/cancel", transfer_id);
101    }
102
103    fn transfert_completed(&self, transfer_id: &str) {
104        let _ = self.window.emit("tool://transfer/done", transfer_id);
105    }
106
107    fn transfert_received(&self, transfer_id: &str, peer: &str, bytes: u64, files: Vec<String>) {
108        let payload = serde_json::json!({
109            "transfer_id": transfer_id,
110            "peer": peer,
111            "bytes": bytes,
112            "files": files
113        });
114        let _ = self.window.emit("tool://transfer/received", payload);
115    }
116
117    fn transfert_error(&self, transfer_id: &str, error: &ToolError) {
118        let payload = serde_json::json!({
119            "transfer_id": transfer_id,
120            "error": error.to_string()
121        });
122        let _ = self.window.emit("tool://transfer/error", payload);
123    }
124}
125
126// ───────────────────────────────────────────────
127// États
128// ───────────────────────────────────────────────
129
130pub struct DiscoveryState {
131    pub stop_flag: Mutex<Arc<AtomicBool>>,
132    /// handle de la tâche de découverte en cours : je m'en sers pour attendre
133    /// qu'elle ait libéré la socket UDP avant d'en relancer une nouvelle
134    /// (sinon le bind du refresh échoue en « port déjà utilisé »)
135    pub handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
136    pub peers: Arc<Mutex<Vec<Peer>>>,
137}
138
139// transferts actifs (envois et réceptions) : drapeau d'arrêt + handle
140// d'abandon. Le handle est None pour les réceptions (l'annulation passe
141// par le drapeau, la connexion se ferme gracieusement).
142type ActiveTransfers =
143    Arc<Mutex<HashMap<String, (Arc<AtomicBool>, Option<tokio::task::AbortHandle>)>>>;
144
145pub struct TransferState {
146    pub active: ActiveTransfers,
147    /// registre des demandes d'acceptation en attente : respond_transfer y
148    /// résout la décision de l'utilisateur pour le récepteur
149    pub decisions: Arc<toole_core::transfer::DecisionBoard>,
150}
151
152/// implémentation du registre de transferts côté Tauri : il se branche sur la
153/// même map que TransferState pour que cancel_transfer gère les réceptions
154pub struct TransferRegistryHandle {
155    pub active: ActiveTransfers,
156}
157
158impl toole_core::TransferRegistry for TransferRegistryHandle {
159    fn register(&self, transfer_id: &str, stop: Arc<AtomicBool>) {
160        self.active
161            .lock()
162            .unwrap_or_else(|e| e.into_inner())
163            .insert(transfer_id.to_string(), (stop, None));
164    }
165
166    fn unregister(&self, transfer_id: &str) {
167        self.active.lock().unwrap_or_else(|e| e.into_inner()).remove(transfer_id);
168    }
169}
170
171// ───────────────────────────────────────────────
172// Découverte
173// ───────────────────────────────────────────────
174
175#[tauri::command]
176pub async fn start_discovery(
177    state: State<'_, DiscoveryState>,
178    window: WebviewWindow,
179) -> Result<(), String> {
180    // je stoppe l'ancienne découverte si elle tourne encore et j'attends
181    // qu'elle ait libéré la socket 58199 : sinon le bind ci-dessous échoue
182    // (AddressInUse) et la découverte ne redémarre jamais après un refresh
183    state.stop_flag.lock().unwrap_or_else(|e| e.into_inner()).store(true, Ordering::Relaxed);
184    let old = state.handle.lock().unwrap_or_else(|e| e.into_inner()).take();
185    if let Some(old) = old {
186        let _ = old.await;
187    }
188
189    // je vide la liste des pairs découverts mais je garde les pairs ajoutés
190    // manuellement : ils n'ont pas de timeout et doivent survivre au refresh
191    state.peers.lock().unwrap_or_else(|e| e.into_inner()).retain(|p| p.id.starts_with("manual-"));
192
193    let stop = Arc::new(AtomicBool::new(false));
194    *state.stop_flag.lock().unwrap_or_else(|e| e.into_inner()) = stop.clone();
195
196    let local_ip = toole_core::utils::local_ip();
197    let peers = state.peers.clone();
198    let ui: Arc<dyn UI> = Arc::new(AppUI {
199        peers,
200        window: window.clone(),
201    });
202
203    let handle = tokio::spawn(async move {
204        if let Err(e) = toole_core::discovery::start_discovery(local_ip, stop, ui).await {
205            eprintln!("Discovery error: {e}");
206            let _ = window.emit("tool://discovery/error", e.to_string());
207        }
208    });
209    *state.handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
210
211    Ok(())
212}
213
214#[tauri::command]
215pub async fn stop_discovery(state: State<'_, DiscoveryState>) -> Result<(), String> {
216    state.stop_flag.lock().unwrap_or_else(|e| e.into_inner()).store(true, Ordering::Relaxed);
217    // j'attends la fin de la tâche pour que la socket soit libérée avant que
218    // la commande ne rende la main (le refresh enchaîne sur start_discovery)
219    let h = state.handle.lock().unwrap_or_else(|e| e.into_inner()).take();
220    if let Some(h) = h {
221        let _ = h.await;
222    }
223    Ok(())
224}
225
226// ajoute un pair manuel à partir d'une IP saisie par l'utilisateur : utile
227// quand la découverte est bloquée (isolation client, pare-feu, broadcast
228// filtré). Le pair n'a pas de timeout : il reste tant que l'app tourne.
229#[tauri::command]
230pub async fn add_peer(
231    ip: String,
232    state: State<'_, DiscoveryState>,
233    window: WebviewWindow,
234) -> Result<(), String> {
235    let peer = toole_core::utils::manual_peer(&ip)
236        .ok_or_else(|| format!("Adresse invalide : {ip} (IPv4 privé attendu)"))?;
237    let mut peers = state.peers.lock().unwrap_or_else(|e| e.into_inner());
238    if peers.iter().any(|p| p.id == peer.id) {
239        return Ok(()); // déjà présent, je ne fais rien
240    }
241    peers.push(peer.clone());
242    let _ = window.emit("tool://peer_found", &peer);
243    Ok(())
244}
245
246// retire un pair ajouté manuellement : les appareils découverts disparaissent
247// d'eux-mêmes à l'expiration du timeout, seuls les pairs manuels restent
248// jusqu'à l'arrêt de l'app et méritent un retrait explicite
249#[tauri::command]
250pub async fn remove_peer(id: String, state: State<'_, DiscoveryState>) -> Result<(), String> {
251    let mut peers = state.peers.lock().unwrap_or_else(|e| e.into_inner());
252    if !id.starts_with("manual-") {
253        return Ok(()); // je ne retire que les pairs manuels
254    }
255    peers.retain(|p| p.id != id);
256    Ok(())
257}
258
259// analyse le pare-feu du système pour guider l'utilisateur si les ports
260// UDP de Toolé sont bloqués (ufw/firewalld sous Linux, règles Windows).
261// Lecture seule : les ouvertures se font à l'installation ou par l'utilisateur.
262#[tauri::command]
263pub fn check_firewall() -> Result<serde_json::Value, String> {
264    use toole_core::firewall::*;
265
266    #[cfg(target_os = "linux")]
267    {
268        let mut ufw_active = false;
269        let mut ufw_open = false;
270        if let Ok(out) = std::process::Command::new("ufw").arg("status").output() {
271            if let Ok(s) = String::from_utf8(out.stdout) {
272                let (a, o) = ufw_ports_open(&s);
273                ufw_active = a;
274                ufw_open = o;
275            }
276        }
277        let mut fw_active = false;
278        let mut fw_open = false;
279        if let Ok(out) = std::process::Command::new("firewall-cmd").arg("--list-ports").output() {
280            if let Ok(s) = String::from_utf8(out.stdout) {
281                fw_active = out.status.success();
282                fw_open = firewalld_ports_open(&s);
283            }
284        }
285        let status = linux_status(ufw_active, ufw_open, fw_active, fw_open);
286        return Ok(serde_json::to_value(status).map_err(|e| e.to_string())?);
287    }
288
289    #[cfg(target_os = "windows")]
290    {
291        // je vérifie si la règle créée par l'installeur NSIS existe : la
292        // sortie est locale-dépendante, je me base sur le code de sortie
293        // (0 = règle trouvée) et sur la présence du nom de la règle
294        let mut ports_open = false;
295        if let Ok(out) = std::process::Command::new("netsh")
296            .args(["advfirewall", "firewall", "show", "rule", "name=Toolé UDP"])
297            .output()
298        {
299            ports_open = out.status.success()
300                && String::from_utf8_lossy(&out.stdout).contains("Toolé UDP");
301        }
302        let status = FirewallStatus {
303            os: "windows".to_string(),
304            active: true,
305            ports_open,
306            commands: commands_for("windows"),
307        };
308        return Ok(serde_json::to_value(status).map_err(|e| e.to_string())?);
309    }
310
311    #[cfg(target_os = "macos")]
312    {
313        // le pare-feu applicatif macOS est géré par le système (invite à la
314        // première écoute) : rien à détecter ici
315        let status = FirewallStatus {
316            os: "macos".to_string(),
317            active: false,
318            ports_open: true,
319            commands: vec![],
320        };
321        return Ok(serde_json::to_value(status).map_err(|e| e.to_string())?);
322    }
323}
324
325// ───────────────────────────────────────────────
326// Transfert
327// ───────────────────────────────────────────────
328
329#[tauri::command]
330pub async fn send_files(
331    paths: Vec<String>,
332    peer_addr: String,
333    peer_id: String,
334    state: State<'_, TransferState>,
335    window: WebviewWindow,
336) -> Result<String, String> {
337    let transfer_id = uuid::Uuid::new_v4().to_string();
338    let stop = Arc::new(AtomicBool::new(false));
339
340    let path_bufs: Vec<PathBuf> = paths.into_iter().map(PathBuf::from).collect();
341    let addr: SocketAddr = peer_addr
342        .parse()
343        .map_err(|e| format!("Adresse invalide: {e}"))?;
344
345    let peers = Arc::new(Mutex::new(Vec::new()));
346    let ui: Arc<dyn UI> = Arc::new(AppUI { peers, window });
347    let transfer_id_clone = transfer_id.clone();
348    let stop_clone = stop.clone();
349    let active = state.active.clone();
350
351    let handle = tokio::spawn(async move {
352        let tid = transfer_id_clone.clone();
353        if let Err(e) = start_sender(ui, transfer_id_clone, path_bufs, addr, peer_id, stop_clone)
354            .await
355        {
356            eprintln!("Sender error: {e}");
357        }
358        // l'envoi est terminé (succès, erreur ou annulation) : je retire
359        // l'entrée du registre, sinon la map grossit à chaque transfert
360        active.lock().unwrap_or_else(|e| e.into_inner()).remove(&tid);
361    });
362
363    state
364        .active
365        .lock()
366        .unwrap_or_else(|e| e.into_inner())
367        .insert(transfer_id.clone(), (stop, Some(handle.abort_handle())));
368
369    Ok(transfer_id)
370}
371
372#[tauri::command]
373pub async fn cancel_transfer(
374    transfer_id: String,
375    state: State<'_, TransferState>,
376    window: WebviewWindow,
377) -> Result<(), String> {
378    let mut active = state.active.lock().unwrap_or_else(|e| e.into_inner());
379    if let Some((stop, handle)) = active.remove(&transfer_id) {
380        stop.store(true, Ordering::Relaxed);
381        // si l'envoi est bloqué (backpressure QUIC), le drapeau d'arrêt ne
382        // suffit pas : j'interromps la tâche, et comme elle ne pourra pas
383        // émettre son événement terminal, je le fais ici pour que la carte
384        // frontend passe bien en « annulé »
385        if let Some(h) = handle {
386            if !h.is_finished() {
387                h.abort();
388                let _ = window.emit("tool://transfer/cancel", &transfer_id);
389            }
390        }
391    }
392    Ok(())
393}
394
395#[tauri::command]
396pub async fn respond_transfer(
397    transfer_id: String,
398    accepted: bool,
399    state: State<'_, TransferState>,
400) -> Result<(), String> {
401    if state.decisions.resolve(&transfer_id, accepted) {
402        Ok(())
403    } else {
404        Err(format!("transfert {transfer_id} inconnu ou deja traite"))
405    }
406}
407
408// ───────────────────────────────────────────────
409// Utilitaires
410// ───────────────────────────────────────────────
411
412#[tauri::command]
413pub fn get_device_id() -> String {
414    toole_core::utils::device_id()
415}
416
417#[tauri::command]
418pub fn get_peers(state: State<'_, DiscoveryState>) -> Result<Vec<Peer>, String> {
419    let peers = state.peers.lock().unwrap_or_else(|e| e.into_inner());
420    Ok(peers.clone())
421}
422
423#[tauri::command]
424pub fn read_clipboard() -> Result<String, String> {
425    let mut cb = arboard::Clipboard::new().map_err(|e| format!("Clipboard error: {e}"))?;
426    cb.get_text()
427        .map_err(|e| format!("Clipboard read error: {e}"))
428}
429
430#[tauri::command]
431pub fn get_file_infos(paths: Vec<String>) -> Result<Vec<FileInfo>, String> {
432    paths
433        .iter()
434        .map(|p| {
435            std::fs::metadata(p)
436                .map(|m| FileInfo {
437                    size: if m.is_file() { m.len() } else { 0 },
438                    is_dir: m.is_dir(),
439                })
440                .map_err(|e| format!("Erreur {p}: {e}"))
441        })
442        .collect()
443}
444
445#[derive(serde::Serialize)]
446pub struct FileInfo {
447    pub size: u64,
448    pub is_dir: bool,
449}