diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index 6af9cfe..c32d0a8 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -12,6 +12,7 @@ //! already on the latest build (the user's case during development). use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use serde::Serialize; @@ -382,7 +383,6 @@ fn quit_codex_gracefully() -> Result<(), AppError> { } fn download_and_verify( - staging: &Path, url: &str, size: u64, max_size: u64, @@ -398,7 +398,11 @@ fn download_and_verify( ))); } let file_name = url.rsplit('/').next().unwrap_or("payload.bin"); - let dest = staging.join(file_name); + // Download into the PERSISTENT cache (not a per-run staging dir): a paused + // `.part` survives here, so the next perform/install resumes it instead of + // restarting at 0. The artifact is consumed (verified → unpacked/applied) + // from here; success clears the cache (see perform/install tails). + let dest = staging::download_cache_path(url, file_name)?; let source = host_of(url); let already = std::fs::metadata(&dest) @@ -461,6 +465,10 @@ fn max_bytes_for_strategy(strategy: &UpdateStrategy) -> u64 { pub fn stage_macos_update(simulated_build: Option) -> Result { log::info!("macOS stage start simulated_build={simulated_build:?}"); + // A standalone stage can also be cancelled (its download sets the abort + // latch). Own a guard so a cancelled stage can't leave the latch set and + // make the NEXT perform/install abort itself at its first checkpoint. + let _abort_guard = AbortGuard; let installed = detect_managed_installed(); let (_, xml) = fetch_appcast_for_arch(arch_of(&installed))?; let appcast = parse_appcast(&xml).map_err(|e| AppError::Engine(e.to_string()))?; @@ -494,23 +502,18 @@ pub fn stage_macos_update(simulated_build: Option) -> Result { - let _ = staging.keep(); - dest - } + ) { + Ok(dest) => dest, Err(err) => { log::error!("macOS stage failed error={err}"); - staging.discard(); return Err(err); } }; @@ -650,7 +653,6 @@ fn reconstruct_full( AppError::Engine("appcast full enclosure missing edSignature".to_string()) })?; let staged = download_and_verify( - work, &latest.full.url, latest.full.length, codex_mac_engine::limits::MAX_PACKAGE_BYTES, @@ -688,6 +690,9 @@ pub fn perform_macos_update( expected: PerformExpectation, progress: &dyn Fn(DownloadProgress), ) -> Result { + // Reset the latch when THIS op ends (not at entry) so a cancel racing the + // op's startup isn't wiped. See AbortGuard. + let _abort_guard = AbortGuard; // A vanished install is itself a stale snapshot: the user confirmed an // update against a Codex that is no longer there (deleted / moved between // confirm and execute). Route it through StaleExpectation so the UI @@ -738,6 +743,9 @@ pub fn perform_macos_update( let appcast = parse_appcast(&xml).map_err(|e| AppError::Engine(e.to_string()))?; let plan = plan_update(&appcast, installed.build) .ok_or_else(|| AppError::Engine("appcast had no items".to_string()))?; + // The appcast fetch is the slow part of "正在准备" — honor a cancel here, + // before the up-to-date / stale early-returns and the destructive work below. + check_update_abort()?; // The appcast must still point at the build the user confirmed. if plan.latest_build != expected.to_build { @@ -773,6 +781,11 @@ pub fn perform_macos_update( require_os_supported(latest.minimum_system_version.as_deref())?; } preflight_mac_disk(&plan)?; + // Last cancel checkpoint before the download begins: the preparing phase + // (appcast fetch → plan → preflight) is done; once curl starts, the download + // loop's own cancel flag takes over. A cancel pressed during "正在准备" + // lands here and bails before any destructive prep. + check_update_abort()?; // 1) Set up same-volume staging for the reconstructed bundle + backup. let staging = staging::create_unique_staging("update")?; @@ -795,7 +808,6 @@ pub fn perform_macos_update( AppError::Engine("appcast delta missing edSignature".to_string()) })?; let staged = download_and_verify( - &work, &plan.download_url, plan.download_size, codex_mac_engine::limits::MAX_DELTA_BYTES, @@ -834,6 +846,12 @@ pub fn perform_macos_update( )); } + // Point of no return. Honor a cancel one last time BEFORE we touch the + // user's running Codex — this also closes the gap after the preparing- + // phase checkpoint where a fully-cached artifact skips the download loop + // (so its cancel flag never arms) yet reconstruct/gate still ran. + check_update_abort()?; + // 4b) graceful quit (never force-kill), then 5) atomic same-volume swap. If // the swap fails after the quit, swap_in_place has restored the old // bundle in place — bring the user's app back before surfacing the error. @@ -950,6 +968,12 @@ pub fn perform_macos_update( } else { staging.discard(); } + // The artifact was downloaded, verified, and consumed — drop it so a + // later run re-downloads fresh. A FAILED run leaves the cache intact + // (the Err arm) so a paused/interrupted download can still resume. + // Best-effort: a stale artifact left behind is reclaimed by the stale + // cache sweep, so a cleanup failure must not fail a successful update. + let _ = staging::clear_download_cache(); Ok(report) } Err(err) => { @@ -1030,6 +1054,8 @@ fn choose_install_dir() -> Result { /// replace). Records `manager-installed` provenance and launches the app. pub fn install_macos(progress: &dyn Fn(DownloadProgress)) -> Result { log::info!("macOS install start"); + // Reset on op end, not entry — race-free cancel (see AbortGuard). + let _abort_guard = AbortGuard; if detect_installed().is_some() { return Err(AppError::Engine( "已检测到 Codex,请使用更新而非安装".to_string(), @@ -1052,6 +1078,9 @@ pub fn install_macos(progress: &dyn Fn(DownloadProgress)) -> Result Result { staging.discard(); + // Consumed on success — clear it (best-effort; the stale sweep + // reclaims any leftover). A failed install keeps the cached partial + // (Err arm) so the next attempt resumes instead of restarting. + let _ = staging::clear_download_cache(); let build = status.installed.as_ref().map(|installed| installed.build); log::info!("macOS install complete build={build:?}"); Ok(status) @@ -1093,6 +1126,9 @@ fn install_macos_in_staging( install_dir.display() ))); } + // Point of no return. Last cancel check before writing into the install + // location — covers a cached-artifact path that skipped the download loop. + check_update_abort()?; std::fs::rename(&out_app, install_path) .map_err(|e| AppError::Engine(format!("写入 {} 失败: {e}", install_dir.display())))?; @@ -1130,16 +1166,72 @@ pub fn launch_codex() -> Result<(), AppError> { }) } +/// Preparing-phase abort latch. The download loop has its own cancel flag once +/// curl is running, but everything BEFORE the first byte — appcast fetch, +/// planning, disk preflight — used to be an uncancellable wait. This latch lets +/// a cancel pressed during "正在准备" be honored at the next checkpoint. +static UPDATE_ABORT: AtomicBool = AtomicBool::new(false); + +fn clear_update_abort() { + UPDATE_ABORT.store(false, Ordering::SeqCst); +} + +/// Resets the abort latch when the operation that owns it ends — on EVERY path +/// (success, error, early return, panic). Clearing on DROP (not at entry) is what +/// makes the latch race-free: a cancel that lands in the window between the UI +/// showing its cancel button and this operation reaching its first checkpoint is +/// NOT wiped by an entry-clear, so the checkpoint still observes it; the latch is +/// reset only once this operation is done, leaving the next one clean. The cancel +/// command does not hold the op lock, so this startup window is real — hence the +/// guard instead of an entry reset. +struct AbortGuard; + +impl Drop for AbortGuard { + fn drop(&mut self) { + clear_update_abort(); + } +} + +/// Bail out of the preparing phase when the user cancelled. Surfaces the same +/// "download cancelled" marker the curl-cancel path uses, so the UI treats it +/// as a cancel (routes home + cancelled notice) uniformly. +fn check_update_abort() -> Result<(), AppError> { + if UPDATE_ABORT.load(Ordering::SeqCst) { + Err(AppError::Engine("download cancelled".to_string())) + } else { + Ok(()) + } +} + pub fn pause_macos_download() -> bool { + // Pause is only offered once bytes are flowing (UI disables it during + // preparing), so it stays a pure download-loop operation — keep the `.part`. let requested = download::pause_active_download(); log::info!("macOS pause download requested={requested}"); requested } pub fn cancel_macos_download() -> bool { + // Latch the preparing-phase abort too: a cancel pressed before the first + // byte (or mid appcast-fetch) is honored at the next checkpoint, not just an + // already-running curl. Report actionable unconditionally — during preparing + // the latch IS the cancel mechanism, so the UI must not say "不能取消". + UPDATE_ABORT.store(true, Ordering::SeqCst); let requested = download::cancel_active_download(); log::info!("macOS cancel download requested={requested}"); - requested + true +} + +/// Paused-state cancel: the download already stopped and its `.part` is on disk. +/// Clear the cache so "继续" can't resume, and drop the abort latch. Surfaces a +/// removal failure (vs. silently reporting a cancel that left the partial behind, +/// which a later run would then resume). +pub fn discard_macos_download() -> Result<(), AppError> { + clear_update_abort(); + staging::clear_download_cache() + .map_err(|e| AppError::Internal(format!("清理下载缓存失败: {e}")))?; + log::info!("macOS discard download cache"); + Ok(()) } #[derive(Debug, Clone, Serialize)] @@ -1274,6 +1366,29 @@ mod disk_preflight_tests { mod tests { use super::*; + #[test] + fn abort_guard_preserves_a_startup_race_cancel_and_resets_on_drop() { + // The race a reviewer flagged: a cancel can land BEFORE perform reaches + // its first checkpoint — the cancel command holds no op lock, and the UI + // shows the cancel button the moment it enters the progress state, before + // the backend call returns. Clearing the latch at op ENTRY would wipe that + // cancel; AbortGuard clears on DROP instead, so a pending cancel survives + // the guard's creation and is still observed, while the next op starts + // clean. (Only this test touches UPDATE_ABORT, so it can't race others.) + UPDATE_ABORT.store(true, Ordering::SeqCst); // a cancel that beat the op + { + let _guard = AbortGuard; + assert!( + check_update_abort().is_err(), + "guard creation must not wipe a pending cancel" + ); + } + assert!( + check_update_abort().is_ok(), + "guard drop must reset the latch for the next op" + ); + } + fn ditto(args: &[&str]) { let status = std::process::Command::new(DITTO) .args(args) diff --git a/src-tauri/src/app/staging.rs b/src-tauri/src/app/staging.rs index 3b9c0eb..6d73565 100644 --- a/src-tauri/src/app/staging.rs +++ b/src-tauri/src/app/staging.rs @@ -67,6 +67,84 @@ pub fn create_unique_staging(prefix: &str) -> Result { Ok(StagingDir { root }) } +/// Root of the persistent download cache — SEPARATE from the per-run `update-*` +/// staging dirs. A unique staging dir is deleted wholesale on pause/cancel +/// (`StagingDir::discard`), which is exactly what used to eat a paused +/// download's `.part` and make "再次更新会继续下载" a lie. Download artifacts +/// live here instead, so the engine's `.part` (kept on pause, removed on +/// cancel) survives across perform/install calls and the next run resumes it. +pub fn download_cache_root() -> PathBuf { + staging_root().join("downloads") +} + +/// Stable on-disk path for the artifact at `url`. Keyed by a per-URL hash so a +/// changed target (new build / different mirror) never resumes onto a stale +/// partial — a different URL is a different file. The human-readable +/// `file_name` is preserved (sanitized) as a suffix so the staged artifact is +/// still recognizable on disk and keeps its extension for downstream tooling. +pub fn download_cache_path(url: &str, file_name: &str) -> Result { + let root = download_cache_root(); + std::fs::create_dir_all(&root) + .map_err(|e| AppError::Internal(format!("创建下载缓存目录失败: {e}")))?; + set_owner_only(&root)?; + Ok(root.join(format!( + "{:016x}-{}", + fnv1a64(url.as_bytes()), + sanitize_file_name(file_name) + ))) +} + +/// FNV-1a (64-bit). A fixed, toolchain-independent digest so a cached download's +/// directory name stays identical across manager updates — unlike `DefaultHasher` +/// (SipHash), whose seed/impl can shift between Rust versions and would orphan +/// the cache on every upgrade. Collision resistance isn't security-critical: the +/// artifact's size + EdDSA/SHA-256 verification is the real gate; this only +/// namespaces per-URL partials so different targets never resume onto each other. +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + hash ^= b as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Drop every cached download. Used when the user cancels from the paused state +/// (the engine already removed the `.part` on an in-flight cancel, but a paused +/// `.part` is still on disk) and after a successful update consumes the +/// artifact. Only one update runs at a time, so clearing the whole dir is safe. +/// Returns `Ok` if the cache is already gone, `Err` only on a real removal +/// failure so a paused-state cancel can surface "didn't actually discard". +pub fn clear_download_cache() -> std::io::Result<()> { + match std::fs::remove_dir_all(download_cache_root()) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// Keep download cache file names to a safe, path-separator-free charset. The +/// caller's `file_name` comes from a URL tail or MSIX moniker; this defends +/// against an upstream path-traversal-ish name escaping the cache dir. +fn sanitize_file_name(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + let trimmed = cleaned.trim_matches('.'); + if trimmed.is_empty() { + "download.bin".to_string() + } else { + trimmed.to_string() + } +} + pub fn cleanup_stale_staging(ops: &OperationManager) -> CleanupSummary { let mut summary = CleanupSummary::default(); if ops.is_busy() { @@ -76,38 +154,65 @@ pub fn cleanup_stale_staging(ops: &OperationManager) -> CleanupSummary { } let root = staging_root(); - let Ok(entries) = std::fs::read_dir(&root) else { - let path = root.display(); - log::debug!("staging cleanup found no root path={path}"); - return summary; - }; let now = SystemTime::now(); - for entry in entries.flatten() { - let path = entry.path(); - let is_update_dir = path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("update-")); - if !is_update_dir || !path.is_dir() { - continue; - } - summary.scanned += 1; - if !is_stale(&path, now) { - continue; + if let Ok(entries) = std::fs::read_dir(&root) { + for entry in entries.flatten() { + let path = entry.path(); + let is_update_dir = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("update-")); + if !is_update_dir || !path.is_dir() { + continue; + } + summary.scanned += 1; + if !is_stale(&path, now) { + continue; + } + match std::fs::remove_dir_all(&path) { + Ok(()) => { + summary.removed += 1; + let path_display = path.display(); + log::debug!("staging cleanup removed path={path_display}"); + } + Err(err) => { + summary.failed += 1; + let path_display = path.display(); + log::debug!("staging cleanup failed path={path_display} error={err}"); + } + } } - match std::fs::remove_dir_all(&path) { - Ok(()) => { - summary.removed += 1; - let path_display = path.display(); - log::debug!("staging cleanup removed path={path_display}"); + } else { + let path = root.display(); + log::debug!("staging cleanup found no root path={path}"); + } + + // Prune stale cached downloads too. A paused `.part` younger than STALE_AFTER + // is left intact so a resume still finds it; only abandoned partials/artifacts + // are reclaimed. Guarded by the same `is_busy` check above, so an in-flight + // download (op active) is never touched. + if let Ok(entries) = std::fs::read_dir(download_cache_root()) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() || !is_stale(&path, now) { + continue; } - Err(err) => { - summary.failed += 1; - let path_display = path.display(); - log::debug!("staging cleanup failed path={path_display} error={err}"); + summary.scanned += 1; + match std::fs::remove_file(&path) { + Ok(()) => { + summary.removed += 1; + let path_display = path.display(); + log::debug!("download cache cleanup removed path={path_display}"); + } + Err(err) => { + summary.failed += 1; + let path_display = path.display(); + log::debug!("download cache cleanup failed path={path_display} error={err}"); + } } } } + log::info!( "staging cleanup summary scanned={} removed={} failed={}", summary.scanned, @@ -145,7 +250,10 @@ fn set_owner_only(_path: &Path) -> Result<(), AppError> { #[cfg(test)] mod tests { - use super::{cleanup_stale_staging, create_unique_staging}; + use super::{ + cleanup_stale_staging, clear_download_cache, create_unique_staging, download_cache_path, + download_cache_root, + }; use crate::app::oplock::{OperationKind, OperationManager}; use std::fs; use std::sync::atomic::{AtomicU64, Ordering}; @@ -191,6 +299,35 @@ mod tests { staging.discard(); } + #[test] + fn download_cache_path_is_stable_per_url_and_collision_free() { + // Same URL → same path across calls (so a second run resumes the .part). + let a1 = download_cache_path("https://m.example/codex-1.zip", "codex-1.zip").unwrap(); + let a2 = download_cache_path("https://m.example/codex-1.zip", "codex-1.zip").unwrap(); + assert_eq!(a1, a2); + // Different URL → different path (never resume onto a stale partial). + let b = download_cache_path("https://m.example/codex-2.zip", "codex-2.zip").unwrap(); + assert_ne!(a1, b); + // The cache dir is the dedicated `downloads` root, not an `update-*` dir. + assert_eq!(a1.parent().unwrap(), download_cache_root()); + // A hostile file name can't escape the cache dir: every path separator + // is neutralized, so the result is a single component inside the cache + // root (a residual ".." with no separator around it is just text). + let evil = download_cache_path("https://m.example/x", "../../etc/passwd").unwrap(); + assert_eq!(evil.parent().unwrap(), download_cache_root()); + let evil_name = evil.file_name().unwrap().to_str().unwrap(); + assert!(!evil_name.contains('/') && !evil_name.contains('\\')); + } + + #[test] + fn clear_download_cache_removes_the_root() { + let p = download_cache_path("https://m.example/clear-me.zip", "clear-me.zip").unwrap(); + fs::write(&p, b"partial").unwrap(); + assert!(p.exists()); + clear_download_cache().unwrap(); + assert!(!p.exists()); + } + #[test] fn cleanup_skips_everything_while_operation_is_busy() { let staging = create_unique_staging("update").unwrap(); diff --git a/src-tauri/src/app/win_update.rs b/src-tauri/src/app/win_update.rs index a0ae94c..7109d5c 100644 --- a/src-tauri/src/app/win_update.rs +++ b/src-tauri/src/app/win_update.rs @@ -7,6 +7,7 @@ //! verification into staging. Non-destructive; it does not install yet. use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use serde::{Deserialize, Serialize}; @@ -243,10 +244,6 @@ fn read_windows_release(endpoints: &MirrorEndpoints) -> Result<(WindowsRelease, Ok((release, sha256)) } -fn staged_msix_path(staging: &std::path::Path, release: &WindowsRelease) -> PathBuf { - staging.join(format!("{}.msix", release.package_moniker)) -} - fn route_label(plan: &WindowsUpdatePlan) -> String { match plan.route { codex_win_engine::WinInstallRoute::MsixSideload => "msix-sideload", @@ -404,7 +401,19 @@ pub fn stage_windows_update_with_install_mode( progress: &dyn Fn(DownloadProgress), ) -> Result { log::info!("Windows stage start install_mode={install_mode}"); + // Own a guard so EVERY stage caller — `perform` (nested, harmless), background + // `auto_stage`, and the standalone `win_stage_update` command — resets the + // latch on exit. Without it, a cancelled background/standalone stage would + // leave the latch set and make the next user perform abort at its first check. + // (Nesting under perform's guard can't lose a reachable cancel: once the + // download completes the UI is in the "finishing" state with cancel disabled, + // so no cancel lands during stage's post-download verify.) + let _abort_guard = WinAbortGuard; let report = plan_windows_update_with_install_mode(endpoints, settings, install_mode)?; + // The plan above did the manifest/checksums fetch (the Windows "正在准备" + // phase). Honor a cancel here — before the up-to-date early-return and the + // download below; once curl runs, its own cancel flag takes over. + check_win_update_abort()?; let route = route_label(&report.plan); if report.plan.up_to_date { log::info!( @@ -429,9 +438,15 @@ pub fn stage_windows_update_with_install_mode( }); } - let staging = staging::create_unique_staging("update")?; let stage_result = (|| -> Result { - let dest = staged_msix_path(staging.path(), &report.release); + // Downloads into the PERSISTENT cache so a paused `.part` survives for the + // next resume instead of dying with a per-run staging dir. (A preparing- + // phase cancel was already checked right after the plan above; the + // transfer itself is interruptible via the download loop's cancel flag.) + let dest = staging::download_cache_path( + &report.package_url, + &format!("{}.msix", report.release.package_moniker), + )?; let expected_size = report.release.content_length.unwrap_or(0); if expected_size > MAX_PACKAGE_BYTES { return Err(AppError::Engine(format!( @@ -465,6 +480,15 @@ pub fn stage_windows_update_with_install_mode( .map_err(engine_err)?; } + // A fully-cached MSIX is hash-verified above WITHOUT firing a progress + // event, so the UI is still in "正在准备" (cancel enabled) during that + // hash — unlike the download path, which fires progress and flips the UI + // to the cancel-disabled "finishing" state. Honor a cancel that landed + // during the cache hash here, before we commit to the artifact: otherwise + // the stage guard (nested under perform) would clear the latch on success + // and perform's later checkpoint would never see it. + check_win_update_abort()?; + let actual_size = std::fs::metadata(&dest) .map_err(|e| AppError::Engine(format!("read staged MSIX metadata: {e}")))? .len(); @@ -549,7 +573,6 @@ pub fn stage_windows_update_with_install_mode( })(); match stage_result { Ok(report) => { - let _ = staging.keep(); let route = &report.route; let verified = report.hash_verified && report.identity_verified; let portable_fallback_ready = report.portable_fallback_ready; @@ -559,7 +582,6 @@ pub fn stage_windows_update_with_install_mode( Ok(report) } Err(err) => { - staging.discard(); log::error!("Windows stage failed error={err}"); Err(err) } @@ -677,18 +699,73 @@ pub fn auto_stage_windows_update_with_install_mode( }) } +/// Preparing-phase abort latch (mirrors the macOS one). Covers the gap before +/// the first byte — manifest/checksums fetch, planning — that the curl-level +/// cancel flag can't reach. Reset on op end via `WinAbortGuard`, not at entry. +static WIN_UPDATE_ABORT: AtomicBool = AtomicBool::new(false); + +fn clear_win_update_abort() { + WIN_UPDATE_ABORT.store(false, Ordering::SeqCst); +} + +/// Resets the latch when the owning operation ends — on every path. Clearing on +/// DROP (not at entry) keeps the cancel race-free: a cancel landing between the +/// UI showing its button and the op reaching its first checkpoint isn't wiped, so +/// the checkpoint observes it; the next op still starts clean. The cancel command +/// doesn't hold the op lock, so this startup window is real. Owned by both +/// `perform` and `stage` (so background `auto_stage` and the standalone +/// `win_stage_update` can't leak a set latch into the next op). The perform→stage +/// nesting is harmless: clears are idempotent, and the only window the inner clear +/// could touch (stage's post-download verify) has the UI cancel already disabled. +struct WinAbortGuard; + +impl Drop for WinAbortGuard { + fn drop(&mut self) { + clear_win_update_abort(); + } +} + +/// Bail out of the Windows preparing phase on a user cancel. Surfaces the same +/// "download cancelled" marker the curl-cancel path uses so the UI treats it as +/// a cancel uniformly. +fn check_win_update_abort() -> Result<(), AppError> { + if WIN_UPDATE_ABORT.load(Ordering::SeqCst) { + Err(AppError::Engine("download cancelled".to_string())) + } else { + Ok(()) + } +} + pub fn cancel_windows_download() -> bool { + // Latch the preparing-phase abort too, so a cancel pressed before the first + // byte (mid manifest-fetch) is honored at the next checkpoint. Report + // actionable unconditionally — during preparing the latch IS the cancel. + WIN_UPDATE_ABORT.store(true, Ordering::SeqCst); let requested = cancel_active_download(); log::info!("Windows cancel download requested={requested}"); - requested + true } pub fn pause_windows_download() -> bool { + // Pause is only offered once bytes flow (UI disables it during preparing), + // so it stays a pure download-loop operation — keep the `.part`. let requested = pause_active_download(); log::info!("Windows pause download requested={requested}"); requested } +/// Paused-state cancel: the download already stopped and its `.part` is on disk. +/// Clear the cache so "继续" can't resume, and drop the abort latch. Surfaces a +/// removal failure rather than silently reporting a cancel that left the partial +/// behind (which a later run would resume). +pub fn discard_windows_download() -> Result<(), AppError> { + clear_win_update_abort(); + staging::clear_download_cache() + .map_err(|e| AppError::Engine(format!("清理下载缓存失败: {e}")))?; + log::info!("Windows discard download cache"); + Ok(()) +} + pub fn perform_windows_update( endpoints: &MirrorEndpoints, settings: &AppSettings, @@ -713,6 +790,10 @@ pub fn perform_windows_update_with_install_mode( progress: &dyn Fn(DownloadProgress), ) -> Result { log::info!("Windows perform start install_mode={install_mode}"); + // Reset the latch when THIS perform ends (not at stage entry) so a cancel + // racing the op's startup isn't wiped, and `auto_stage` never clears it. See + // WinAbortGuard. + let _abort_guard = WinAbortGuard; if !confirm { return Err(AppError::Internal( "explicit confirmation is required before installing Windows Codex".to_string(), @@ -747,6 +828,11 @@ pub fn perform_windows_update_with_install_mode( }); } + // Point of no return. Honor a cancel one last time BEFORE closing Codex or + // sideloading — closes the gap after staging where a fully-cached MSIX skips + // the download loop (so its cancel flag never arms) yet still reaches here. + check_win_update_abort()?; + if stage.route == "portable-fallback" { log::warn!("Windows route changed to portable fallback from_route=msix-sideload to_route=portable-fallback"); close_existing_codex_before_portable_fallback(settings, current_installed.as_ref())?; @@ -1133,8 +1219,32 @@ pub fn uninstall_windows_codex( #[cfg(test)] mod tests { - use super::{bind_manifest_checksums, WinPerformAction}; + use super::{ + bind_manifest_checksums, check_win_update_abort, WinAbortGuard, WinPerformAction, + WIN_UPDATE_ABORT, + }; use codex_win_engine::WindowsRelease; + use std::sync::atomic::Ordering; + + #[test] + fn win_abort_guard_preserves_a_startup_race_cancel_and_resets_on_drop() { + // Mirrors the macOS guard test: a cancel landing before `perform` reaches + // its first checkpoint must survive the guard's creation (no entry-clear) + // and still be observed; the guard resets the latch on drop so the next + // op — and background auto_stage — start clean. + WIN_UPDATE_ABORT.store(true, Ordering::SeqCst); + { + let _guard = WinAbortGuard; + assert!( + check_win_update_abort().is_err(), + "guard creation must not wipe a pending cancel" + ); + } + assert!( + check_win_update_abort().is_ok(), + "guard drop must reset the latch for the next op" + ); + } #[test] fn serializes_win_perform_actions_as_frontend_contract() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 38661c5..749fb65 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -11,9 +11,9 @@ use crate::app::diagnostics::Diagnostics; use crate::app::disk::available_space; use crate::app::logging::redact_url; 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, + cancel_macos_download, discard_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, OperationManager, OperationToken, @@ -24,10 +24,10 @@ 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, - stage_windows_update_with_install_mode, uninstall_windows_codex, - win_adopt as adopt_windows_install, win_install_status, + auto_stage_windows_update_with_install_mode, cancel_windows_download, discard_windows_download, + pause_windows_download, perform_windows_update_with_install_mode, + plan_windows_update_with_install_mode, stage_windows_update_with_install_mode, + uninstall_windows_codex, win_adopt as adopt_windows_install, win_install_status, DownloadProgress as WinDownloadProgress, WinAutoStageReport, WinInstallStatus, WinPerformExpectation, WinPerformReport, WinStageReport, WinUninstallReport, WinUpdateReport, }; @@ -542,6 +542,17 @@ pub fn mac_cancel_download() -> Result { Ok(cancel_macos_download()) } +/// macOS-only: discard a PAUSED download. After a pause the curl process is gone +/// but its `.part` is still cached for resume; this drops it when the user +/// cancels from the paused state instead of resuming. +#[tauri::command] +pub fn mac_discard_download() -> Result<(), CommandError> { + if !cfg!(target_os = "macos") { + return Err(AppError::UnsupportedPlatform.into()); + } + discard_macos_download().map_err(Into::into) +} + /// Windows-only: detect installed Codex, read mirror manifest/checksums, probe /// sideload capabilities, and return the preferred update path. Read-only. #[tauri::command] @@ -910,6 +921,16 @@ pub fn win_cancel_download(state: State<'_, ManagerState>) -> Result) -> Result<(), CommandError> { + if !matches!(state.target.os, OperatingSystem::Windows) { + return Err(AppError::UnsupportedPlatform.into()); + } + discard_windows_download().map_err(Into::into) +} + /// Whether "launch at login" is currently enabled (off by default). #[tauri::command] pub fn get_autostart(app: tauri::AppHandle) -> Result { @@ -1216,6 +1237,15 @@ pub async fn win_perform_update( saved.save()?; } } + if report.success { + // The staged MSIX was consumed by the install — drop the cache. A failed + // or cancelled perform leaves it so the next run (or a resume) reuses the + // partial/full artifact instead of re-downloading. `stage`/`auto_stage` + // never clear it: they're pre-downloads whose whole point is to be reused. + // Best-effort: the stale sweep reclaims a leftover, so a cleanup failure + // must not turn a successful install into an error. + let _ = crate::app::staging::clear_download_cache(); + } Ok(report) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 34922f7..43c8e37 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -118,6 +118,7 @@ pub fn run() { commands::mac_install, commands::mac_pause_download, commands::mac_cancel_download, + commands::mac_discard_download, commands::mac_launch_codex, commands::mac_uninstall, commands::get_settings, @@ -141,6 +142,7 @@ pub fn run() { commands::win_auto_stage_update, commands::win_pause_download, commands::win_cancel_download, + commands::win_discard_download, commands::win_status, commands::win_adopt, commands::win_launch_codex, diff --git a/src/app/i18n.tsx b/src/app/i18n.tsx index 66af448..172ba25 100644 --- a/src/app/i18n.tsx +++ b/src/app/i18n.tsx @@ -86,6 +86,10 @@ const ZH = { "progress.paused": "下载已暂停,再次更新会继续下载。", "progress.cancelled": "下载已取消。", "progress.cannotCancel": "当前阶段不能取消。", + "progress.resume": "继续", + "progress.paused.title": "下载已暂停", + "progress.paused.hint": "点「继续」从上次的位置接着下载", + "progress.finishing": "下载完成,正在安装,请勿关闭", "install.done.title": "已安装 Codex", "install.done.open": "打开 Codex", @@ -274,6 +278,10 @@ const EN: Record = { "progress.paused": "Download paused. Start again to resume.", "progress.cancelled": "Download cancelled.", "progress.cannotCancel": "This phase cannot be cancelled.", + "progress.resume": "Resume", + "progress.paused.title": "Download paused", + "progress.paused.hint": "Resume to pick up where you left off", + "progress.finishing": "Download complete — installing, don't close", "install.done.title": "Codex installed", "install.done.open": "Open Codex", @@ -459,6 +467,10 @@ const FR: Record = { "progress.paused": "Téléchargement mis en pause. Relancez pour reprendre.", "progress.cancelled": "Téléchargement annulé.", "progress.cannotCancel": "Cette étape ne peut pas être annulée.", + "progress.resume": "Reprendre", + "progress.paused.title": "Téléchargement en pause", + "progress.paused.hint": "Reprenez pour continuer là où vous vous êtes arrêté", + "progress.finishing": "Téléchargement terminé — installation, ne pas fermer", "install.done.title": "Codex installé", "install.done.open": "Ouvrir Codex", @@ -645,6 +657,10 @@ const ZH_TW: Record = { "progress.paused": "下載已暫停,再次更新會繼續下載。", "progress.cancelled": "下載已取消。", "progress.cannotCancel": "目前階段不能取消。", + "progress.resume": "繼續", + "progress.paused.title": "下載已暫停", + "progress.paused.hint": "點「繼續」從上次的位置接著下載", + "progress.finishing": "下載完成,正在安裝,請勿關閉", "install.done.title": "Codex 已安裝", "install.done.open": "開啟 Codex", @@ -841,6 +857,10 @@ const DE: Record = { "progress.paused": "Download pausiert. Starte erneut, um fortzufahren.", "progress.cancelled": "Download abgebrochen.", "progress.cannotCancel": "Diese Phase kann nicht abgebrochen werden.", + "progress.resume": "Fortsetzen", + "progress.paused.title": "Download pausiert", + "progress.paused.hint": "Fortsetzen, um dort weiterzumachen, wo du aufgehört hast", + "progress.finishing": "Download abgeschlossen – wird installiert, nicht schließen", "install.done.title": "Codex installiert", "install.done.open": "Codex öffnen", @@ -1037,6 +1057,10 @@ const KO: Record = { "progress.paused": "다운로드가 일시 중지되었습니다. 다시 시작하면 이어받습니다.", "progress.cancelled": "다운로드가 취소되었습니다.", "progress.cannotCancel": "이 단계는 취소할 수 없습니다.", + "progress.resume": "계속", + "progress.paused.title": "다운로드 일시 중지됨", + "progress.paused.hint": "이어서 받으려면 계속을 누르세요", + "progress.finishing": "다운로드 완료 — 설치 중, 닫지 마세요", "install.done.title": "Codex 설치 완료", "install.done.open": "Codex 열기", @@ -1225,6 +1249,10 @@ const JA: Record = { "progress.paused": "ダウンロードを一時停止しました。再開すると続きから始まります。", "progress.cancelled": "ダウンロードをキャンセルしました。", "progress.cannotCancel": "この段階はキャンセルできません。", + "progress.resume": "再開", + "progress.paused.title": "ダウンロードを一時停止しました", + "progress.paused.hint": "「再開」で続きからダウンロードします", + "progress.finishing": "ダウンロード完了 — インストール中です。閉じないでください", "install.done.title": "Codex をインストールしました", "install.done.open": "Codex を開く", "success.title": "アップデート完了", @@ -1401,6 +1429,10 @@ const RU: Record = { "progress.paused": "Загрузка приостановлена. Запустите снова, чтобы продолжить.", "progress.cancelled": "Загрузка отменена.", "progress.cannotCancel": "Этот этап нельзя отменить.", + "progress.resume": "Продолжить", + "progress.paused.title": "Загрузка приостановлена", + "progress.paused.hint": "Нажмите «Продолжить», чтобы возобновить загрузку", + "progress.finishing": "Загрузка завершена — установка, не закрывайте", "install.done.title": "Codex установлен", "install.done.open": "Открыть Codex", "success.title": "Обновлено", @@ -1577,6 +1609,10 @@ const AR: Record = { "progress.paused": "تم إيقاف التنزيل مؤقتًا. ابدأ مرة أخرى للمتابعة.", "progress.cancelled": "تم إلغاء التنزيل.", "progress.cannotCancel": "لا يمكن إلغاء هذه المرحلة.", + "progress.resume": "استئناف", + "progress.paused.title": "تم إيقاف التنزيل مؤقتًا", + "progress.paused.hint": "اضغط استئناف لمتابعة التنزيل من حيث توقفت", + "progress.finishing": "اكتمل التنزيل — جارٍ التثبيت، لا تغلق النافذة", "install.done.title": "تم تثبيت Codex", "install.done.open": "فتح Codex", "success.title": "تم التحديث", @@ -1753,6 +1789,10 @@ const ES: Record = { "progress.paused": "Descarga pausada. Inicia de nuevo para reanudar.", "progress.cancelled": "Descarga cancelada.", "progress.cannotCancel": "Esta fase no se puede cancelar.", + "progress.resume": "Reanudar", + "progress.paused.title": "Descarga en pausa", + "progress.paused.hint": "Reanuda para continuar donde lo dejaste", + "progress.finishing": "Descarga completa — instalando, no cierres", "install.done.title": "Codex instalado", "install.done.open": "Abrir Codex", "success.title": "Actualizado", @@ -1929,6 +1969,10 @@ const PT_BR: Record = { "progress.paused": "Download pausado. Inicie de novo para continuar.", "progress.cancelled": "Download cancelado.", "progress.cannotCancel": "Esta fase não pode ser cancelada.", + "progress.resume": "Retomar", + "progress.paused.title": "Download pausado", + "progress.paused.hint": "Retome para continuar de onde parou", + "progress.finishing": "Download concluído — instalando, não feche", "install.done.title": "Codex instalado", "install.done.open": "Abrir Codex", "success.title": "Atualizado", diff --git a/src/app/icons.tsx b/src/app/icons.tsx index c7eca8d..cea5c88 100644 --- a/src/app/icons.tsx +++ b/src/app/icons.tsx @@ -11,6 +11,7 @@ export type IconName = | "alert" | "arrowUp" | "download" + | "pause" | "loader" | "refresh" | "gear" @@ -50,6 +51,12 @@ const PATHS: Record = { ), + pause: ( + <> + + + + ), loader: , refresh: ( <> diff --git a/src/app/views/Home.tsx b/src/app/views/Home.tsx index 97ab251..bf01868 100644 --- a/src/app/views/Home.tsx +++ b/src/app/views/Home.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import { Pause, XCircle } from "lucide-react"; +import { Pause, Play, XCircle } from "lucide-react"; import { errorCode, @@ -69,6 +69,15 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { const [downloadStop, setDownloadStop] = useState(null); const [downloadStopBusy, setDownloadStopBusy] = useState(false); const downloadStopRef = useRef(null); + // Latest live progress, read at pause time to snapshot the paused figures + // (the `dl` state is cleared when the perform call unwinds). + const dlRef = useRef(null); + // A paused download: the progress screen stays up (not routed home) offering + // 〔继续〕/〔取消〕. `dl` is the byte snapshot captured at the moment of pause. + const [paused, setPaused] = useState<{ + kind: "perform" | "install"; + dl: DownloadProgress | null; + } | null>(null); const scopeRef = useRef(null); const confirmTitleId = useId(); const confirmBodyId = useId(); @@ -81,6 +90,7 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { const onDlProgress = useCallback((e: { payload: DownloadProgress }) => { const p = e.payload; setDl(p); + dlRef.current = p; const now = Date.now(); const prev = dlSample.current; if (!prev) { @@ -93,6 +103,7 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { const startDlListen = useCallback(async () => { setDl(null); + dlRef.current = null; setSpeed(0); dlSample.current = null; try { @@ -282,6 +293,7 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { const runInstall = useCallback(async () => { setBusy("install"); setActionError(null); + setPaused(null); const un = await startDlListen(); try { setStatus(await managerApi.macInstall()); @@ -289,8 +301,12 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { await check(); } catch (cause) { const stop = downloadStopRef.current; - if (stop && isDownloadCancelled(cause)) { - setNotice(t(stop === "pause" ? "progress.paused" : "progress.cancelled")); + if (stop === "pause" && isDownloadCancelled(cause)) { + // Keep the progress screen up in a paused state instead of routing home; + // the cached `.part` survives, so 〔继续〕 resumes from here. + setPaused({ kind: "install", dl: dlRef.current }); + } else if (stop && isDownloadCancelled(cause)) { + setNotice(t("progress.cancelled")); } else { setActionError(errorMessage(cause)); } @@ -315,6 +331,7 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { if (!installed || !plan || plan.upToDate) return; setBusy("perform"); setActionError(null); + setPaused(null); // Capture the human-facing versions BEFORE the swap — afterward a re-check // makes installed/latest identical. Fall back to a build number only if a // feed omits the short version. @@ -334,8 +351,12 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { } catch (cause) { setConfirmOpen(false); const stop = downloadStopRef.current; - if (stop && isDownloadCancelled(cause)) { - setNotice(t(stop === "pause" ? "progress.paused" : "progress.cancelled")); + if (stop === "pause" && isDownloadCancelled(cause)) { + // Stay on the progress screen as paused; the cached `.part` lets 〔继续〕 + // resume from here instead of restarting at 0. + setPaused({ kind: "perform", dl: dlRef.current }); + } else if (stop && isDownloadCancelled(cause)) { + setNotice(t("progress.cancelled")); } else if (errorCode(cause) === "stale_expectation") { // Reality moved between confirm and execute (the backend's TOCTOU // guard). Refresh the snapshot and post a NOTICE (not an error) so the @@ -358,6 +379,32 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { } }, [report, check, startDlListen, t]); + // 〔继续〕from the paused state — re-run the same operation. The backend finds + // the cached `.part` and resumes via `curl -C -`, so the bar picks up where it + // stopped instead of at 0. + const resumeDownload = useCallback(() => { + const kind = paused?.kind; + setPaused(null); + if (kind === "install") void runInstall(); + else void runPerform(); + }, [paused, runInstall, runPerform]); + + // 〔取消〕from the paused state — the download already stopped, so drop the + // cached partial and route home. (An in-flight cancel is handled by + // requestDownloadStop instead.) + const cancelPausedDownload = useCallback(async () => { + setPaused(null); + try { + // Only claim "已取消" once the cached partial is actually gone — otherwise + // a failed discard would leave a `.part` that the next update silently + // resumes, contradicting the cancel. + await managerApi.macDiscardDownload(); + setNotice(t("progress.cancelled")); + } catch (cause) { + setActionError(errorMessage(cause)); + } + }, [t]); + const plan = report?.plan ?? null; // The report is one atomic backend snapshot (installed detected together // with the plan) — when it exists, it is the truth the card paints and the @@ -420,13 +467,17 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { // the headline and re-splits it — otherwise SplitText's aria-label would keep // the old language's text for screen readers. const progressing = busy === "perform" || busy === "install"; + // The paused screen is calm (no shimmer): the headline is a settled "已暂停", + // not an in-flight state. const isShimmer = progressing || rechecking || kind === "loading"; const scene = `${lang}/${ - progressing - ? `progress-${busy}` - : justInstalled - ? "done" - : `${kind}${rechecking ? "-checking" : ""}` + paused + ? `paused-${paused.kind}` + : progressing + ? `progress-${busy}` + : justInstalled + ? "done" + : `${kind}${rechecking ? "-checking" : ""}` }`; const success = justInstalled || (!rechecking && kind === "uptodate"); // Outcome-strip detail + persistence. Surface a relaunch-failure prompt and @@ -448,23 +499,46 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { const splitHeadline = !isShimmer && dirOf(lang) === "ltr"; useHomeMotion(scopeRef, scene, { splitHeadline, success }); - // ── progress (performing / installing) takes over the whole screen ───────── - if (busy === "perform" || busy === "install") { - const known = Boolean(dl && dl.total > 0); - const pct = known ? Math.round(dlPct) : null; - const canStopDownload = - Boolean(dl && dl.total > 0 && dl.downloaded < dl.total) && !downloadStopBusy; + // ── progress (performing / installing / paused) takes over the whole screen ─ + if (busy === "perform" || busy === "install" || paused) { + const installing = paused ? paused.kind === "install" : busy === "install"; + // Paused reads from its captured snapshot; live runs from the eased `dl`. + const snap = paused ? paused.dl : dl; + const known = Boolean(snap && snap.total > 0); + const snapPct = snap && snap.total > 0 ? Math.min(100, (snap.downloaded / snap.total) * 100) : 0; + const pct = known ? Math.round(paused ? snapPct : dlPct) : null; + const barPct = paused ? snapPct : dlPct; + // Bytes are in → the uninterruptible install phase (gate/quit/atomic swap). + // Say so and drop the dead buttons rather than leave them greyed for no + // visible reason. + const finishing = !paused && Boolean(snap && snap.total > 0 && snap.downloaded >= snap.total); + // Pause only makes sense mid-transfer; cancel is the "abandon" out and works + // through the preparing phase too (a backend abort checkpoint honors it), but + // not once the install has begun. + const canPause = + !paused && Boolean(dl && dl.total > 0 && dl.downloaded < dl.total) && !downloadStopBusy; + const canCancel = !paused && !finishing && !downloadStopBusy; return (
- -
- {busy === "install" ? t("progress.installing") : t("progress.title")} + +
+ {paused + ? t("progress.paused.title") + : installing + ? t("progress.installing") + : t("progress.title")}
- {dl ? t("progress.downloadingFrom", { source: dl.source }) : t("progress.preparing")} + {paused + ? t("progress.paused.hint") + : finishing + ? t("progress.finishing") + : snap + ? t("progress.downloadingFrom", { source: snap.source }) + : t("progress.preparing")}
{pct !== null ? (
@@ -475,28 +549,38 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) {
- {known && dl ? ( + {known && snap ? (
- {mib(dlBytes)} / {mib(dl.total)} - {dlSpeed > 0 ? ` · ${mib(dlSpeed)}/s` : ""} + {mib(paused ? snap.downloaded : dlBytes)} / {mib(snap.total)} + {!paused && dlSpeed > 0 ? ` · ${mib(dlSpeed)}/s` : ""}
) : null}
+ {paused ? ( + + ) : ( + + )} - + ) : ( + + )} -