From 92941a2758e158f23a8093e5a1060b5f36a00f28 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 22 Jun 2026 13:12:39 -0400 Subject: [PATCH] fix(persist): atomic+durable .margin writes Route save_book, write_file, write_bytes through a shared atomic_write helper: write to a temp file, fsync, then rename into place so a crash mid-write can never leave a truncated or empty manuscript. Rotate the prior copy to .bak for manuscripts (not exports) so one good copy always survives. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk --- src-tauri/src/library.rs | 2 +- src-tauri/src/project.rs | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 89ce039..98a93e0 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -72,7 +72,7 @@ pub fn load_book(app: tauri::AppHandle, id: String) -> Result { #[tauri::command] pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> { let path = library_dir(&app)?.join(format!("{id}.margin")); - fs::write(&path, contents).map_err(|e| e.to_string()) + crate::project::atomic_write(&path, contents.as_bytes(), true) } #[tauri::command] diff --git a/src-tauri/src/project.rs b/src-tauri/src/project.rs index 3e7e856..e8e27ef 100644 --- a/src-tauri/src/project.rs +++ b/src-tauri/src/project.rs @@ -1,6 +1,33 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD; -use std::fs; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) +} + +pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> { + let tmp = with_suffix(path, ".tmp"); + { + let mut f = File::create(&tmp).map_err(|e| e.to_string())?; + f.write_all(bytes).map_err(|e| e.to_string())?; + f.sync_all().map_err(|e| e.to_string())?; + } + if path.exists() { + if backup { + let bak = with_suffix(path, ".bak"); + let _ = fs::remove_file(&bak); + fs::rename(path, &bak).map_err(|e| e.to_string())?; + } else { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + } + fs::rename(&tmp, path).map_err(|e| e.to_string()) +} #[tauri::command] pub fn read_file(path: String) -> Result { @@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result { #[tauri::command] pub fn write_file(path: String, contents: String) -> Result<(), String> { - fs::write(&path, contents).map_err(|e| e.to_string()) + atomic_write(Path::new(&path), contents.as_bytes(), true) } #[tauri::command] pub fn write_bytes(path: String, data: String) -> Result<(), String> { let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?; - fs::write(&path, bytes).map_err(|e| e.to_string()) + atomic_write(Path::new(&path), &bytes, false) }