Multi-Hoster-Upload-2/src-tauri/src/folder_monitor.rs
Claude 615161d747 Full port: v1 renderer shim + folder monitor + remote server + updater + upload log fallback
Major additions:

Frontend — v1 renderer/app.js and renderer/index.html copied 1:1. A
new tauri-shim.js reconstructs window.api with all 54 methods v1 used,
each mapped to a matching Rust #[tauri::command] so the existing
renderer works unchanged. Drag-drop paths are bridged via the Tauri
tauri://drag-drop event.

Backend modules:
  - folder_monitor.rs: notify-debouncer-full based recursive/
    non-recursive watch, include/exclude extension filter, skip-dupes,
    initial baseline scan, emits 'folder-monitor-new-files' to frontend.
    Auto-restarts on launch if persisted settings have enabled=true.
  - remote_server.rs: axum HTTP server with bearer-token auth exposing
    /api/status, /api/control/cancel, /api/control/finish-after.
    Auto-restarts on launch if enabled + token set.
  - updater.rs: Gitea releases polling + semver compare, returns
    download URL for current platform. install_update opens the URL
    externally (true in-app update needs signing cert later).
  - upload_log.rs: full fallback ladder (primary → Desktop → AppData),
    daily-log suffix handling, auto-persists working fallback path into
    globalSettings.logFilePath so next session writes there directly,
    emits 'upload-log-fallback' to the renderer once per session.

Commands added (all wired into tauri::generate_handler!):
  get_hoster_settings, save_hoster_settings, get_global_settings,
  save_global_settings, set_always_on_top, get_always_on_top,
  set_shutdown_after_finish, get_shutdown_after_finish, cancel_shutdown,
  resolve_folder_files, copy_to_clipboard (Windows clipboard via
  PowerShell pipe), start_upload, add_jobs_to_batch,
  finish_after_active, run_health_check, export_backup, import_backup,
  import_backup_saved (legacy-password path), read_own_upload_log,
  import_upload_log, save_text_file, open_log_folder,
  read_rotation_log, get_version, check_for_update, install_update,
  folder_monitor_start/stop/status, remote_get_settings,
  remote_save_settings, remote_generate_token, remote_status,
  show_drop_target, hide_drop_target, debug_log.

Release build: exe 7.5 MB, NSIS 2.7 MB, MSI 3.7 MB.
2026-04-20 17:41:11 +02:00

182 lines
6.6 KiB
Rust

//! Folder monitor — watches a directory for new files and emits
//! `folder-monitor-new-files` with absolute paths to the renderer.
//!
//! Reflects the v1 `lib/folder-monitor.js` design: debounced notify events,
//! extension include/exclude filter, skip-duplicates against a seen-set,
//! optional recursive watch, initial scan on start.
use notify::{EventKind, RecursiveMode, Watcher};
use notify_debouncer_full::{new_debouncer, DebouncedEvent, DebounceEventResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct FolderMonitorSettings {
pub enabled: bool,
pub folder_path: String,
#[serde(default)]
pub recursive: bool,
#[serde(default = "default_filter_mode")]
pub filter_mode: String,
#[serde(default)]
pub extensions: String,
#[serde(default = "truthy")]
pub skip_duplicates: bool,
#[serde(default = "default_delay")]
pub delay_sec: u64,
}
fn default_filter_mode() -> String { "include".into() }
fn truthy() -> bool { true }
fn default_delay() -> u64 { 3 }
pub struct FolderMonitor {
app: AppHandle,
stop_tx: Mutex<Option<mpsc::Sender<()>>>,
current: Mutex<Option<FolderMonitorSettings>>,
seen: Arc<Mutex<HashSet<PathBuf>>>,
}
impl FolderMonitor {
pub fn new(app: AppHandle) -> Self {
Self {
app,
stop_tx: Mutex::new(None),
current: Mutex::new(None),
seen: Arc::new(Mutex::new(HashSet::new())),
}
}
pub fn is_running(&self) -> bool {
self.stop_tx.lock().is_some()
}
pub fn current(&self) -> Option<FolderMonitorSettings> {
self.current.lock().clone()
}
pub async fn start(&self, settings: FolderMonitorSettings) -> Result<(), String> {
self.stop().await;
if !settings.enabled || settings.folder_path.is_empty() {
return Ok(());
}
let root = PathBuf::from(&settings.folder_path);
if !root.exists() {
return Err(format!("Ordner existiert nicht: {}", root.display()));
}
let recursive = if settings.recursive { RecursiveMode::Recursive } else { RecursiveMode::NonRecursive };
let extensions = parse_extensions(&settings.extensions);
let filter_include = settings.filter_mode == "include";
let skip_dup = settings.skip_duplicates;
let delay = Duration::from_secs(settings.delay_sec.max(1));
let seen = self.seen.clone();
// Seed baseline so existing files don't get queued on startup.
if skip_dup {
let mut s = seen.lock();
s.clear();
walk_collect(&root, settings.recursive, &mut s);
}
let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
let (event_tx, mut event_rx) = mpsc::channel::<Vec<PathBuf>>(32);
let app = self.app.clone();
tokio::spawn(async move {
while let Some(paths) = event_rx.recv().await {
if paths.is_empty() { continue; }
let filtered: Vec<String> = paths.into_iter()
.filter(|p| path_matches(p, &extensions, filter_include))
.filter_map(|p| p.to_str().map(|s| s.to_string()))
.collect();
if filtered.is_empty() { continue; }
let _ = app.emit("folder-monitor-new-files", filtered);
}
});
let event_tx_cloned = event_tx.clone();
let seen_cloned = seen.clone();
tokio::task::spawn_blocking(move || {
let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<DebounceEventResult>();
let mut debouncer = match new_debouncer(
delay,
None,
move |res: DebounceEventResult| { let _ = debounce_tx.send(res); },
) {
Ok(d) => d,
Err(e) => { tracing::error!("folder-monitor debouncer: {e}"); return; }
};
if let Err(e) = debouncer.watch(&root, recursive) {
tracing::error!("folder-monitor watch: {e}");
return;
}
loop {
match debounce_rx.recv_timeout(Duration::from_millis(250)) {
Ok(Ok(events)) => {
let mut new_paths = Vec::new();
for ev in events {
if matches!(ev.event.kind, EventKind::Create(_) | EventKind::Modify(_)) {
for p in &ev.paths {
if p.is_file() {
let mut s = seen_cloned.lock();
if skip_dup && !s.insert(p.clone()) { continue; }
new_paths.push(p.clone());
}
}
}
}
if !new_paths.is_empty() {
let _ = event_tx_cloned.blocking_send(new_paths);
}
}
Ok(Err(_)) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if stop_rx.try_recv().is_ok() { break; }
}
Err(_) => break,
}
}
});
*self.stop_tx.lock() = Some(stop_tx);
*self.current.lock() = Some(settings);
Ok(())
}
pub async fn stop(&self) {
let tx = self.stop_tx.lock().take();
if let Some(tx) = tx { let _ = tx.send(()).await; }
*self.current.lock() = None;
}
}
fn parse_extensions(s: &str) -> Vec<String> {
s.split(',')
.map(|x| x.trim().trim_start_matches('.').to_lowercase())
.filter(|x| !x.is_empty())
.collect()
}
fn path_matches(p: &Path, extensions: &[String], include: bool) -> bool {
if extensions.is_empty() { return true; }
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).unwrap_or_default();
let listed = extensions.iter().any(|e| *e == ext);
if include { listed } else { !listed }
}
fn walk_collect(dir: &Path, recursive: bool, out: &mut HashSet<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let p = entry.path();
if p.is_file() { out.insert(p); }
else if p.is_dir() && recursive { walk_collect(&p, recursive, out); }
}
}