//! The Tauri-facing side of the SFV panel: compute checksums for a set of loaded //! files, save them as a sidecar checksum file (`.sfv`/`.md5`/`.sha1`), and verify a //! set of files against an existing checksum file. Wraps `aurora_curator_core::sfv`, //! which does all the actual algorithm/format work - this module is just Tauri //! commands, native file dialogs, and the async/off-main-thread plumbing. Fully local: //! unlike every other panel added this session, SFV needs no network access and no //! credentials at all. use std::path::{Path, PathBuf}; use aurora_curator_core::sfv::{self, ChecksumEntry, ChecksumKind, VerifyResult}; use serde::Serialize; use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; /// One computed checksum, or the error hashing that particular file hit (a file /// disappearing between being loaded and being hashed, a permissions problem, etc) - /// kept per-file so one bad file doesn't abort the whole batch. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ChecksumOutcome { pub path: String, pub name: String, pub checksum: Option, pub error: Option, } fn file_name_of(path: &str) -> String { Path::new(path) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string()) } /// Computes a checksum for every path in `paths`, using the algorithm `kind` selects. /// Runs the whole batch on a blocking thread (real file I/O, potentially large video /// files) rather than the async command-invocation path. #[tauri::command] pub async fn compute_checksums(paths: Vec, kind: ChecksumKind) -> Vec { tauri::async_runtime::spawn_blocking(move || { paths .into_iter() .map(|path| { let name = file_name_of(&path); match sfv::compute_checksum(Path::new(&path), kind) { Ok(checksum) => ChecksumOutcome { path, name, checksum: Some(checksum), error: None }, Err(err) => ChecksumOutcome { path, name, checksum: None, error: Some(err.to_string()) }, } }) .collect() }) .await .unwrap_or_default() } /// One row the frontend already has (a computed checksum for a loaded file) - the /// input shape `save_checksum_file` builds a sidecar file's text content from. #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChecksumFileRow { pub name: String, pub checksum: String, } /// Opens a native "Save File" dialog defaulted to `.`, /// and if the user doesn't cancel, writes the built checksum-file text there. Returns /// the saved path, or `None` if the user cancelled the dialog (not an error). #[tauri::command] pub async fn save_checksum_file(app: AppHandle, default_name: String, rows: Vec, kind: ChecksumKind) -> Result, String> { let entries: Vec = rows.into_iter().map(|r| ChecksumEntry { file_name: r.name, checksum: r.checksum }).collect(); let content = sfv::build_checksum_file(&entries, kind); let (tx, rx) = tokio::sync::oneshot::channel(); app.dialog() .file() .set_file_name(format!("{default_name}.{}", kind.extension())) .save_file(move |result| { let _ = tx.send(result); }); let chosen = rx.await.map_err(|e| e.to_string())?; let Some(file_path) = chosen else { return Ok(None); }; let path = file_path.into_path().map_err(|e| e.to_string())?; tauri::async_runtime::spawn_blocking({ let path = path.clone(); move || std::fs::write(&path, content) }) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; Ok(Some(path.to_string_lossy().to_string())) } /// What verifying one checksum file produced: the file's own directory (every entry is /// resolved relative to it) and the per-entry OK/mismatch/missing results. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct VerifyOutcome { pub base_dir: String, pub results: Vec, } /// Reads and parses `checksum_file_path` (a `.sfv`/`.md5`/`.sha1` file the user picked) /// and verifies every entry it lists against the real files on disk, resolved relative /// to the checksum file's own parent directory. #[tauri::command] pub async fn verify_checksum_file(checksum_file_path: String, kind: ChecksumKind) -> Result { tauri::async_runtime::spawn_blocking(move || { let path = PathBuf::from(&checksum_file_path); let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let entries = sfv::parse_checksum_file(&content, kind); let base_dir = path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")); let results = sfv::verify_entries(&entries, &base_dir, kind); Ok(VerifyOutcome { base_dir: base_dir.to_string_lossy().to_string(), results }) }) .await .map_err(|e| e.to_string())? } #[cfg(test)] mod tests { use super::*; #[test] fn file_name_of_strips_the_directory_portion() { assert_eq!(file_name_of("/a/b/c.mkv"), "c.mkv"); assert_eq!(file_name_of("just-a-name.mkv"), "just-a-name.mkv"); } }