//! User-visible files: exports written to a path from the save dialog, and the //! reveal/open helpers used afterwards. use super::raw::{ decode_header_path, header_map, raw_body, required_header, write_atomic, Headers, }; use crate::AppState; use rusqlite::{params, Connection}; use std::path::{Path, PathBuf}; use std::process::Command; use tauri::ipc::Request; use tauri::State; use tauri_plugin_opener::OpenerExt; pub fn write_export_file_impl( conn: &Connection, bytes: &[u8], headers: &Headers, ) -> Result { let raw = decode_header_path(required_header(headers, "x-path")?)?; if raw.trim().is_empty() { return Err("No file path given".to_string()); } let path = PathBuf::from(&raw); if !path.is_absolute() { return Err("The export path must be absolute".to_string()); } if bytes.is_empty() { return Err("Nothing to write: the file is empty".to_string()); } let parent = path .parent() .ok_or_else(|| "The export path has no folder".to_string())?; std::fs::create_dir_all(parent) .map_err(|e| format!("Could not create {}: {e}", parent.display()))?; write_atomic(&path, bytes)?; conn.execute( "UPDATE app_settings SET last_export_dir = ?1 WHERE id = 1", params![parent.to_string_lossy()], ) .map_err(|e| e.to_string())?; Ok(raw) } pub fn get_last_export_dir_impl(conn: &Connection) -> Result { conn.query_row( "SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0), ) .map_err(|e| e.to_string()) } fn require_existing(path: &str) -> Result { if path.trim().is_empty() { return Err("No file path given".to_string()); } let path = PathBuf::from(path); if !path.exists() { return Err(format!("{} does not exist", path.display())); } Ok(path) } /// Last resort when the opener plugin cannot reach a file manager (a minimal Linux /// install has no D-Bus file-manager service or portal). Arguments are passed /// individually; nothing goes through a shell. fn reveal_fallback_command(path: &Path) -> Command { #[cfg(target_os = "windows")] { let mut cmd = Command::new("explorer"); cmd.arg(format!("/select,{}", path.display())); cmd } #[cfg(target_os = "macos")] { let mut cmd = Command::new("open"); cmd.arg("-R").arg(path); cmd } #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) }; let mut cmd = Command::new("xdg-open"); cmd.arg(folder); cmd } } fn open_fallback_command(path: &Path) -> Command { #[cfg(target_os = "windows")] let mut cmd = Command::new("explorer"); #[cfg(target_os = "macos")] let mut cmd = Command::new("open"); #[cfg(not(any(target_os = "windows", target_os = "macos")))] let mut cmd = Command::new("xdg-open"); cmd.arg(path); cmd } fn spawn_detached(mut cmd: Command) -> Result<(), String> { let mut child = cmd .spawn() .map_err(|e| format!("Could not start the file manager: {e}"))?; // Reap the child so it does not linger as a zombie; the result is irrelevant // (explorer.exe, for one, exits non-zero even on success). std::thread::spawn(move || { let _ = child.wait(); }); Ok(()) } #[tauri::command] pub async fn write_export_file( request: Request<'_>, state: State<'_, AppState>, ) -> Result { let bytes = raw_body(&request)?; let headers = header_map(&request); let conn = state.db.lock().map_err(|e| e.to_string())?; write_export_file_impl(&conn, bytes, &headers) } #[tauri::command] pub fn get_last_export_dir(state: State) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; get_last_export_dir_impl(&conn) } #[tauri::command] pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> { let path = require_existing(&path)?; if app.opener().reveal_item_in_dir(&path).is_ok() { return Ok(()); } spawn_detached(reveal_fallback_command(&path)) } #[tauri::command] pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> { let path = require_existing(&path)?; if app .opener() .open_path(path.to_string_lossy(), None::<&str>) .is_ok() { return Ok(()); } spawn_detached(open_fallback_command(&path)) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn headers(path: &str) -> Headers { Headers::from([("x-path".to_string(), path.to_string())]) } fn encode(path: &Path) -> String { percent_encoding::utf8_percent_encode( &path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC, ) .to_string() } #[test] fn writes_the_file_and_remembers_the_folder() { let dir = tempdir().unwrap(); let conn = crate::db::open_in_memory().unwrap(); assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); // Non-ASCII, spaces and a folder that does not exist yet. let target = dir .path() .join("New folder") .join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf"); let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap(); assert_eq!(PathBuf::from(&written), target); assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7"); assert_eq!( get_last_export_dir_impl(&conn).unwrap(), target.parent().unwrap().to_string_lossy() ); // Overwrites in place. write_export_file_impl(&conn, b"%PDF-2", &headers(&encode(&target))).unwrap(); assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-2"); } #[test] fn rejects_empty_relative_and_missing_paths() { let conn = crate::db::open_in_memory().unwrap(); for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] { assert!( write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}" ); } assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err()); assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); } #[test] fn rejects_an_empty_body_without_touching_the_folder_setting() { let dir = tempdir().unwrap(); let conn = crate::db::open_in_memory().unwrap(); let target = dir.path().join("a.pdf"); assert!(write_export_file_impl(&conn, b"", &headers(&encode(&target))).is_err()); assert!(!target.exists()); assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); } #[test] fn reveal_and_open_refuse_missing_paths() { let dir = tempdir().unwrap(); assert!(require_existing("").is_err()); let missing = dir.path().join("nope.pdf"); assert!(require_existing(&missing.to_string_lossy()) .unwrap_err() .contains("does not exist")); assert!(require_existing(&dir.path().to_string_lossy()).is_ok()); } #[cfg(target_os = "linux")] #[test] fn linux_fallback_opens_the_parent_folder_with_separate_args() { let dir = tempdir().unwrap(); let file = dir.path().join("a b; rm -rf.pdf"); std::fs::write(&file, b"x").unwrap(); let cmd = reveal_fallback_command(&file); assert_eq!(cmd.get_program(), "xdg-open"); let args: Vec<_> = cmd.get_args().collect(); assert_eq!(args, [dir.path().as_os_str()]); let open = open_fallback_command(&file); assert_eq!(open.get_args().collect::>(), [file.as_os_str()]); } }