245 lines
7.8 KiB
Rust
245 lines
7.8 KiB
Rust
//! 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<String, String> {
|
|
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<String, String> {
|
|
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<PathBuf, String> {
|
|
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<String, String> {
|
|
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<AppState>) -> Result<String, String> {
|
|
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::<Vec<_>>(), [file.as_os_str()]);
|
|
}
|
|
}
|