diff --git a/crates/codex-mac-engine/src/download.rs b/crates/codex-mac-engine/src/download.rs index d252351..803cb2d 100644 --- a/crates/codex-mac-engine/src/download.rs +++ b/crates/codex-mac-engine/src/download.rs @@ -76,6 +76,10 @@ pub fn download_to_with_progress( let mut child = Command::new("curl") .args([ "-fL", + "--proto", + "=https", + "--proto-redir", + "=https", "--no-progress-meter", "-C", "-", diff --git a/crates/codex-mac-engine/src/sys.rs b/crates/codex-mac-engine/src/sys.rs index 099cde9..e0ecb44 100644 --- a/crates/codex-mac-engine/src/sys.rs +++ b/crates/codex-mac-engine/src/sys.rs @@ -14,7 +14,16 @@ use crate::EngineError; /// Fetch a small text resource (the appcast) over HTTPS via system `curl`. pub fn fetch_text(url: &str) -> Result { let output = Command::new("curl") - .args(["-fsSL", "--connect-timeout", "20", url]) + .args([ + "-fsSL", + "--proto", + "=https", + "--proto-redir", + "=https", + "--connect-timeout", + "20", + url, + ]) .output() .map_err(|e| EngineError::Io(format!("spawn curl: {e}")))?; @@ -35,6 +44,10 @@ pub fn fetch_text_timeout(url: &str, max_secs: u64) -> Result let dest = dest.to_string_lossy().into_owned(); let mut args = vec![ "-fL".to_string(), + "--proto".to_string(), + "=https".to_string(), + "--proto-redir".to_string(), + "=https".to_string(), "--no-progress-meter".to_string(), "--connect-timeout".to_string(), "20".to_string(), diff --git a/crates/codex-win-engine/src/sys.rs b/crates/codex-win-engine/src/sys.rs index 1e69f95..bd4c72b 100644 --- a/crates/codex-win-engine/src/sys.rs +++ b/crates/codex-win-engine/src/sys.rs @@ -118,7 +118,16 @@ impl MsixDependencyPrecheck { pub fn fetch_text(url: &str) -> Result { let output = hidden_command("curl") - .args(["-fsSL", "--connect-timeout", "20", url]) + .args([ + "-fsSL", + "--proto", + "=https", + "--proto-redir", + "=https", + "--connect-timeout", + "20", + url, + ]) .output() .map_err(|e| EngineError::Io(format!("spawn curl: {e}")))?; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0dc2023..1e2396a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -56,6 +56,137 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -168,6 +299,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.3" @@ -360,6 +504,8 @@ dependencies = [ "codex-mac-engine", "codex-win-engine", "directories", + "fs4", + "libc", "serde", "serde_json", "tauri", @@ -367,8 +513,10 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-updater", "thiserror 2.0.18", + "url", "windows-sys 0.61.2", ] @@ -405,6 +553,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -883,6 +1040,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -910,6 +1094,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1021,6 +1226,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1053,6 +1268,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1418,6 +1646,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2353,6 +2587,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2392,6 +2636,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2480,6 +2730,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -2535,6 +2796,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3251,6 +3526,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3739,6 +4024,21 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4200,9 +4500,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4252,6 +4564,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5329,6 +5652,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5435,3 +5819,43 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.3", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee38a6f..543f857 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,12 +24,18 @@ tauri-build = { version = "2.6.2", features = [] } codex-mac-engine = { path = "../crates/codex-mac-engine" } codex-win-engine = { path = "../crates/codex-win-engine" } directories = "6.0.0" +fs4 = "0.13" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tauri = { version = "2.11.2", features = ["tray-icon", "image-png", "macos-private-api"] } tauri-plugin-autostart = "2" tauri-plugin-dialog = "2.7.1" tauri-plugin-process = "2" +tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" thiserror = "2.0" +url = "2.5" windows-sys = { version = "0.61.2", features = ["Win32_Storage_FileSystem", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/src-tauri/src/app/atomic_file.rs b/src-tauri/src/app/atomic_file.rs new file mode 100644 index 0000000..237f6ee --- /dev/null +++ b/src-tauri/src/app/atomic_file.rs @@ -0,0 +1,207 @@ +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use serde::de::DeserializeOwned; + +static TMP_COUNTER: AtomicU64 = AtomicU64::new(1); +static WRITE_MUTEX: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoadOutcome { + Ok, + RecoveredFromBak, + Corrupt, +} + +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + let _guard = WRITE_MUTEX + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "atomic path has no parent"))?; + fs::create_dir_all(parent)?; + + let tmp = tmp_path(path); + let result = write_atomic_inner(path, &tmp, bytes); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +fn write_atomic_inner(path: &Path, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + { + let mut file = File::create(tmp)?; + file.write_all(bytes)?; + file.flush()?; + file.sync_all()?; + } + + let bak = backup_path(path); + if path.exists() { + let _ = fs::remove_file(&bak); + match fs::rename(path, &bak) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + } + + if let Err(err) = fs::rename(tmp, path) { + if !path.exists() && bak.exists() { + let _ = fs::rename(&bak, path); + } + return Err(err); + } + + if let Some(parent) = path.parent() { + sync_parent_dir(parent)?; + } + Ok(()) +} + +pub fn read_with_recovery(path: &Path) -> (Option, LoadOutcome) { + if let Some(value) = read_json(path) { + return (Some(value), LoadOutcome::Ok); + } + if let Some(value) = read_json(&backup_path(path)) { + return (Some(value), LoadOutcome::RecoveredFromBak); + } + (None, LoadOutcome::Corrupt) +} + +fn read_json(path: &Path) -> Option { + let bytes = fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn tmp_path(path: &Path) -> PathBuf { + let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + path_with_added_extension(path, &format!("tmp-{}-{counter}", std::process::id())) +} + +pub fn backup_path(path: &Path) -> PathBuf { + path_with_added_extension(path, "bak") +} + +fn path_with_added_extension(path: &Path, added: &str) -> PathBuf { + match path.extension().and_then(|ext| ext.to_str()) { + Some(ext) if !ext.is_empty() => path.with_extension(format!("{ext}.{added}")), + _ => path.with_extension(added), + } +} + +#[cfg(unix)] +fn sync_parent_dir(parent: &Path) -> io::Result<()> { + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_dir(_parent: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{read_with_recovery, write_atomic, LoadOutcome}; + use serde::{Deserialize, Serialize}; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(1); + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + struct Payload { + value: u64, + } + + fn test_dir(name: &str) -> std::path::PathBuf { + let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("test-data") + .join(format!("{name}-{}-{id}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn write_atomic_replaces_main_and_preserves_backup() { + let dir = test_dir("atomic-replace"); + let path = dir.join("settings.json"); + write_atomic(&path, br#"{"value":1}"#).unwrap(); + write_atomic(&path, br#"{"value":2}"#).unwrap(); + + let (value, outcome) = read_with_recovery::(&path); + assert_eq!(outcome, LoadOutcome::Ok); + assert_eq!(value.unwrap(), Payload { value: 2 }); + assert_eq!( + serde_json::from_slice::(&fs::read(path.with_extension("json.bak")).unwrap()) + .unwrap(), + Payload { value: 1 } + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn read_with_recovery_uses_backup_for_corrupt_main() { + let dir = test_dir("atomic-recover"); + let path = dir.join("provenance.json"); + fs::write(&path, b"{").unwrap(); + fs::write(path.with_extension("json.bak"), br#"{"value":7}"#).unwrap(); + + let (value, outcome) = read_with_recovery::(&path); + assert_eq!(outcome, LoadOutcome::RecoveredFromBak); + assert_eq!(value.unwrap(), Payload { value: 7 }); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn read_with_recovery_reports_corrupt_when_main_and_backup_fail() { + let dir = test_dir("atomic-corrupt"); + let path = dir.join("settings.json"); + fs::write(&path, b"").unwrap(); + fs::write(path.with_extension("json.bak"), b"{").unwrap(); + + let (value, outcome) = read_with_recovery::(&path); + assert_eq!(outcome, LoadOutcome::Corrupt); + assert!(value.is_none()); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn concurrent_writes_leave_complete_json() { + let dir = test_dir("atomic-concurrent"); + let path = dir.join("settings.json"); + write_atomic(&path, br#"{"value":0}"#).unwrap(); + + let handles: Vec<_> = (1_u64..=8) + .map(|value| { + let path = path.clone(); + std::thread::spawn(move || { + for _ in 0..10 { + let bytes = serde_json::to_vec(&Payload { value }).unwrap(); + write_atomic(&path, &bytes).unwrap(); + } + }) + }) + .collect(); + for handle in handles { + handle.join().unwrap(); + } + + let (value, outcome) = read_with_recovery::(&path); + assert_eq!(outcome, LoadOutcome::Ok); + assert!(matches!(value.unwrap().value, 1..=8)); + + let _ = fs::remove_dir_all(dir); + } +} diff --git a/src-tauri/src/app/config_health.rs b/src-tauri/src/app/config_health.rs new file mode 100644 index 0000000..a5b7b1d --- /dev/null +++ b/src-tauri/src/app/config_health.rs @@ -0,0 +1,79 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ConfigStatus { + #[default] + Ok, + Recovered, + Corrupt, +} + +impl ConfigStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Recovered => "recovered", + Self::Corrupt => "corrupt", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct StoreLoadHealth { + pub status: ConfigStatus, + pub unknown_source: Option, + pub detail: Option, +} + +impl StoreLoadHealth { + pub fn ok() -> Self { + Self::default() + } + + pub fn recovered(detail: String) -> Self { + Self { + status: ConfigStatus::Recovered, + detail: Some(detail), + ..Self::default() + } + } + + pub fn corrupt(detail: String) -> Self { + Self { + status: ConfigStatus::Corrupt, + detail: Some(detail), + ..Self::default() + } + } +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigHealth { + pub settings_status: String, + pub provenance_status: String, + pub unknown_source: Option, + pub detail: Option, +} + +impl ConfigHealth { + pub fn from_parts(settings: StoreLoadHealth, provenance: StoreLoadHealth) -> Self { + let detail = [settings.detail, provenance.detail] + .into_iter() + .flatten() + .collect::>() + .join(";"); + Self { + settings_status: settings.status.as_str().to_string(), + provenance_status: provenance.status.as_str().to_string(), + unknown_source: settings.unknown_source, + detail: (!detail.is_empty()).then_some(detail), + } + } + + pub fn is_ok(&self) -> bool { + self.settings_status == "ok" + && self.provenance_status == "ok" + && self.unknown_source.is_none() + } +} diff --git a/src-tauri/src/app/disk.rs b/src-tauri/src/app/disk.rs new file mode 100644 index 0000000..d1bf4f6 --- /dev/null +++ b/src-tauri/src/app/disk.rs @@ -0,0 +1,96 @@ +use std::path::{Path, PathBuf}; + +use crate::errors::AppError; + +/// Available bytes for the current user on the volume containing `path`. +/// `Ok(None)` means the platform cannot measure it and should not block work. +pub fn available_space(path: &Path) -> Result, AppError> { + platform_available_space(&nearest_existing_dir(path)) +} + +fn nearest_existing_dir(path: &Path) -> PathBuf { + let mut cur = path; + loop { + if cur.is_dir() { + return cur.to_path_buf(); + } + match cur.parent() { + Some(parent) => cur = parent, + None => return cur.to_path_buf(), + } + } +} + +#[cfg(windows)] +fn platform_available_space(path: &Path) -> Result, AppError> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + wide.push(0); + let mut free_to_caller = 0_u64; + let mut total = 0_u64; + let mut total_free = 0_u64; + let ok = unsafe { + GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut free_to_caller, + &mut total, + &mut total_free, + ) + }; + if ok == 0 { + return Err(AppError::Internal(format!( + "读取磁盘剩余空间失败: {}", + std::io::Error::last_os_error() + ))); + } + Ok(Some(free_to_caller)) +} + +#[cfg(unix)] +fn platform_available_space(path: &Path) -> Result, AppError> { + use std::ffi::CString; + use std::mem::MaybeUninit; + use std::os::unix::ffi::OsStrExt; + + let c_path = CString::new(path.as_os_str().as_bytes()) + .map_err(|e| AppError::Internal(format!("读取磁盘剩余空间失败: {e}")))?; + let mut stat = MaybeUninit::::uninit(); + let rc = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) }; + if rc != 0 { + return Err(AppError::Internal(format!( + "读取磁盘剩余空间失败: {}", + std::io::Error::last_os_error() + ))); + } + let stat = unsafe { stat.assume_init() }; + let fragment_size = if stat.f_frsize == 0 { + stat.f_bsize as u128 + } else { + stat.f_frsize as u128 + }; + let bytes = (stat.f_bavail as u128).saturating_mul(fragment_size); + Ok(Some(bytes.min(u64::MAX as u128) as u64)) +} + +#[cfg(not(any(windows, unix)))] +fn platform_available_space(_path: &Path) -> Result, AppError> { + Ok(None) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::available_space; + + #[cfg(unix)] + #[test] + fn unix_available_space_handles_nonexistent_child_path() { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("missing") + .join("child"); + assert!(available_space(&path).unwrap().unwrap() > 0); + } +} diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index fc6d7fb..ac02f39 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -21,7 +21,10 @@ use codex_mac_engine::{ UpdateStrategy, }; +use crate::app::disk; use crate::app::provenance::ProvenanceStore; +use crate::app::settings_store::UpdateSource; +use crate::app::url_guard::validate_custom_source; use crate::errors::AppError; #[derive(Debug, Clone, Serialize)] @@ -121,19 +124,19 @@ fn latest_build_of(xml: &str) -> u64 { /// that source (custom falls back to the mirror when its URL is blank). fn fetch_appcast_for_arch(arch: &str) -> Result<(String, String), AppError> { let settings = crate::app::settings_store::AppSettings::load(); - match settings.source.as_str() { - "official" => fetch_one(official_for_arch(arch).to_string()), - "mirror" => fetch_one(appcast_for_arch(arch).to_string()), - "custom" => { + match settings.source { + UpdateSource::Official => fetch_one(official_for_arch(arch).to_string()), + UpdateSource::Mirror => fetch_one(appcast_for_arch(arch).to_string()), + UpdateSource::Custom => { let u = settings.custom_url.trim(); let url = if u.is_empty() { appcast_for_arch(arch).to_string() } else { - u.to_string() + validate_custom_source(u).map_err(|e| AppError::Engine(e.to_string()))? }; fetch_one(url) } - _ => { + UpdateSource::Auto => { // auto: pick the higher build between the CN-reachable mirror and // OpenAI official, among whichever sources are reachable. The mirror // can lag the official feed by a release; when it does and official @@ -281,6 +284,49 @@ fn strategy_label(strategy: &UpdateStrategy) -> String { } } +/// Worst-case temporary disk budget for one macOS update. Even a delta plan may +/// fall back to the full package, and `ditto` extraction can briefly occupy +/// several times the zip size, so the budget uses the full archive as the +/// conservative basis. +fn mac_space_budget(plan: &UpdatePlan) -> u64 { + const HEADROOM: u64 = 512 * 1024 * 1024; + plan.download_size + .saturating_add(plan.full_size.saturating_mul(3)) + .saturating_add(HEADROOM) +} + +fn human_bytes(bytes: u64) -> String { + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + if bytes >= 1024 * 1024 * 1024 { + format!("{:.1} GiB", bytes as f64 / GIB) + } else { + format!("{:.0} MiB", bytes as f64 / MIB) + } +} + +fn preflight_mac_disk(plan: &UpdatePlan) -> Result<(), AppError> { + preflight_mac_disk_with_available(plan, disk::available_space) +} + +fn preflight_mac_disk_with_available(plan: &UpdatePlan, available: F) -> Result<(), AppError> +where + F: Fn(&Path) -> Result, AppError>, +{ + let staging = staging_dir(); + let need = mac_space_budget(plan); + if let Some(free) = available(&staging)? { + if free < need { + return Err(AppError::Engine(format!( + "磁盘可用空间不足:本次更新约需 {},当前可用 {}。请清理后重试", + human_bytes(need), + human_bytes(free) + ))); + } + } + Ok(()) +} + /// Download an artifact `(url, size, signature)` into staging, size-gate it, and /// verify its EdDSA signature against the pinned Sparkle key. Idempotent: reuses /// an already-staged file of the right size. On EdDSA failure the staged file is @@ -335,7 +381,9 @@ fn download_and_verify( let dest = staging_dir().join(file_name); let source = host_of(url); - let already = std::fs::metadata(&dest).map(|m| m.len() == size).unwrap_or(false); + let already = std::fs::metadata(&dest) + .map(|m| m.len() == size) + .unwrap_or(false); if already { // Cached from a prior stage — report complete so the UI doesn't sit at 0. progress(DownloadProgress { @@ -394,12 +442,18 @@ pub fn stage_macos_update(simulated_build: Option) -> Result Result<(), AppError> { let extract = work.join("unzip"); let _ = std::fs::remove_dir_all(&extract); - std::fs::create_dir_all(&extract) - .map_err(|e| AppError::Engine(format!("mkdir unzip: {e}")))?; + std::fs::create_dir_all(&extract).map_err(|e| AppError::Engine(format!("mkdir unzip: {e}")))?; let status = std::process::Command::new("ditto") .args(["-x", "-k"]) @@ -469,7 +522,9 @@ fn unpack_app_zip(zip: &Path, work: &Path, out_app: &Path) -> Result<(), AppErro .status() .map_err(|e| AppError::Engine(format!("spawn ditto: {e}")))?; if !status.success() { - return Err(AppError::Engine(format!("ditto unzip exited with {status}"))); + return Err(AppError::Engine(format!( + "ditto unzip exited with {status}" + ))); } let found = find_dot_app(&extract) @@ -603,6 +658,7 @@ pub fn perform_macos_update( if let Some(latest) = appcast.latest() { require_os_supported(latest.minimum_system_version.as_deref())?; } + preflight_mac_disk(&plan)?; // 1) Set up same-volume staging for the reconstructed bundle + backup. let work = staging_dir(); @@ -678,7 +734,11 @@ pub fn perform_macos_update( // update still succeeded — but surface it, since status would otherwise // keep classifying this now-manager install as "external". let mut store = ProvenanceStore::load(); - store.record(installed.path.clone(), plan.latest_build, "manager-installed"); + store.record( + installed.path.clone(), + plan.latest_build, + "manager-installed", + ); let save_warning = match store.save() { Ok(()) => None, Err(e) => Some(format!("托管记录保存失败({e}),安装暂仍会被识别为外部")), @@ -833,6 +893,9 @@ pub fn install_macos(progress: &dyn Fn(DownloadProgress)) -> Result Result Result<(), AppError> { - let installed = - detect_managed_installed().ok_or_else(|| AppError::Engine("没有可打开的 Codex".to_string()))?; + let installed = detect_managed_installed() + .ok_or_else(|| AppError::Engine("没有可打开的 Codex".to_string()))?; std::process::Command::new("open") .arg(&installed.path) .spawn() @@ -969,6 +1032,45 @@ pub fn uninstall_macos(keep_codex_home: bool) -> Result UpdatePlan { + UpdatePlan { + up_to_date: false, + current_build: 1, + latest_build: 2, + latest_short_version: "2.0.0".to_string(), + strategy, + download_url: "https://example.com/Codex.zip".to_string(), + download_size, + ed_signature: Some("sig".to_string()), + full_size, + savings_pct: 0.0, + } + } + + #[test] + fn mac_space_budget_accounts_for_delta_full_fallback_and_headroom() { + let p = plan(10, 100, UpdateStrategy::Delta { from_build: 1 }); + assert_eq!(mac_space_budget(&p), 10 + 300 + 512 * 1024 * 1024); + + let p = plan(u64::MAX, u64::MAX, UpdateStrategy::Full); + assert_eq!(mac_space_budget(&p), u64::MAX); + } + + #[test] + fn preflight_mac_disk_rejects_low_space_and_allows_unknown_space() { + let p = plan(100, 200, UpdateStrategy::Full); + let err = preflight_mac_disk_with_available(&p, |_| Ok(Some(10))).unwrap_err(); + assert!(err.to_string().contains("磁盘可用空间不足")); + + assert!(preflight_mac_disk_with_available(&p, |_| Ok(None)).is_ok()); + assert!(preflight_mac_disk_with_available(&p, |_| Ok(Some(mac_space_budget(&p)))).is_ok()); + } +} + // The full-update unpack branch is new logic (the delta/gate/swap tail reuses // engine primitives already proven on real /Applications). `ditto` is macOS-only, // so these stay gated off the cross-compiled Windows build. @@ -1025,7 +1127,10 @@ mod tests { std::fs::create_dir_all(root.join("Codex.app")).unwrap(); let found = find_dot_app(&root).expect("an .app is found"); - assert!(found.ends_with("Codex.app"), "prefers Codex.app, got {found:?}"); + assert!( + found.ends_with("Codex.app"), + "prefers Codex.app, got {found:?}" + ); let _ = std::fs::remove_dir_all(&root); } diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index fdfd6dd..9239ddc 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -1,4 +1,10 @@ +pub mod atomic_file; +pub mod config_health; +pub mod disk; pub mod mac_update; +pub mod oplock; +pub mod paths; pub mod provenance; pub mod settings_store; +pub mod url_guard; pub mod win_update; diff --git a/src-tauri/src/app/oplock.rs b/src-tauri/src/app/oplock.rs new file mode 100644 index 0000000..1d0a9bd --- /dev/null +++ b/src-tauri/src/app/oplock.rs @@ -0,0 +1,374 @@ +use std::fs::{File, OpenOptions}; +use std::io::{self, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs4::fs_std::FileExt; + +static TOKEN_COUNTER: AtomicU64 = AtomicU64::new(1); +const DEFAULT_STALE_AFTER_SECS: u64 = 5 * 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OperationKind { + Install, + Update, + Uninstall, + SetInstallRoot, + Adopt, +} + +impl OperationKind { + pub fn as_str(&self) -> &'static str { + match self { + Self::Install => "install", + Self::Update => "update", + Self::Uninstall => "uninstall", + Self::SetInstallRoot => "set-install-root", + Self::Adopt => "adopt", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct OperationToken(pub String); + +#[derive(Debug, thiserror::Error)] +pub enum OperationError { + #[error("已有操作正在进行({0}),请等待完成后再试")] + BusySameProcess(&'static str), + #[error("另一个 Codex 管理器实例正在执行操作,请关闭多余窗口后重试")] + BusyOtherProcess, + #[error("操作令牌无效或已过期,请重新发起操作")] + InvalidToken, + #[error("无法获取操作锁:{0}")] + Lock(String), +} + +pub struct OperationManager { + inner: Arc>, + stale_after_secs: u64, +} + +struct Inner { + active: Option, + lock_file: Result, +} + +struct ActiveOp { + token: String, + kind: OperationKind, + started_unix: u64, + detached: bool, +} + +#[must_use = "持有 guard 才代表持有操作锁;提前 drop 会立即释放锁"] +pub struct OperationGuard { + manager: Arc>, + token: OperationToken, + kind: OperationKind, +} + +impl OperationGuard { + pub fn token(&self) -> &OperationToken { + &self.token + } + + pub fn kind(&self) -> OperationKind { + self.kind + } +} + +impl Drop for OperationGuard { + fn drop(&mut self) { + let Ok(mut inner) = self.manager.lock() else { + return; + }; + if inner + .active + .as_ref() + .is_some_and(|active| active.token == self.token.0) + { + let _ = OperationManager::unlock_lock_file(&mut inner); + inner.active.take(); + } + } +} + +impl OperationManager { + pub fn new(lock_path: PathBuf) -> Self { + Self::new_with_stale_after(lock_path, DEFAULT_STALE_AFTER_SECS) + } + + fn new_with_stale_after(lock_path: PathBuf, stale_after_secs: u64) -> Self { + let lock_file = Self::open_lock_file(&lock_path); + Self { + inner: Arc::new(Mutex::new(Inner { + active: None, + lock_file, + })), + stale_after_secs, + } + } + + pub fn begin(&self, kind: OperationKind) -> Result { + let token = self.begin_inner(kind, false)?; + Ok(OperationGuard { + manager: Arc::clone(&self.inner), + token, + kind, + }) + } + + pub fn begin_detached(&self, kind: OperationKind) -> Result { + self.begin_inner(kind, true) + } + + pub fn end(&self, token: OperationToken) -> Result<(), OperationError> { + let mut inner = self + .inner + .lock() + .map_err(|_| OperationError::Lock("operation mutex poisoned".to_string()))?; + self.reclaim_stale_detached(&mut inner)?; + let Some(active) = inner.active.as_ref() else { + return Err(OperationError::InvalidToken); + }; + if active.token != token.0 { + return Err(OperationError::InvalidToken); + } + Self::unlock_lock_file(&mut inner)?; + inner.active.take(); + Ok(()) + } + + pub fn validate(&self, token: &OperationToken) -> Result<(), OperationError> { + let mut inner = self + .inner + .lock() + .map_err(|_| OperationError::Lock("operation mutex poisoned".to_string()))?; + self.reclaim_stale_detached(&mut inner)?; + match inner.active.as_ref() { + Some(active) if active.token == token.0 => Ok(()), + _ => Err(OperationError::InvalidToken), + } + } + + pub fn is_busy(&self) -> bool { + let Ok(mut inner) = self.inner.lock() else { + return false; + }; + let _ = self.reclaim_stale_detached(&mut inner); + inner.active.is_some() + } + + fn begin_inner( + &self, + kind: OperationKind, + detached: bool, + ) -> Result { + let mut inner = self + .inner + .lock() + .map_err(|_| OperationError::Lock("operation mutex poisoned".to_string()))?; + self.reclaim_stale_detached(&mut inner)?; + if let Some(active) = inner.active.as_ref() { + return Err(OperationError::BusySameProcess(active.kind.as_str())); + } + + let started_unix = now_unix(); + let token = OperationToken(generate_token(started_unix)); + { + let lock_file = Self::lock_file_mut(&mut inner)?; + Self::try_lock_file(lock_file)?; + let _ = write_lock_diagnostics(lock_file, kind, &token, started_unix); + } + inner.active = Some(ActiveOp { + token: token.0.clone(), + kind, + started_unix, + detached, + }); + Ok(token) + } + + fn open_lock_file(lock_path: &Path) -> Result { + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(lock_path) + .map_err(|e| e.to_string()) + } + + fn lock_file_mut(inner: &mut Inner) -> Result<&mut File, OperationError> { + inner + .lock_file + .as_mut() + .map_err(|err| OperationError::Lock(err.clone())) + } + + fn try_lock_file(file: &File) -> Result<(), OperationError> { + match file.try_lock_exclusive() { + Ok(true) => Ok(()), + Ok(false) => Err(OperationError::BusyOtherProcess), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + Err(OperationError::BusyOtherProcess) + } + Err(err) => Err(OperationError::Lock(err.to_string())), + } + } + + fn unlock_lock_file(inner: &mut Inner) -> Result<(), OperationError> { + let file = Self::lock_file_mut(inner)?; + file.unlock() + .map_err(|err| OperationError::Lock(err.to_string())) + } + + fn reclaim_stale_detached(&self, inner: &mut Inner) -> Result<(), OperationError> { + if self.has_stale_detached(inner) { + Self::unlock_lock_file(inner)?; + inner.active.take(); + } + Ok(()) + } + + fn has_stale_detached(&self, inner: &Inner) -> bool { + inner.active.as_ref().is_some_and(|active| { + active.detached + && now_unix().saturating_sub(active.started_unix) >= self.stale_after_secs + }) + } +} + +fn generate_token(started_unix: u64) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let counter = TOKEN_COUNTER.fetch_add(1, Ordering::Relaxed); + format!( + "{:x}-{:x}-{:x}", + std::process::id(), + nanos ^ started_unix as u128, + counter + ) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn write_lock_diagnostics( + file: &mut File, + kind: OperationKind, + token: &OperationToken, + started_unix: u64, +) -> io::Result<()> { + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + writeln!(file, "pid={}", std::process::id())?; + writeln!(file, "kind={}", kind.as_str())?; + writeln!(file, "token={}", token.0)?; + writeln!(file, "started_unix={started_unix}")?; + file.flush() +} + +#[cfg(test)] +mod tests { + use super::{OperationError, OperationKind, OperationManager, OperationToken}; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(1); + + fn lock_path(name: &str) -> std::path::PathBuf { + let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("test-data") + .join(format!("oplock-{name}-{}-{id}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir.join("operation.lock") + } + + #[test] + fn begin_validate_and_drop_release_lock() { + let path = lock_path("basic"); + let manager = OperationManager::new(path.clone()); + let guard = manager.begin(OperationKind::Update).unwrap(); + assert!(manager.is_busy()); + assert!(manager.validate(guard.token()).is_ok()); + assert!(matches!( + manager.validate(&OperationToken("wrong".to_string())), + Err(OperationError::InvalidToken) + )); + assert!(matches!( + manager.begin(OperationKind::Install), + Err(OperationError::BusySameProcess("update")) + )); + + drop(guard); + assert!(!manager.is_busy()); + assert!(manager.begin(OperationKind::Install).is_ok()); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn detached_token_must_be_ended_once() { + let path = lock_path("detached"); + let manager = OperationManager::new(path.clone()); + let token = manager.begin_detached(OperationKind::Adopt).unwrap(); + + assert!(matches!( + manager.end(OperationToken("wrong".to_string())), + Err(OperationError::InvalidToken) + )); + assert!(manager.is_busy()); + manager.end(token.clone()).unwrap(); + assert!(!manager.is_busy()); + assert!(matches!( + manager.end(token), + Err(OperationError::InvalidToken) + )); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn detached_token_times_out_and_allows_new_begin() { + let path = lock_path("timeout"); + let manager = OperationManager::new_with_stale_after(path.clone(), 0); + let _token = manager.begin_detached(OperationKind::Update).unwrap(); + + let guard = manager.begin(OperationKind::Install).unwrap(); + assert_eq!(guard.kind(), OperationKind::Install); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn second_manager_hits_cross_process_lock() { + let path = lock_path("cross-process"); + let first = OperationManager::new(path.clone()); + let _guard = first.begin(OperationKind::Update).unwrap(); + let second = OperationManager::new(path.clone()); + + assert!(matches!( + second.begin(OperationKind::Install), + Err(OperationError::BusyOtherProcess) + )); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } +} diff --git a/src-tauri/src/app/paths.rs b/src-tauri/src/app/paths.rs new file mode 100644 index 0000000..6b805e9 --- /dev/null +++ b/src-tauri/src/app/paths.rs @@ -0,0 +1,15 @@ +use std::path::PathBuf; + +/// Manager data directory shared by settings, provenance, and operation locks. +pub fn data_dir() -> Option { + directories::ProjectDirs::from("io.github", "wangnov", "codexappmanager") + .map(|dirs| dirs.data_dir().to_path_buf()) +} + +pub fn settings_path() -> Option { + data_dir().map(|dir| dir.join("settings.json")) +} + +pub fn provenance_path() -> Option { + data_dir().map(|dir| dir.join("provenance.json")) +} diff --git a/src-tauri/src/app/provenance.rs b/src-tauri/src/app/provenance.rs index 47c3e78..9a7d16d 100644 --- a/src-tauri/src/app/provenance.rs +++ b/src-tauri/src/app/provenance.rs @@ -12,8 +12,17 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::app::atomic_file::{self, LoadOutcome}; +use crate::app::config_health::StoreLoadHealth; +use crate::app::paths; use crate::errors::AppError; +pub const CURRENT_SCHEMA_VERSION: u32 = 1; + +fn default_schema_version() -> u32 { + CURRENT_SCHEMA_VERSION +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ManagedRecord { @@ -24,15 +33,26 @@ pub struct ManagedRecord { pub adopted_at_unix: u64, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProvenanceStore { + #[serde(default = "default_schema_version")] + pub schema_version: u32, + #[serde(default)] pub managed: Vec, } +impl Default for ProvenanceStore { + fn default() -> Self { + Self { + schema_version: CURRENT_SCHEMA_VERSION, + managed: Vec::new(), + } + } +} + fn store_path() -> Option { - directories::ProjectDirs::from("io.github", "wangnov", "codexappmanager") - .map(|dirs| dirs.data_dir().join("provenance.json")) + paths::provenance_path() } fn now_unix() -> u64 { @@ -44,24 +64,47 @@ fn now_unix() -> u64 { impl ProvenanceStore { pub fn load() -> Self { + Self::load_with_health().0 + } + + pub fn load_with_health() -> (Self, StoreLoadHealth) { let Some(path) = store_path() else { - return Self::default(); + return ( + Self::default(), + StoreLoadHealth::corrupt("无法定位 provenance.json 数据目录".to_string()), + ); }; - std::fs::read(&path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) - .unwrap_or_default() + if !path.exists() && !atomic_file::backup_path(&path).exists() { + return (Self::default(), StoreLoadHealth::ok()); + } + + let (store, outcome) = atomic_file::read_with_recovery::(&path); + let mut health = match outcome { + LoadOutcome::Ok => StoreLoadHealth::ok(), + LoadOutcome::RecoveredFromBak => { + StoreLoadHealth::recovered("provenance.json 已从 .bak 备份恢复".to_string()) + } + LoadOutcome::Corrupt => StoreLoadHealth::corrupt( + "provenance.json 损坏且 .bak 备份不可用,已使用空托管记录".to_string(), + ), + }; + + let mut store = store.unwrap_or_default(); + if store.schema_version > CURRENT_SCHEMA_VERSION { + health.detail = Some(format!( + "provenance.json schema_version={} 高于当前支持版本 {}", + store.schema_version, CURRENT_SCHEMA_VERSION + )); + } + store.schema_version = CURRENT_SCHEMA_VERSION; + (store, health) } pub fn save(&self) -> Result<(), AppError> { let path = store_path().ok_or_else(|| AppError::Internal("no data directory".into()))?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| AppError::Internal(format!("create data dir: {e}")))?; - } let json = serde_json::to_vec_pretty(self) .map_err(|e| AppError::Internal(format!("serialize provenance: {e}")))?; - std::fs::write(&path, json) + atomic_file::write_atomic(&path, &json) .map_err(|e| AppError::Internal(format!("write provenance: {e}"))) } @@ -74,7 +117,9 @@ impl ProvenanceStore { /// official Codex landing where a managed one used to be) and against a stale /// record left by a failed save — those won't match the current build. pub fn is_managed_build(&self, path: &str, build: u64) -> bool { - self.managed.iter().any(|r| r.path == path && r.build == build) + self.managed + .iter() + .any(|r| r.path == path && r.build == build) } pub fn remove(&mut self, path: &str) { @@ -83,6 +128,7 @@ impl ProvenanceStore { /// Record (or refresh) a managed install, keyed by path. pub fn record(&mut self, path: String, build: u64, source: &str) { + self.schema_version = CURRENT_SCHEMA_VERSION; self.managed.retain(|r| r.path != path); self.managed.push(ManagedRecord { path, @@ -119,4 +165,11 @@ mod tests { assert_eq!(store.managed.len(), 1); assert_eq!(store.managed[0].build, 3600); } + + #[test] + fn old_schema_defaults_schema_version() { + let store: ProvenanceStore = + serde_json::from_str(r#"{"managed":[]}"#).expect("old provenance parses"); + assert_eq!(store.schema_version, CURRENT_SCHEMA_VERSION); + } } diff --git a/src-tauri/src/app/settings_store.rs b/src-tauri/src/app/settings_store.rs index a5bfb88..67ebd6d 100644 --- a/src-tauri/src/app/settings_store.rs +++ b/src-tauri/src/app/settings_store.rs @@ -7,14 +7,63 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use crate::adapters::host; +use crate::app::atomic_file::{self, LoadOutcome}; +use crate::app::config_health::StoreLoadHealth; +use crate::app::paths; use crate::domain::target::Target; use crate::errors::AppError; +pub const CURRENT_SCHEMA_VERSION: u32 = 1; + +fn default_schema_version() -> u32 { + CURRENT_SCHEMA_VERSION +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UpdateSource { + Auto, + Mirror, + Official, + Custom, +} + +impl UpdateSource { + pub fn from_raw(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "auto" => Ok(Self::Auto), + "mirror" => Ok(Self::Mirror), + "official" => Ok(Self::Official), + "custom" => Ok(Self::Custom), + _ => Err(raw.to_string()), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Mirror => "mirror", + Self::Official => "official", + Self::Custom => "custom", + } + } +} + +fn default_source() -> UpdateSource { + UpdateSource::Auto +} + +fn default_source_string() -> String { + UpdateSource::Auto.as_str().to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AppSettings { - /// "auto" | "mirror" | "official" | "custom" - pub source: String, + #[serde(default = "default_schema_version")] + pub schema_version: u32, + #[serde(default = "default_source")] + pub source: UpdateSource, pub custom_url: String, pub auto_check: bool, pub ask_before: bool, @@ -34,6 +83,29 @@ pub struct AppSettings { pub install_root: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawAppSettings { + #[serde(default = "default_schema_version")] + schema_version: u32, + #[serde(default = "default_source_string")] + source: String, + #[serde(default)] + custom_url: String, + #[serde(default = "default_true")] + auto_check: bool, + #[serde(default = "default_true")] + ask_before: bool, + #[serde(default = "default_true")] + signed_only: bool, + #[serde(default = "default_true")] + confirm_close: bool, + #[serde(default = "default_windows_install_mode")] + windows_install_mode: String, + #[serde(default = "default_install_root")] + install_root: String, +} + fn default_true() -> bool { true } @@ -49,7 +121,8 @@ pub fn default_install_root() -> String { impl Default for AppSettings { fn default() -> Self { Self { - source: "auto".to_string(), + schema_version: CURRENT_SCHEMA_VERSION, + source: UpdateSource::Auto, custom_url: String::new(), auto_check: true, ask_before: true, @@ -62,12 +135,52 @@ impl Default for AppSettings { } fn store_path() -> Option { - directories::ProjectDirs::from("io.github", "wangnov", "codexappmanager") - .map(|dirs| dirs.data_dir().join("settings.json")) + paths::settings_path() +} + +impl RawAppSettings { + fn into_settings(self) -> (AppSettings, Option, Option) { + let (source, unknown_source) = match UpdateSource::from_raw(&self.source) { + Ok(source) => (source, None), + Err(raw) => (UpdateSource::Auto, Some(raw)), + }; + let newer_schema = (self.schema_version > CURRENT_SCHEMA_VERSION).then(|| { + format!( + "settings.json schema_version={} 高于当前支持版本 {}", + self.schema_version, CURRENT_SCHEMA_VERSION + ) + }); + ( + AppSettings { + schema_version: self.schema_version, + source, + custom_url: self.custom_url, + auto_check: self.auto_check, + ask_before: self.ask_before, + signed_only: self.signed_only, + confirm_close: self.confirm_close, + windows_install_mode: self.windows_install_mode, + install_root: self.install_root, + }, + unknown_source, + newer_schema, + ) + } +} + +fn append_detail(health: &mut StoreLoadHealth, detail: String) { + match &mut health.detail { + Some(existing) => { + existing.push(';'); + existing.push_str(&detail); + } + None => health.detail = Some(detail), + } } impl AppSettings { pub fn normalize(&mut self) { + self.schema_version = CURRENT_SCHEMA_VERSION; self.signed_only = true; // enforce regardless of what is on disk if !matches!(self.windows_install_mode.as_str(), "msix" | "portable") { self.windows_install_mode = default_windows_install_mode(); @@ -82,25 +195,107 @@ impl AppSettings { } pub fn load() -> Self { + Self::load_with_health().0 + } + + pub fn load_with_health() -> (Self, StoreLoadHealth) { let Some(path) = store_path() else { - return Self::default(); + return ( + Self::default(), + StoreLoadHealth::corrupt("无法定位 settings.json 数据目录".to_string()), + ); + }; + if !path.exists() && !atomic_file::backup_path(&path).exists() { + return (Self::default(), StoreLoadHealth::ok()); + } + + let (raw, outcome) = atomic_file::read_with_recovery::(&path); + let mut health = match outcome { + LoadOutcome::Ok => StoreLoadHealth::ok(), + LoadOutcome::RecoveredFromBak => { + StoreLoadHealth::recovered("settings.json 已从 .bak 备份恢复".to_string()) + } + LoadOutcome::Corrupt => StoreLoadHealth::corrupt( + "settings.json 损坏且 .bak 备份不可用,已使用默认配置".to_string(), + ), }; - let mut s: Self = std::fs::read(&path) - .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()) - .unwrap_or_default(); - s.normalize(); - s + + let mut settings = match raw { + Some(raw) => { + let (settings, unknown_source, newer_schema) = raw.into_settings(); + if let Some(raw) = unknown_source { + health.unknown_source = Some(raw.clone()); + append_detail(&mut health, format!("未知更新源 {raw:?} 已归一为 auto")); + } + if let Some(detail) = newer_schema { + append_detail(&mut health, detail); + } + settings + } + None => Self::default(), + }; + settings.normalize(); + (settings, health) } pub fn save(&self) -> Result<(), AppError> { let path = store_path().ok_or_else(|| AppError::Internal("no data directory".into()))?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| AppError::Internal(format!("create data dir: {e}")))?; - } let json = serde_json::to_vec_pretty(self) .map_err(|e| AppError::Internal(format!("serialize settings: {e}")))?; - std::fs::write(&path, json).map_err(|e| AppError::Internal(format!("write settings: {e}"))) + atomic_file::write_atomic(&path, &json) + .map_err(|e| AppError::Internal(format!("write settings: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::{AppSettings, RawAppSettings, UpdateSource, CURRENT_SCHEMA_VERSION}; + + #[test] + fn old_schema_defaults_schema_version() { + let raw: RawAppSettings = serde_json::from_str( + r#"{ + "source": "mirror", + "customUrl": "", + "autoCheck": true, + "askBefore": true, + "signedOnly": true + }"#, + ) + .unwrap(); + let (settings, unknown_source, newer_schema) = raw.into_settings(); + assert_eq!(settings.schema_version, CURRENT_SCHEMA_VERSION); + assert_eq!(settings.source, UpdateSource::Mirror); + assert!(unknown_source.is_none()); + assert!(newer_schema.is_none()); + } + + #[test] + fn unknown_source_normalizes_to_auto_with_warning() { + let raw: RawAppSettings = serde_json::from_str( + r#"{ + "schemaVersion": 1, + "source": "surprise", + "customUrl": "", + "autoCheck": true, + "askBefore": true, + "signedOnly": true + }"#, + ) + .unwrap(); + let (mut settings, unknown_source, _) = raw.into_settings(); + settings.normalize(); + assert_eq!(settings.source, UpdateSource::Auto); + assert_eq!(unknown_source.as_deref(), Some("surprise")); + } + + #[test] + fn app_settings_serializes_source_as_lowercase_string() { + let settings = AppSettings { + source: UpdateSource::Custom, + ..AppSettings::default() + }; + let value = serde_json::to_value(settings).unwrap(); + assert_eq!(value["source"], "custom"); } } diff --git a/src-tauri/src/app/url_guard.rs b/src-tauri/src/app/url_guard.rs new file mode 100644 index 0000000..b81e7e6 --- /dev/null +++ b/src-tauri/src/app/url_guard.rs @@ -0,0 +1,222 @@ +use std::net::{Ipv4Addr, Ipv6Addr}; + +use url::{Host, Url}; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum UrlRejectReason { + #[error("自定义源必须是 https:// 链接")] + NotHttps, + #[error("自定义源不能使用本机 / 内网地址")] + PrivateOrLoopback, + #[error("自定义源不能直接使用 IP 地址,请用域名")] + BareIp, + #[error("自定义源不能包含用户名/密码")] + HasUserinfo, + #[error("自定义源缺少有效主机名")] + MissingHost, + #[error("无法解析该 URL")] + Unparsable, + #[error("自定义源不能为空")] + Empty, +} + +pub fn validate_custom_source(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err(UrlRejectReason::Empty); + } + if raw + .chars() + .any(|ch| ch.is_control() || ch.is_whitespace() || ch == '\\') + { + return Err(UrlRejectReason::Unparsable); + } + let lower_raw = raw.to_ascii_lowercase(); + if let Some(rest) = lower_raw.strip_prefix("https://") { + if rest.is_empty() || rest.starts_with('/') { + return Err(UrlRejectReason::MissingHost); + } + } + + let mut url = Url::parse(raw).map_err(|_| UrlRejectReason::Unparsable)?; + if url.scheme() != "https" { + return Err(UrlRejectReason::NotHttps); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(UrlRejectReason::HasUserinfo); + } + + match url.host() { + None => return Err(UrlRejectReason::MissingHost), + Some(Host::Ipv4(ip)) => { + if is_blocked_ipv4(ip) { + return Err(UrlRejectReason::PrivateOrLoopback); + } + return Err(UrlRejectReason::BareIp); + } + Some(Host::Ipv6(ip)) => { + if let Some(v4) = ip.to_ipv4_mapped() { + if is_blocked_ipv4(v4) { + return Err(UrlRejectReason::PrivateOrLoopback); + } + } + if is_blocked_ipv6(ip) { + return Err(UrlRejectReason::PrivateOrLoopback); + } + return Err(UrlRejectReason::BareIp); + } + Some(Host::Domain(domain)) => validate_domain(domain)?, + } + + if url.port() == Some(443) { + let _ = url.set_port(None); + } + Ok(url.to_string()) +} + +fn validate_domain(domain: &str) -> Result<(), UrlRejectReason> { + let domain = domain.trim_end_matches('.').to_ascii_lowercase(); + if domain.is_empty() { + return Err(UrlRejectReason::MissingHost); + } + if domain == "localhost" + || domain.ends_with(".localhost") + || domain.ends_with(".local") + || domain.ends_with(".internal") + || domain.ends_with(".home.arpa") + { + return Err(UrlRejectReason::PrivateOrLoopback); + } + Ok(()) +} + +fn is_blocked_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, _, _] = ip.octets(); + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || (a == 100 && (64..=127).contains(&b)) + || (a == 198 && matches!(b, 18 | 19)) +} + +fn is_blocked_ipv6(ip: Ipv6Addr) -> bool { + let segments = ip.segments(); + ip.is_loopback() + || ip.is_unspecified() + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) +} + +#[cfg(test)] +mod tests { + use super::{validate_custom_source, UrlRejectReason}; + + #[test] + fn accepts_https_domain_sources() { + assert_eq!( + validate_custom_source("https://codexapp.agentsmirror.com/latest/appcast.xml").unwrap(), + "https://codexapp.agentsmirror.com/latest/appcast.xml" + ); + assert_eq!( + validate_custom_source("https://mirror.example.com:8443/feed").unwrap(), + "https://mirror.example.com:8443/feed" + ); + assert!(validate_custom_source("https://my-mirror.internal-name.com/x").is_ok()); + assert!(validate_custom_source("https://例え.テスト/").is_ok()); + assert_eq!( + validate_custom_source("https://EXAMPLE.com:443/feed").unwrap(), + "https://example.com/feed" + ); + } + + #[test] + fn rejects_non_https_schemes() { + for raw in [ + "http://example.com/feed", + "ftp://example.com", + "file:///etc/passwd", + "data:text/plain,x", + "gopher://x", + "htps://example.com", + ] { + assert_eq!(validate_custom_source(raw), Err(UrlRejectReason::NotHttps)); + } + } + + #[test] + fn rejects_empty_userinfo_and_bad_host_shapes() { + for raw in ["", " "] { + assert_eq!(validate_custom_source(raw), Err(UrlRejectReason::Empty)); + } + for raw in [ + "https://user@example.com/", + "https://user:pw@example.com/", + "https://trusted.com@evil-internal/", + ] { + assert_eq!( + validate_custom_source(raw), + Err(UrlRejectReason::HasUserinfo) + ); + } + for raw in ["https://", "https:///path"] { + assert!(matches!( + validate_custom_source(raw), + Err(UrlRejectReason::MissingHost | UrlRejectReason::Unparsable) + )); + } + assert_eq!( + validate_custom_source("https://example.com\t/feed"), + Err(UrlRejectReason::Unparsable) + ); + } + + #[test] + fn rejects_loopback_private_and_local_domains() { + for raw in [ + "https://localhost/feed", + "https://LOCALHOST/feed", + "https://foo.local/feed", + "https://x.localhost/", + "https://h.home.arpa/", + "https://svc.internal/", + ] { + assert_eq!( + validate_custom_source(raw), + Err(UrlRejectReason::PrivateOrLoopback), + "{raw}" + ); + } + } + + #[test] + fn rejects_loopback_private_and_metadata_ips() { + for raw in [ + "https://127.0.0.1/", + "https://127.0.0.1:8443/", + "https://[::1]/", + "https://10.0.0.5/", + "https://172.16.3.4/", + "https://192.168.1.1/", + "https://169.254.169.254/latest/meta-data/", + "https://[fe80::1]/", + "https://[fc00::1]/", + "https://0.0.0.0/", + "https://[::ffff:127.0.0.1]/", + ] { + assert_eq!( + validate_custom_source(raw), + Err(UrlRejectReason::PrivateOrLoopback), + "{raw}" + ); + } + } + + #[test] + fn rejects_public_bare_ips() { + for raw in ["https://8.8.8.8/", "https://[2001:4860:4860::8888]/"] { + assert_eq!(validate_custom_source(raw), Err(UrlRejectReason::BareIp)); + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 14d16fb..feaacb6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,12 +4,20 @@ use tauri::{Emitter, Manager, State}; use tauri_plugin_autostart::ManagerExt; use tauri_plugin_dialog::DialogExt; +use crate::app::atomic_file; +use crate::app::config_health::ConfigHealth; +use crate::app::disk::available_space; use crate::app::mac_update::{ cancel_macos_download, install_macos, pause_macos_download, perform_macos_update, plan_macos_update, stage_macos_update, uninstall_macos, MacInstallStatus, MacPerformReport, MacStageReport, MacUninstallReport, MacUpdateReport, PerformExpectation, }; +use crate::app::oplock::{OperationError, OperationGuard, OperationKind, OperationToken}; +use crate::app::paths; +use crate::app::provenance::ProvenanceStore; use crate::app::settings_store::AppSettings as PersistedAppSettings; +use crate::app::settings_store::UpdateSource; +use crate::app::url_guard::validate_custom_source; use crate::app::win_update::{ auto_stage_windows_update_with_install_mode, cancel_windows_download, pause_windows_download, perform_windows_update_with_install_mode, plan_windows_update_with_install_mode, @@ -48,26 +56,34 @@ fn windows_endpoints_for_settings( ) -> Result { let mut saved = PersistedAppSettings::load(); normalize_settings_for_target(&mut saved, &state.target); - match saved.source.as_str() { - "custom" => { - let base = normalize_windows_source_base(&saved.custom_url) - .unwrap_or_else(|| state.settings.mirror_base_url.clone()); + match saved.source { + UpdateSource::Custom => { + let base = if saved.custom_url.trim().is_empty() { + state.settings.mirror_base_url.clone() + } else { + let normalized = validate_custom_source(&saved.custom_url) + .map_err(|e| AppError::Engine(e.to_string()))?; + normalize_windows_source_base(&normalized) + .unwrap_or_else(|| state.settings.mirror_base_url.clone()) + }; Ok(crate::domain::manifest::MirrorEndpoints::from_base_url(&base)) } - "official" => Err(AppError::Engine( + UpdateSource::Official => Err(AppError::Engine( "Windows official update source is not available yet; choose mirror, auto, or a custom source that serves latest/manifest, latest/checksums, and latest/win.".to_string(), )), // Windows currently depends on the mirror-style manifest/checksum/MSIX // endpoints. `auto` therefore resolves to the known-good mirror until an // official source exposes the same contract. - "auto" | "mirror" => Ok(state.endpoints.clone()), - _ => Ok(state.endpoints.clone()), + UpdateSource::Auto | UpdateSource::Mirror => Ok(state.endpoints.clone()), } } -fn normalize_settings_for_target(settings: &mut PersistedAppSettings, target: &crate::domain::target::Target) { - if matches!(target.os, OperatingSystem::Windows) && settings.source == "official" { - settings.source = "auto".to_string(); +fn normalize_settings_for_target( + settings: &mut PersistedAppSettings, + target: &crate::domain::target::Target, +) { + if matches!(target.os, OperatingSystem::Windows) && settings.source == UpdateSource::Official { + settings.source = UpdateSource::Auto; } } @@ -100,6 +116,54 @@ fn dialog_start_dir(path: &str) -> PathBuf { const MIN_PORTABLE_FREE_SPACE_BYTES: u64 = 1_073_741_824; +fn begin_guard(state: &ManagerState, kind: OperationKind) -> Result { + state + .operations + .begin(kind) + .map_err(AppError::from) + .map_err(Into::into) +} + +fn refresh_config_health(state: &ManagerState) -> ConfigHealth { + let (_, settings_health) = PersistedAppSettings::load_with_health(); + let (_, provenance_health) = ProvenanceStore::load_with_health(); + let health = ConfigHealth::from_parts(settings_health, provenance_health); + let mut slot = state + .config_health + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + *slot = health.clone(); + health +} + +fn config_path(which: &str) -> Result { + match which { + "settings" => paths::settings_path() + .ok_or_else(|| AppError::Internal("无法定位 settings.json 数据目录".to_string())), + "provenance" => paths::provenance_path() + .ok_or_else(|| AppError::Internal("无法定位 provenance.json 数据目录".to_string())), + _ => Err(AppError::Internal( + "配置类型必须是 settings 或 provenance".to_string(), + )), + } +} + +fn auto_stage_busy_report(enabled: bool, allow_metered: bool) -> WinAutoStageReport { + WinAutoStageReport { + enabled, + allow_metered, + attempted: false, + skipped: true, + reason: "operation-busy".to_string(), + stage: None, + capabilities: None, + notes: vec![ + "Automatic Windows pre-download was skipped because another operation is running." + .to_string(), + ], + } +} + fn path_key(path: &Path) -> String { path.to_string_lossy() .replace('/', "\\") @@ -156,38 +220,6 @@ fn directory_is_empty(path: &Path) -> Result { Ok(entries.next().is_none()) } -#[cfg(windows)] -fn available_space(path: &Path) -> Result, AppError> { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; - - let mut wide: Vec = path.as_os_str().encode_wide().collect(); - wide.push(0); - let mut free_to_caller = 0_u64; - let mut total = 0_u64; - let mut total_free = 0_u64; - let ok = unsafe { - GetDiskFreeSpaceExW( - wide.as_ptr(), - &mut free_to_caller, - &mut total, - &mut total_free, - ) - }; - if ok == 0 { - return Err(AppError::Internal(format!( - "读取磁盘剩余空间失败: {}", - std::io::Error::last_os_error() - ))); - } - Ok(Some(free_to_caller)) -} - -#[cfg(not(windows))] -fn available_space(_path: &Path) -> Result, AppError> { - Ok(None) -} - fn validate_install_root_path(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -293,11 +325,13 @@ pub async fn mac_plan_update( /// (no apply/swap). Runs the blocking download off the main thread. #[tauri::command] pub async fn mac_stage_update( + state: State<'_, ManagerState>, simulated_build: Option, ) -> Result { if !cfg!(target_os = "macos") { return Err(AppError::UnsupportedPlatform.into()); } + let _op = begin_guard(&state, OperationKind::Update)?; tauri::async_runtime::spawn_blocking(move || stage_macos_update(simulated_build)) .await .map_err(|e| AppError::Internal(format!("join: {e}")))? @@ -317,7 +351,10 @@ fn resolve_binary_delta(app: &tauri::AppHandle) -> Option { } } for rel in ["resources/BinaryDelta", "BinaryDelta"] { - if let Ok(res) = app.path().resolve(rel, tauri::path::BaseDirectory::Resource) { + if let Ok(res) = app + .path() + .resolve(rel, tauri::path::BaseDirectory::Resource) + { if res.exists() { return Some(res); } @@ -333,6 +370,7 @@ fn resolve_binary_delta(app: &tauri::AppHandle) -> Option { #[tauri::command] pub async fn mac_perform_update( app: tauri::AppHandle, + state: State<'_, ManagerState>, confirm: bool, expected_from_build: u64, expected_to_build: u64, @@ -342,8 +380,11 @@ pub async fn mac_perform_update( return Err(AppError::UnsupportedPlatform.into()); } if !confirm { - return Err(AppError::Internal("拒绝执行:破坏性更新必须带显式 confirm".to_string()).into()); + return Err( + AppError::Internal("拒绝执行:破坏性更新必须带显式 confirm".to_string()).into(), + ); } + let _op = begin_guard(&state, OperationKind::Update)?; // Best-effort: a full-package update needs no delta tool, so don't reject the // whole operation when it's absent — only the delta branch requires it. let binary_delta = resolve_binary_delta(&app); @@ -382,6 +423,7 @@ pub fn mac_adopt(state: State<'_, ManagerState>) -> Result Result<(), CommandError> { /// macOS-only: fresh-install the latest Codex (full package) into /Applications. /// Runs the blocking download/verify/install off the main thread. #[tauri::command] -pub async fn mac_install(app: tauri::AppHandle) -> Result { +pub async fn mac_install( + app: tauri::AppHandle, + state: State<'_, ManagerState>, +) -> Result { if !cfg!(target_os = "macos") { return Err(AppError::UnsupportedPlatform.into()); } + let _op = begin_guard(&state, OperationKind::Install)?; tauri::async_runtime::spawn_blocking(move || { let report = move |p: crate::app::mac_update::DownloadProgress| { let _ = app.emit("mac://download-progress", p); @@ -461,6 +507,7 @@ pub async fn win_stage_update( if !matches!(state.target.os, OperatingSystem::Windows) { return Err(AppError::UnsupportedPlatform.into()); } + let _op = begin_guard(&state, OperationKind::Update)?; let endpoints = windows_endpoints_for_settings(&state)?; let settings = windows_domain_settings_for_persisted(&state); let install_mode = windows_install_mode_for_settings(); @@ -489,10 +536,105 @@ pub fn set_settings( let mut s = settings; s.normalize(); normalize_settings_for_target(&mut s, &state.target); + if s.source == UpdateSource::Custom && !s.custom_url.trim().is_empty() { + s.custom_url = + validate_custom_source(&s.custom_url).map_err(|e| AppError::Engine(e.to_string()))?; + } + let _op = begin_guard(&state, OperationKind::SetInstallRoot)?; s.save()?; + refresh_config_health(&state); Ok(s) } +#[tauri::command] +pub fn get_config_health(state: State<'_, ManagerState>) -> ConfigHealth { + state + .config_health + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() +} + +#[tauri::command] +pub fn restore_config_backup( + state: State<'_, ManagerState>, + which: String, +) -> Result { + let _op = begin_guard(&state, OperationKind::SetInstallRoot)?; + let path = config_path(which.as_str())?; + let backup = atomic_file::backup_path(&path); + if !backup.exists() { + return Err(AppError::Internal(format!("找不到 {} 的 .bak 备份", which)).into()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| AppError::Internal(format!("create data dir: {e}")))?; + } + let current_tmp = path.with_extension(format!("restore-current-{}", std::process::id())); + if path.exists() { + std::fs::rename(&path, ¤t_tmp) + .map_err(|e| AppError::Internal(format!("move current config aside: {e}")))?; + } + if let Err(err) = std::fs::rename(&backup, &path) { + if current_tmp.exists() { + let _ = std::fs::rename(¤t_tmp, &path); + } + return Err(AppError::Internal(format!("restore config backup: {err}")).into()); + } + if current_tmp.exists() { + let _ = std::fs::remove_file(current_tmp); + } + Ok(refresh_config_health(&state)) +} + +#[tauri::command] +pub fn reset_config( + state: State<'_, ManagerState>, + which: String, +) -> Result { + let _op = begin_guard(&state, OperationKind::SetInstallRoot)?; + match which.as_str() { + "settings" => { + let mut settings = PersistedAppSettings::default(); + normalize_settings_for_target(&mut settings, &state.target); + settings.save()?; + } + "provenance" => { + ProvenanceStore::default().save()?; + } + _ => { + return Err( + AppError::Internal("配置类型必须是 settings 或 provenance".to_string()).into(), + ) + } + } + Ok(refresh_config_health(&state)) +} + +#[tauri::command] +pub fn begin_operation( + state: State<'_, ManagerState>, + kind: OperationKind, +) -> Result { + state + .operations + .begin_detached(kind) + .map_err(AppError::from) + .map_err(Into::into) +} + +#[tauri::command] +pub fn end_operation( + state: State<'_, ManagerState>, + token: OperationToken, +) -> Result<(), CommandError> { + state + .operations + .end(token) + .map_err(AppError::from) + .map_err(Into::into) +} + /// The user confirmed quitting from the close dialog — flag it and exit so the /// CloseRequested / ExitRequested guards stop intercepting and let it go. #[tauri::command] @@ -553,6 +695,7 @@ pub fn win_set_install_root( if !matches!(state.target.os, OperatingSystem::Windows) { return Err(AppError::UnsupportedPlatform.into()); } + let _op = begin_guard(&state, OperationKind::SetInstallRoot)?; let install_root = validate_install_root_path(&path)?; let mut settings = PersistedAppSettings::load(); settings.install_root = install_root; @@ -569,6 +712,7 @@ pub fn win_reset_install_root( if !matches!(state.target.os, OperatingSystem::Windows) { return Err(AppError::UnsupportedPlatform.into()); } + let _op = begin_guard(&state, OperationKind::SetInstallRoot)?; let install_root = validate_install_root_path(&PersistedAppSettings::default().install_root)?; let mut settings = PersistedAppSettings::load(); settings.install_root = install_root; @@ -582,6 +726,7 @@ pub fn win_reset_install_root( /// user opts out. Runs the blocking work off the main thread. #[tauri::command] pub async fn mac_uninstall( + state: State<'_, ManagerState>, confirm: bool, keep_codex_home: bool, ) -> Result { @@ -591,6 +736,7 @@ pub async fn mac_uninstall( if !confirm { return Err(AppError::Internal("拒绝执行:卸载必须带显式 confirm".to_string()).into()); } + let _op = begin_guard(&state, OperationKind::Uninstall)?; tauri::async_runtime::spawn_blocking(move || uninstall_macos(keep_codex_home)) .await .map_err(|e| AppError::Internal(format!("join: {e}")))? @@ -608,6 +754,17 @@ pub async fn win_auto_stage_update( if !matches!(state.target.os, OperatingSystem::Windows) { return Err(AppError::UnsupportedPlatform.into()); } + let _op = if enabled { + match state.operations.begin(OperationKind::Update) { + Ok(guard) => Some(guard), + Err(OperationError::BusySameProcess(_) | OperationError::BusyOtherProcess) => { + return Ok(auto_stage_busy_report(enabled, allow_metered)); + } + Err(err) => return Err(AppError::from(err).into()), + } + } else { + None + }; let endpoints = windows_endpoints_for_settings(&state)?; let settings = windows_domain_settings_for_persisted(&state); let install_mode = windows_install_mode_for_settings(); @@ -756,7 +913,10 @@ mod open_url_tests { "https://user@example.com/", "https://", ] { - assert!(validate_external_http_url(url).is_err(), "{url} should be rejected"); + assert!( + validate_external_http_url(url).is_err(), + "{url} should be rejected" + ); } } } @@ -777,6 +937,7 @@ pub fn win_adopt(state: State<'_, ManagerState>) -> Result "unsupported_platform", Self::Engine(_) => "engine_error", Self::StaleExpectation(_) => "stale_expectation", + Self::Busy(_) => "operation_busy", Self::Internal(_) => "internal_error", } } } +impl From for AppError { + fn from(value: OperationError) -> Self { + Self::Busy(value.to_string()) + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CommandError { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d3a561..54ef8b2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -62,6 +62,13 @@ fn install_macos_menu(app: &tauri::App) -> tauri::Result<()> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_dialog::init()) @@ -85,6 +92,11 @@ pub fn run() { commands::mac_uninstall, commands::get_settings, commands::set_settings, + commands::get_config_health, + commands::restore_config_backup, + commands::reset_config, + commands::begin_operation, + commands::end_operation, commands::confirm_quit, commands::win_default_install_root, commands::win_pick_install_dir, @@ -107,7 +119,15 @@ pub fn run() { .setup(|app| { #[cfg(target_os = "macos")] install_macos_menu(app)?; - let _ = &app; + let health = app + .state::() + .config_health + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone(); + if !health.is_ok() { + let _ = app.emit("app://config-health", health); + } Ok(()) }) // Our custom macOS Quit item lands here (Cmd+Q). Same guard as the diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 1c1f584..9fd9332 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,10 @@ use std::sync::atomic::AtomicBool; +use std::sync::Mutex; use crate::adapters::host; +use crate::app::config_health::ConfigHealth; +use crate::app::oplock::OperationManager; +use crate::app::provenance::ProvenanceStore; use crate::app::settings_store::AppSettings as PersistedAppSettings; use crate::domain::manifest::MirrorEndpoints; use crate::domain::settings::AppSettings; @@ -13,13 +17,18 @@ pub struct ManagerState { /// Set once the user confirms quitting (or has the guard off) so the close / /// exit handlers stop intercepting and let the process go. pub force_quit: AtomicBool, + pub operations: OperationManager, + pub config_health: Mutex, } impl ManagerState { pub fn new() -> Self { let target = Target::current(); let mirror_base_url = "https://codexapp.agentsmirror.com".to_string(); - let saved = PersistedAppSettings::load(); + let (saved, settings_health) = PersistedAppSettings::load_with_health(); + let (_, provenance_health) = ProvenanceStore::load_with_health(); + let config_health = + Mutex::new(ConfigHealth::from_parts(settings_health, provenance_health)); let install_root = if saved.install_root.trim().is_empty() { host::default_install_root(&target) } else { @@ -27,12 +36,18 @@ impl ManagerState { }; let settings = AppSettings::new(mirror_base_url.clone(), install_root); let endpoints = MirrorEndpoints::from_base_url(&mirror_base_url); + let lock_path = crate::app::paths::data_dir() + .map(|dir| dir.join("operation.lock")) + .unwrap_or_else(|| std::env::temp_dir().join("codex-app-manager-operation.lock")); + let operations = OperationManager::new(lock_path); Self { target, settings, endpoints, force_quit: AtomicBool::new(false), + operations, + config_health, } } }