From 0dbbece89c524c6b1f3e3502e0ba91283723bb65 Mon Sep 17 00:00:00 2001 From: xuyua9 <15558128926@wo.cn> Date: Thu, 13 Aug 2026 07:40:14 +0800 Subject: [PATCH 1/2] fix(bindings): atomically replace persisted store --- Cargo.lock | 1 + crates/core/Cargo.toml | 3 ++ crates/core/src/bindings.rs | 99 +++++++++++++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23b3759..1630b9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -706,6 +706,7 @@ dependencies = [ "serde_json", "tokio", "url", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d91bfec..c59b712 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -11,3 +11,6 @@ serde_json = "1" rspotify = { version = "0.15.3", default-features = false, features = ["client-reqwest", "reqwest-rustls-tls"] } url = "2" rand = "0.9" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/core/src/bindings.rs b/crates/core/src/bindings.rs index 6761288..9836332 100644 --- a/crates/core/src/bindings.rs +++ b/crates/core/src/bindings.rs @@ -6,7 +6,11 @@ use crate::{ use anyhow::{bail, Context, Result}; use rand::{distr::Alphanumeric, Rng}; use serde::{Deserialize, Serialize}; -use std::{fs, path::PathBuf}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::PathBuf, +}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -128,11 +132,84 @@ fn write_store(store: &BindingStore) -> Result<()> { } let raw = serde_json::to_string_pretty(store).context("failed to serialize bindings")?; - fs::write(&path, raw) - .with_context(|| format!("failed to write bindings file at {}", path.display()))?; + let temp_path = path.with_extension(format!("json.tmp-{}", generate_binding_id())); + let write_result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .with_context(|| { + format!( + "failed to create temporary bindings file at {}", + temp_path.display() + ) + })?; + file.write_all(raw.as_bytes()).with_context(|| { + format!( + "failed to write temporary bindings file at {}", + temp_path.display() + ) + })?; + file.sync_all().with_context(|| { + format!( + "failed to sync temporary bindings file at {}", + temp_path.display() + ) + })?; + Ok(()) + })(); + + if let Err(error) = write_result { + let _ = fs::remove_file(&temp_path); + return Err(error); + } + + if let Err(error) = replace_file(&temp_path, &path) { + let _ = fs::remove_file(&temp_path); + return Err(error) + .with_context(|| format!("failed to replace bindings file at {}", path.display())); + } + Ok(()) } +#[cfg(not(windows))] +fn replace_file(temp_path: &PathBuf, path: &PathBuf) -> std::io::Result<()> { + fs::rename(temp_path, path) +} + +#[cfg(windows)] +fn replace_file(temp_path: &PathBuf, path: &PathBuf) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source: Vec = temp_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let destination: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + if unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + fn generate_binding_id() -> String { rand::rng() .sample_iter(&Alphanumeric) @@ -286,4 +363,20 @@ mod tests { assert_eq!(reloaded.bindings[0].favorite_order, 2); }); } + + #[test] + fn replaces_bindings_without_leaving_temporary_files() { + with_temp_home(|| { + save_binding(binding("home", "Home", false, 0)).expect("save binding"); + let mut updated = binding("home", "Updated", false, 0); + updated.hotkey = "Ctrl+H".to_string(); + save_binding(updated).expect("replace binding"); + + let path = stored_bindings_path(); + let reloaded = list_bindings().expect("reload binding"); + assert_eq!(reloaded.bindings[0].label, "Updated"); + assert_eq!(reloaded.bindings[0].hotkey, "Ctrl+H"); + assert!(fs::read_to_string(path).is_ok()); + }); + } } From 0b88f5f172da35ea3c57042e9e5a5dede361fcbe Mon Sep 17 00:00:00 2001 From: postigodev Date: Wed, 26 Aug 2026 15:27:20 -0500 Subject: [PATCH 2/2] test(bindings): serialize environment-dependent cases Co-authored-by: xuyua9 <15558128926@wo.cn> --- crates/core/src/bindings.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/core/src/bindings.rs b/crates/core/src/bindings.rs index 9836332..ecf433d 100644 --- a/crates/core/src/bindings.rs +++ b/crates/core/src/bindings.rs @@ -253,7 +253,9 @@ fn normalized_favorite_order( mod tests { use super::*; use crate::firetv::FireTvAction; - use std::{env, fs, path::PathBuf}; + use std::{env, fs, path::PathBuf, sync::Mutex}; + + static TEST_ENV_LOCK: Mutex<()> = Mutex::new(()); fn binding(id: &str, label: &str, favorite: bool, favorite_order: u32) -> Binding { Binding { @@ -269,6 +271,7 @@ mod tests { } fn with_temp_home(test: impl FnOnce()) { + let _env_guard = TEST_ENV_LOCK.lock().expect("lock test environment"); let original_home = env::var_os("HOME"); let original_appdata = env::var_os("APPDATA"); let temp_home =