The searchable PDF can be archived content-addressed under the local data dir with its sha256 and layout fingerprint; a different PDF for an archived invoice is refused and reads verify the hash. Export writes go through a raw-body command that remembers the last folder; reveal-in-folder and open fall back to xdg-open / explorer. Adds a Windows-safe export file name builder. Migration M4 adds the archive columns and last export dir.
93 lines
3.1 KiB
Rust
93 lines
3.1 KiB
Rust
//! Helpers for commands that take their payload as a raw IPC body (no base64).
|
|
//!
|
|
//! The webview sends `invoke(cmd, Uint8Array, { headers })`; Tauri hands the bytes over as
|
|
//! `InvokeBody::Raw` and the headers as an `http::HeaderMap`. Header values must be visible
|
|
//! ASCII (`HeaderValue::from_str` rejects anything else), so non-ASCII data such as file
|
|
//! paths is percent-encoded by the caller.
|
|
use percent_encoding::percent_decode_str;
|
|
use std::collections::HashMap;
|
|
use tauri::ipc::{InvokeBody, Request};
|
|
|
|
/// Lower-cased header name to value; values that are not valid UTF-8 are dropped.
|
|
pub type Headers = HashMap<String, String>;
|
|
|
|
pub fn raw_body<'a>(request: &'a Request<'_>) -> Result<&'a [u8], String> {
|
|
match request.body() {
|
|
InvokeBody::Raw(bytes) => Ok(bytes),
|
|
InvokeBody::Json(_) => Err("Expected a raw binary request body".to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn header_map(request: &Request<'_>) -> Headers {
|
|
request
|
|
.headers()
|
|
.iter()
|
|
.filter_map(|(name, value)| {
|
|
let value = value.to_str().ok()?;
|
|
Some((name.as_str().to_ascii_lowercase(), value.to_string()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn required_header<'a>(headers: &'a Headers, name: &str) -> Result<&'a str, String> {
|
|
headers
|
|
.get(name)
|
|
.map(String::as_str)
|
|
.ok_or_else(|| format!("Missing {name} header"))
|
|
}
|
|
|
|
pub fn decode_header_path(value: &str) -> Result<String, String> {
|
|
percent_decode_str(value)
|
|
.decode_utf8()
|
|
.map(|s| s.into_owned())
|
|
.map_err(|_| "The path header is not valid UTF-8".to_string())
|
|
}
|
|
|
|
/// Write `bytes` to `path` via a sibling temp file and a rename, so a crash never leaves a
|
|
/// half-written file under the final name.
|
|
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
|
|
use std::io::Write;
|
|
let mut tmp_name = path
|
|
.file_name()
|
|
.ok_or_else(|| "The path has no file name".to_string())?
|
|
.to_os_string();
|
|
tmp_name.push(".tmp");
|
|
let tmp = path.with_file_name(tmp_name);
|
|
let result = (|| {
|
|
let mut file = std::fs::File::create(&tmp)?;
|
|
file.write_all(bytes)?;
|
|
file.sync_all()?;
|
|
std::fs::rename(&tmp, path)
|
|
})();
|
|
if let Err(e) = result {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
return Err(format!("Could not write {}: {e}", path.display()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn decodes_percent_encoded_paths() {
|
|
assert_eq!(
|
|
decode_header_path("%2Ftmp%2Fa%20b%2F%E0%A6%AC.pdf").unwrap(),
|
|
"/tmp/a b/\u{9ac}.pdf"
|
|
);
|
|
assert!(decode_header_path("%FF").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn atomic_write_replaces_and_leaves_no_temp_file() {
|
|
let dir = tempdir().unwrap();
|
|
let target = dir.path().join("out.pdf");
|
|
write_atomic(&target, b"one").unwrap();
|
|
write_atomic(&target, b"two").unwrap();
|
|
assert_eq!(std::fs::read(&target).unwrap(), b"two");
|
|
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
|
|
}
|
|
}
|