From 6b8c77a578ec48c0a917f1f69876f252b53ca0f7 Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:30:33 +0800 Subject: [PATCH 1/2] chore: reserve implementation for #166 From 769318467e7a211314996fcabab73a3e000913fd Mon Sep 17 00:00:00 2001 From: wangnov <48670012+Wangnov@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:06:53 +0800 Subject: [PATCH 2/2] fix(i18n): localize safety-critical recovery copy --- crates/codex-win-engine/src/portable.rs | 221 ++++++++- src-tauri/src/app/install_tx.rs | 23 +- src-tauri/src/app/mac_update.rs | 38 +- src-tauri/src/app/operation_outcome.rs | 24 + src-tauri/src/app/oplock.rs | 295 +++++++++++- src-tauri/src/app/win_update.rs | 214 ++++++++- src-tauri/src/commands.rs | 60 ++- src-tauri/src/lib.rs | 1 + src/app/i18n.test.tsx | 117 ++++- src/app/i18n.tsx | 585 +++++++++++++----------- src/app/views/Home.tsx | 6 +- src/app/views/Settings.test.tsx | 73 ++- src/app/views/Settings.tsx | 16 +- src/app/views/Uninstall.test.tsx | 133 +++++- src/app/views/Uninstall.tsx | 64 ++- src/app/views/WinHome.test.tsx | 429 +++++++++++++++++ src/app/views/WinHome.tsx | 525 +++++++++++++++++---- src/services/managerApi.ts | 11 +- src/shared/types.ts | 14 + 19 files changed, 2438 insertions(+), 411 deletions(-) diff --git a/crates/codex-win-engine/src/portable.rs b/crates/codex-win-engine/src/portable.rs index f3be6aa..27c29df 100644 --- a/crates/codex-win-engine/src/portable.rs +++ b/crates/codex-win-engine/src/portable.rs @@ -552,11 +552,20 @@ fn restore_previous_install( backup: &Path, had_previous: bool, ) -> Result<(), EngineError> { + // Never delete the failed/new tree and then discover that the old tree we + // promised to restore is gone. Missing rollback material is ambiguous and + // must not emit positive rollback evidence. + if had_previous && !backup.exists() { + return Err(EngineError::Install(format!( + "portable rollback backup is missing: {}", + backup.display() + ))); + } if install_root.exists() { fs::remove_dir_all(install_root) .map_err(|e| io_err("remove failed portable install", e))?; } - if had_previous && backup.exists() { + if had_previous { fs::rename(backup, install_root) .map_err(|e| io_err("restore portable rollback backup", e))?; } @@ -567,10 +576,27 @@ fn rollback_install_error( install_root: &Path, backup: &Path, had_previous: bool, + observer: &mut PortableObserver<'_>, err: EngineError, ) -> EngineError { match restore_previous_install(install_root, backup, had_previous) { - Ok(()) => EngineError::Install(format!("{err}; previous install was restored")), + Ok(()) => { + let restored = if had_previous { + "previous install was restored" + } else { + "new install was removed and the absent state was restored" + }; + match observer(PortableBoundary::RollbackCompleted { + install_root: install_root.to_path_buf(), + backup: backup.to_path_buf(), + had_previous, + }) { + Ok(()) => EngineError::Install(format!("{err}; {restored}")), + Err(evidence_err) => EngineError::Install(format!( + "{err}; {restored}, but recording rollback evidence failed: {evidence_err}" + )), + } + } Err(rollback_err) => { EngineError::Install(format!("{err}; rollback failed: {rollback_err}")) } @@ -664,6 +690,13 @@ pub enum PortableBoundary { backup: PathBuf, had_previous: bool, }, + /// A later failure was fully rolled back. The install root now matches its + /// pre-operation state (the old tree was restored, or a fresh tree removed). + RollbackCompleted { + install_root: PathBuf, + backup: PathBuf, + had_previous: bool, + }, } pub type PortableObserver<'a> = dyn FnMut(PortableBoundary) -> Result<(), EngineError> + 'a; @@ -673,6 +706,7 @@ pub enum PortableFault { BeforeMoveOld, AfterMoveOld, OnMoveNew, + AfterMoveNew, } thread_local! { @@ -764,15 +798,13 @@ pub fn install_portable_from_msix_with_observer( backup: backup.clone(), had_previous, }) { - if had_previous { - if let Err(rb) = restore_previous_install(install_root, &backup, had_previous) { - log::error!( - "portable observer failed after move-old and rollback also failed: obs={obs_err} rollback={rb}" - ); - // Crash window left for startup recovery. - } - } - return Err(obs_err); + return Err(rollback_install_error( + install_root, + &backup, + had_previous, + observer, + obs_err, + )); } if fault == Some(PortableFault::AfterMoveOld) { // Leave the crash window intact for recovery tests (no auto-rollback). @@ -786,9 +818,14 @@ pub fn install_portable_from_msix_with_observer( had_previous, })?; if fault == Some(PortableFault::OnMoveNew) { - let _ = restore_previous_install(install_root, &backup, had_previous); let _ = fs::remove_dir_all(&work_dir); - return Err(portable_fault_err("on-move-new")); + return Err(rollback_install_error( + install_root, + &backup, + had_previous, + observer, + portable_fault_err("on-move-new"), + )); } match fs::rename(&payload, install_root) { @@ -804,11 +841,27 @@ pub fn install_portable_from_msix_with_observer( } } Err(err) => { - let _ = restore_previous_install(install_root, &backup, had_previous); - return Err(io_err("install portable payload (rolled back)", err)); + return Err(rollback_install_error( + install_root, + &backup, + had_previous, + observer, + io_err("install portable payload", err), + )); } } + if fault == Some(PortableFault::AfterMoveNew) { + let _ = fs::remove_dir_all(&work_dir); + return Err(rollback_install_error( + install_root, + &backup, + had_previous, + observer, + portable_fault_err("after-move-new"), + )); + } + let relaunched = match health_check_portable_install(install_root, manage_process && relaunch) { Ok(relaunched) => relaunched, Err(err) => { @@ -817,6 +870,7 @@ pub fn install_portable_from_msix_with_observer( install_root, &backup, had_previous, + observer, err, )); } @@ -1381,6 +1435,7 @@ mod tests { PortableBoundary::AfterMoveOld { .. } => "after-old", PortableBoundary::BeforeMoveNew { .. } => "before-new", PortableBoundary::AfterMoveNew { .. } => "after-new", + PortableBoundary::RollbackCompleted { .. } => "rollback-completed", }); Ok(()) }, @@ -1392,4 +1447,140 @@ mod tests { ); let _ = fs::remove_dir_all(&root); } + + #[test] + fn successful_fresh_install_rollback_emits_absent_state_evidence() { + let root = std::env::temp_dir().join(format!( + "codex-portable-fresh-rollback-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + let msix = root.join("codex.msix"); + let install_root = root.join("Codex"); + write_fake_msix(&msix); + let mut kinds = Vec::new(); + + inject_portable_fault(Some(PortableFault::AfterMoveNew)); + let err = install_portable_from_msix_with_observer( + &msix, + &install_root, + false, + false, + &mut |boundary| { + kinds.push(match boundary { + PortableBoundary::BeforeMoveOld { .. } => "before-old", + PortableBoundary::AfterMoveOld { .. } => "after-old", + PortableBoundary::BeforeMoveNew { .. } => "before-new", + PortableBoundary::AfterMoveNew { .. } => "after-new", + PortableBoundary::RollbackCompleted { .. } => "rollback-completed", + }); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(err.to_string().contains("absent state was restored")); + assert_eq!( + kinds, + [ + "before-old", + "after-old", + "before-new", + "after-new", + "rollback-completed" + ] + ); + assert!( + !install_root.exists(), + "a fully rolled-back fresh install must be absent and safe to retry" + ); + assert!( + !fs::read_dir(&root).unwrap().any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("Codex.rollback-")), + "a fresh rollback must not leave rollback material" + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn missing_upgrade_backup_never_reports_rollback_completion() { + let root = std::env::temp_dir().join(format!( + "codex-portable-missing-rollback-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + let install_root = root.join("Codex"); + let backup = root.join("Codex.rollback-missing"); + fs::create_dir_all(&install_root).unwrap(); + fs::write(install_root.join("new-marker.txt"), b"new").unwrap(); + let mut boundaries = Vec::new(); + + let err = rollback_install_error( + &install_root, + &backup, + true, + &mut |boundary| { + boundaries.push(boundary); + Ok(()) + }, + portable_fault_err("after-move-new"), + ); + + assert!(err.to_string().contains("rollback backup is missing")); + assert!(boundaries.is_empty(), "failed rollback must emit no evidence"); + assert!( + install_root.join("new-marker.txt").exists(), + "failed rollback must preserve the current tree for recovery" + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn successful_upgrade_rollback_restores_old_tree_before_evidence() { + let root = std::env::temp_dir().join(format!( + "codex-portable-upgrade-rollback-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + let install_root = root.join("Codex"); + let backup = root.join("Codex.rollback-old"); + fs::create_dir_all(&install_root).unwrap(); + fs::write(install_root.join("new-marker.txt"), b"new").unwrap(); + fs::create_dir_all(&backup).unwrap(); + fs::write(backup.join("old-marker.txt"), b"old").unwrap(); + let mut saw_restored_tree = false; + + let err = rollback_install_error( + &install_root, + &backup, + true, + &mut |boundary| { + assert!(matches!( + boundary, + PortableBoundary::RollbackCompleted { + had_previous: true, + .. + } + )); + saw_restored_tree = install_root.join("old-marker.txt").exists() + && !install_root.join("new-marker.txt").exists(); + Ok(()) + }, + portable_fault_err("after-move-new"), + ); + + assert!(err.to_string().contains("previous install was restored")); + assert!(saw_restored_tree, "evidence must follow the completed restore"); + assert!(!backup.exists()); + + let _ = fs::remove_dir_all(&root); + } } diff --git a/src-tauri/src/app/install_tx.rs b/src-tauri/src/app/install_tx.rs index 8a203d8..d069880 100644 --- a/src-tauri/src/app/install_tx.rs +++ b/src-tauri/src/app/install_tx.rs @@ -291,6 +291,20 @@ impl InstallTransaction { } } + fn remove_file_checked(&self) -> Result<(), AppError> { + let path = tx_path_for(&self.id).ok_or_else(|| { + AppError::Internal("无法定位 install-transactions 数据目录".to_string()) + })?; + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(AppError::Internal(format!( + "删除事务日志失败({}): {err}", + path.display() + ))), + } + } + pub fn load_from_path(path: &Path) -> Result { let bytes = fs::read(path) .map_err(|e| AppError::Internal(format!("读取事务日志失败: {e}")))?; @@ -590,10 +604,11 @@ impl ActiveInstallTx { /// Record a successful rollback and clear the log. pub fn mark_rolled_back(mut self) -> Result<(), AppError> { if let Some(mut tx) = self.inner.take() { - tx.step = InstallTxStep::RolledBack; - tx.updated_unix = now_unix(); - let _ = tx.persist(); - tx.remove_file(); + // Persist the terminal state before clearing the journal. If this + // write fails, propagate it so the operation remains outcome-unknown + // instead of claiming a fully recorded rollback. + tx.advance(InstallTxStep::RolledBack)?; + tx.remove_file_checked()?; } Ok(()) } diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index cc45c1c..ae19d77 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -1663,6 +1663,20 @@ pub fn retry_macos_ancillary( path: Option<&str>, purge_user_data: bool, ) -> Result { + retry_macos_ancillary_with_detector(actions, path, purge_user_data, || { + detect_managed_installed().or_else(detect_installed) + }) +} + +fn retry_macos_ancillary_with_detector( + actions: &[String], + path: Option<&str>, + purge_user_data: bool, + detect_install: F, +) -> Result +where + F: FnOnce() -> Option, +{ use crate::app::operation_outcome::AncillaryRetryReport; let mut outcome = OperationOutcome { @@ -1680,7 +1694,7 @@ pub fn retry_macos_ancillary( if actions.iter().any(|a| a == recovery::RECORD_PROVENANCE) { // Re-record currently detected install as managed — same as adopt, but // as an explicit recovery path for "installed, record failed". - match detect_managed_installed().or_else(detect_installed) { + match detect_install() { Some(installed) => { let mut store = ProvenanceStore::load(); store.record( @@ -1708,6 +1722,10 @@ pub fn retry_macos_ancillary( messages.push("未检测到 Codex,无法写入托管记录".to_string()); } } + // Every failed record attempt remains retryable, including the + // no-install-detected branch above. Keep this invariant outside the + // individual match arms so future failure paths cannot omit the CTA. + outcome.retain_failed_provenance_recovery(recovery::RECORD_PROVENANCE); } if actions.iter().any(|a| a == recovery::CLEAR_PROVENANCE) { @@ -1804,6 +1822,24 @@ mod disk_preflight_tests { 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()); } + + #[test] + fn retry_record_provenance_without_detected_install_keeps_retry_action() { + let report = retry_macos_ancillary_with_detector( + &[recovery::RECORD_PROVENANCE.to_string()], + None, + false, + || None, + ) + .expect("a missing install is a retryable ancillary result"); + + assert!(report.outcome.provenance.is_failed()); + assert!(report + .outcome + .recovery_actions + .iter() + .any(|action| action == recovery::RECORD_PROVENANCE)); + } } // The full-update unpack branch is new logic (the delta/gate/swap tail reuses diff --git a/src-tauri/src/app/operation_outcome.rs b/src-tauri/src/app/operation_outcome.rs index e0e9648..923f9c5 100644 --- a/src-tauri/src/app/operation_outcome.rs +++ b/src-tauri/src/app/operation_outcome.rs @@ -126,6 +126,15 @@ impl OperationOutcome { } } + /// Preserve the retry action for every failed provenance branch. Keeping + /// this check after a multi-branch attempt prevents a newly-added error + /// path (including "nothing detected") from stranding the UI without a CTA. + pub fn retain_failed_provenance_recovery(&mut self, action: &str) { + if self.provenance.is_failed() { + self.push_recovery(action); + } + } + /// True when the primary op succeeded but at least one ancillary step failed. pub fn is_partial(&self) -> bool { self.primary_ok && (self.provenance.is_failed() || self.cleanup.is_failed()) @@ -182,4 +191,19 @@ mod tests { outcome.push_recovery(recovery::CLEANUP_METADATA); assert_eq!(outcome.recovery_actions.len(), 1); } + + #[test] + fn failed_provenance_always_retains_its_retry_action() { + let mut failed = OperationOutcome::full_success("present", Some("external")); + failed.provenance = StepOutcome::failed("record target missing"); + failed.retain_failed_provenance_recovery(recovery::RECORD_PROVENANCE); + assert_eq!( + failed.recovery_actions, + vec![recovery::RECORD_PROVENANCE.to_string()] + ); + + let mut succeeded = OperationOutcome::full_success("present", Some("managed")); + succeeded.retain_failed_provenance_recovery(recovery::RECORD_PROVENANCE); + assert!(succeeded.recovery_actions.is_empty()); + } } diff --git a/src-tauri/src/app/oplock.rs b/src-tauri/src/app/oplock.rs index 09a4856..5dde09c 100644 --- a/src-tauri/src/app/oplock.rs +++ b/src-tauri/src/app/oplock.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::fs::{File, OpenOptions}; use std::io::{self, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; @@ -11,6 +12,7 @@ use crate::app::op_phase::{OperationPhase, QuitPolicy}; static TOKEN_COUNTER: AtomicU64 = AtomicU64::new(1); const DEFAULT_STALE_AFTER_SECS: u64 = 5 * 60; +const COMPLETION_HISTORY_LIMIT: usize = 16; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] @@ -57,6 +59,7 @@ pub struct OperationManager { struct Inner { active: Option, + completed: VecDeque, lock_file: Result, } @@ -87,6 +90,28 @@ pub struct OperationSnapshot { pub interruptible: bool, } +/// Terminal backend evidence retained across renderer reloads. The frontend +/// may release a fresh-install safety guard only for `FailedBeforeCommit` or a +/// positively proven `RolledBack`; once an unresolved mutation is observed (or +/// an ambiguous platform call starts), an invoke rejection cannot prove disk state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OperationCompletionState { + Succeeded, + FailedBeforeCommit, + RolledBack, + OutcomeUnknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationCompletion { + pub id: String, + pub kind: OperationKind, + pub phase: OperationPhase, + pub state: OperationCompletionState, +} + struct ActiveOp { token: String, kind: OperationKind, @@ -101,6 +126,17 @@ struct ActiveOp { holders: u32, /// Progress through the op lifecycle; drives quit policy. phase: OperationPhase, + /// True once the operation has definitely changed installed app state. + /// Completion classification must use this evidence, not the quit phase: + /// `Committing` may be published before the first write so closing the app + /// is blocked across the final pre-write crash window. + mutation_started: bool, + /// True only after a mutation was observed and the engine subsequently + /// proved that the install root was restored to its pre-operation state. + mutation_rolled_back: bool, + /// True once a platform call has begun whose failure cannot prove that the + /// installed app is unchanged (for example Add-AppxPackage). + outcome_ambiguous: bool, /// Last reported download progress (if any). progress: Option, /// True after a pause was requested while the lease is still held. @@ -155,6 +191,7 @@ impl OperationManager { Self { inner: Arc::new(Mutex::new(Inner { active: None, + completed: VecDeque::new(), lock_file, })), stale_after_secs, @@ -313,6 +350,87 @@ impl OperationManager { Ok(()) } + /// Record positive evidence that installed app state has changed. + pub fn mark_mutation_started(&self, token: &OperationToken) -> Result<(), OperationError> { + let mut inner = self + .inner + .lock() + .map_err(|_| OperationError::Lock("operation mutex poisoned".to_string()))?; + let Some(active) = inner.active.as_mut() else { + return Err(OperationError::InvalidToken); + }; + if active.token != token.0 { + return Err(OperationError::InvalidToken); + } + if !active.mutation_started { + log::info!( + "operation mutation started kind={} token_prefix={}", + active.kind.as_str(), + token_prefix(&token.0) + ); + active.mutation_started = true; + } + // Any later mutation invalidates rollback evidence from an earlier + // attempt in the same operation. + active.mutation_rolled_back = false; + Ok(()) + } + + /// Record positive evidence that every observed portable install-root + /// mutation was restored. This does not clear `outcome_ambiguous`: a prior + /// platform call such as Add-AppxPackage may still have changed system state. + pub fn mark_mutation_rolled_back( + &self, + token: &OperationToken, + ) -> Result<(), OperationError> { + let mut inner = self + .inner + .lock() + .map_err(|_| OperationError::Lock("operation mutex poisoned".to_string()))?; + let Some(active) = inner.active.as_mut() else { + return Err(OperationError::InvalidToken); + }; + if active.token != token.0 { + return Err(OperationError::InvalidToken); + } + // Some rollback callbacks also clear a Prepared journal before the + // first rename. They are safe failures, but not "rolled back" unless + // this operation actually recorded a mutation first. + if active.mutation_started { + log::info!( + "operation mutation rolled back kind={} token_prefix={}", + active.kind.as_str(), + token_prefix(&token.0) + ); + active.mutation_rolled_back = true; + } + Ok(()) + } + + /// Record that a platform mutation call has started and its eventual error + /// cannot establish the pre-call disk state. + pub fn mark_outcome_ambiguous(&self, token: &OperationToken) -> Result<(), OperationError> { + let mut inner = self + .inner + .lock() + .map_err(|_| OperationError::Lock("operation mutex poisoned".to_string()))?; + let Some(active) = inner.active.as_mut() else { + return Err(OperationError::InvalidToken); + }; + if active.token != token.0 { + return Err(OperationError::InvalidToken); + } + if !active.outcome_ambiguous { + log::info!( + "operation outcome became ambiguous kind={} token_prefix={}", + active.kind.as_str(), + token_prefix(&token.0) + ); + active.outcome_ambiguous = true; + } + Ok(()) + } + /// Unified quit/close policy for window close, menu quit, and quit command. /// /// Reads busy/phase/kind under a **single** mutex hold so a concurrent @@ -382,6 +500,59 @@ impl OperationManager { }) } + /// Record the terminal outcome before the lease is released. Keeping a + /// small token-keyed history lets a replacement renderer distinguish a + /// true no-mutation failure from an ambiguous platform/mutation error. + pub fn record_completion( + &self, + token: &OperationToken, + succeeded: bool, + ) -> Result { + let mut inner = self + .inner + .lock() + .map_err(|_| OperationError::Lock("operation mutex poisoned".to_string()))?; + let Some(active) = inner.active.as_ref() else { + return Err(OperationError::InvalidToken); + }; + if active.token != token.0 { + return Err(OperationError::InvalidToken); + } + let completion = OperationCompletion { + id: active.token.clone(), + kind: active.kind, + phase: active.phase, + state: if succeeded { + OperationCompletionState::Succeeded + } else if active.outcome_ambiguous { + OperationCompletionState::OutcomeUnknown + } else if active.mutation_started && active.mutation_rolled_back { + OperationCompletionState::RolledBack + } else if active.mutation_started { + OperationCompletionState::OutcomeUnknown + } else { + OperationCompletionState::FailedBeforeCommit + }, + }; + inner.completed.push_back(completion.clone()); + while inner.completed.len() > COMPLETION_HISTORY_LIMIT { + inner.completed.pop_front(); + } + Ok(completion) + } + + pub fn completion(&self, token: &OperationToken) -> Option { + let Ok(inner) = self.inner.lock() else { + return None; + }; + inner + .completed + .iter() + .rev() + .find(|completion| completion.id == token.0) + .cloned() + } + /// Record the latest download progress for a validated token. pub fn set_progress( &self, @@ -470,6 +641,9 @@ impl OperationManager { claimed, holders: 0, phase: OperationPhase::Preparing, + mutation_started: false, + mutation_rolled_back: false, + outcome_ambiguous: false, progress: None, paused: false, }); @@ -580,7 +754,9 @@ fn write_lock_diagnostics( #[cfg(test)] mod tests { - use super::{OperationError, OperationKind, OperationManager, OperationToken}; + use super::{ + OperationCompletionState, OperationError, OperationKind, OperationManager, OperationToken, + }; use std::fs; use std::sync::atomic::{AtomicU64, Ordering}; @@ -897,4 +1073,121 @@ mod tests { let _ = fs::remove_dir_all(path.parent().unwrap()); } + + #[test] + fn terminal_completion_uses_mutation_evidence_independently_of_quit_phase() { + use crate::app::op_phase::OperationPhase; + + let path = lock_path("completion"); + let manager = OperationManager::new(path.clone()); + + let before_commit = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&before_commit).unwrap(); + manager + .set_phase(&before_commit, OperationPhase::Downloading) + .unwrap(); + let completion = manager.record_completion(&before_commit, false).unwrap(); + assert_eq!( + completion.state, + OperationCompletionState::FailedBeforeCommit + ); + manager.end(before_commit.clone()).unwrap(); + assert_eq!(manager.completion(&before_commit), Some(completion)); + + let committing_without_write = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&committing_without_write).unwrap(); + manager + .set_phase(&committing_without_write, OperationPhase::Committing) + .unwrap(); + let completion = manager + .record_completion(&committing_without_write, false) + .unwrap(); + assert_eq!( + completion.state, + OperationCompletionState::FailedBeforeCommit + ); + manager.end(committing_without_write).unwrap(); + + let after_mutation = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&after_mutation).unwrap(); + manager.mark_mutation_started(&after_mutation).unwrap(); + let completion = manager.record_completion(&after_mutation, false).unwrap(); + assert_eq!(completion.state, OperationCompletionState::OutcomeUnknown); + manager.end(after_mutation.clone()).unwrap(); + assert_eq!(manager.completion(&after_mutation), Some(completion)); + + let rolled_back = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&rolled_back).unwrap(); + manager.mark_mutation_started(&rolled_back).unwrap(); + assert!(matches!( + manager.mark_mutation_rolled_back(&OperationToken("wrong".to_string())), + Err(OperationError::InvalidToken) + )); + manager.mark_mutation_rolled_back(&rolled_back).unwrap(); + let completion = manager.record_completion(&rolled_back, false).unwrap(); + assert_eq!(completion.state, OperationCompletionState::RolledBack); + manager.end(rolled_back.clone()).unwrap(); + assert_eq!(manager.completion(&rolled_back), Some(completion)); + + let rollback_before_mutation = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&rollback_before_mutation).unwrap(); + manager + .mark_mutation_rolled_back(&rollback_before_mutation) + .unwrap(); + let completion = manager + .record_completion(&rollback_before_mutation, false) + .unwrap(); + assert_eq!( + completion.state, + OperationCompletionState::FailedBeforeCommit + ); + manager.end(rollback_before_mutation).unwrap(); + + let ambiguous_rollback = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&ambiguous_rollback).unwrap(); + manager + .mark_outcome_ambiguous(&ambiguous_rollback) + .unwrap(); + manager.mark_mutation_started(&ambiguous_rollback).unwrap(); + manager + .mark_mutation_rolled_back(&ambiguous_rollback) + .unwrap(); + let completion = manager + .record_completion(&ambiguous_rollback, false) + .unwrap(); + assert_eq!(completion.state, OperationCompletionState::OutcomeUnknown); + manager.end(ambiguous_rollback).unwrap(); + + let mutation_after_rollback = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&mutation_after_rollback).unwrap(); + manager.mark_mutation_started(&mutation_after_rollback).unwrap(); + manager + .mark_mutation_rolled_back(&mutation_after_rollback) + .unwrap(); + manager.mark_mutation_started(&mutation_after_rollback).unwrap(); + let completion = manager + .record_completion(&mutation_after_rollback, false) + .unwrap(); + assert_eq!(completion.state, OperationCompletionState::OutcomeUnknown); + manager.end(mutation_after_rollback).unwrap(); + + let ambiguous_call = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&ambiguous_call).unwrap(); + manager + .mark_outcome_ambiguous(&ambiguous_call) + .unwrap(); + let completion = manager.record_completion(&ambiguous_call, false).unwrap(); + assert_eq!(completion.state, OperationCompletionState::OutcomeUnknown); + manager.end(ambiguous_call.clone()).unwrap(); + assert_eq!(manager.completion(&ambiguous_call), Some(completion)); + + let succeeded = manager.begin_detached(OperationKind::Update).unwrap(); + manager.validate(&succeeded).unwrap(); + let completion = manager.record_completion(&succeeded, true).unwrap(); + assert_eq!(completion.state, OperationCompletionState::Succeeded); + manager.end(succeeded.clone()).unwrap(); + assert_eq!(manager.completion(&succeeded), Some(completion)); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } } diff --git a/src-tauri/src/app/win_update.rs b/src-tauri/src/app/win_update.rs index 9736a07..d90c66e 100644 --- a/src-tauri/src/app/win_update.rs +++ b/src-tauri/src/app/win_update.rs @@ -39,6 +39,19 @@ use crate::errors::AppError; /// Optional hook for the command layer to publish operation phases (quit policy). pub type PhaseHook<'a> = dyn Fn(OperationPhase) + Send + Sync + 'a; +/// Completion evidence is intentionally separate from `OperationPhase`. +/// `Committing` blocks app exit before the first rename/platform call, while +/// these events describe whether a rejected command can still prove that the +/// installed app was left unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationEvidence { + MutationStarted, + MutationRolledBack, + OutcomeAmbiguous, +} + +pub type EvidenceHook<'a> = dyn Fn(OperationEvidence) + Send + Sync + 'a; + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WinUpdateReport { @@ -1010,6 +1023,7 @@ pub fn perform_windows_update_with_install_mode_and_network( progress, network, None, + None, ) } @@ -1023,6 +1037,7 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( progress: &dyn Fn(DownloadProgress), network: &NetworkConfig, phase: Option<&PhaseHook<'_>>, + evidence: Option<&EvidenceHook<'_>>, ) -> Result { let set_phase = |p: OperationPhase| { if let Some(hook) = phase { @@ -1083,16 +1098,25 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( }); } - // 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. + // Honor a cancel one last time before closing Codex or beginning install + // preparation. A fully-cached MSIX can skip the download loop (so its cancel + // flag never arms) yet still reach here. Keep the operation pre-commit until + // the selected route actually mutates the installed application. check_win_update_abort()?; - set_phase(OperationPhase::Committing); + set_phase(OperationPhase::Applying); 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())?; - return install_portable_after_stage(settings, stage, None, None, current_installed, phase); + return install_portable_after_stage( + settings, + stage, + None, + None, + current_installed, + phase, + evidence, + ); } let staged_path = stage @@ -1121,8 +1145,15 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( "Skipped MSIX sideload before attempting it: {}. Routed to the portable build, which carries its own runtime and does not need these framework packages.", precheck.reason )); - let mut report = - install_portable_after_stage(settings, stage, None, None, current_installed, phase)?; + let mut report = install_portable_after_stage( + settings, + stage, + None, + None, + current_installed, + phase, + evidence, + )?; report.action = WinPerformAction::PortableFallbackMissingFramework; return Ok(report); } @@ -1144,6 +1175,13 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( .map_err(engine_err)?; } } + // Add-AppxPackage is the first operation on the MSIX route that can mutate + // installed package state. From here an error cannot prove the old state is + // intact, so renderer recovery must treat the outcome as ambiguous. + set_phase(OperationPhase::Committing); + if let Some(hook) = evidence { + hook(OperationEvidence::OutcomeAmbiguous); + } let sideload = install_msix_sideload(PathBuf::from(&staged_path).as_path()).map_err(engine_err)?; @@ -1181,6 +1219,7 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( Some(health), current_installed, phase, + evidence, )?; match remove_msix_package() { Ok(remove) if remove.success => { @@ -1279,7 +1318,15 @@ pub fn perform_windows_update_with_install_mode_network_and_phase( return Ok(report); } - install_portable_after_stage(settings, stage, Some(sideload), None, current_installed, phase) + install_portable_after_stage( + settings, + stage, + Some(sideload), + None, + current_installed, + phase, + evidence, + ) } fn install_portable_after_stage( @@ -1289,10 +1336,8 @@ fn install_portable_after_stage( health: Option, previous_installed: Option, phase: Option<&PhaseHook<'_>>, + evidence: Option<&EvidenceHook<'_>>, ) -> Result { - if let Some(hook) = phase { - hook(OperationPhase::Committing); - } let staged_path = stage .staged_path .as_ref() @@ -1304,10 +1349,31 @@ fn install_portable_after_stage( .unwrap_or_else(|| settings.install_root.clone()); let install_root_path = PathBuf::from(&install_root); let msix_path = PathBuf::from(staged_path); - // Persist the transaction log on BeforeMoveOld (first destructive rename) - // using the real paths chosen by the engine. + // Persist the transaction log before the first possible rename using the + // real paths chosen by the engine. Once that journal is durable, publish + // `Committing` before the first rename so the app cannot exit in the narrow + // gap between the boundary callback and the filesystem call. Completion + // remains pre-write until a later boundary proves a rename succeeded. let mut tx: Option = None; let mut observer = |boundary: PortableBoundary| -> Result<(), codex_win_engine::EngineError> { + if matches!(&boundary, PortableBoundary::RollbackCompleted { .. }) { + // Disk rollback is already complete. Persist and clear the durable + // journal first; only then publish evidence that permits a retry. + if let Some(active) = tx.take() { + active + .mark_rolled_back() + .map_err(|e| codex_win_engine::EngineError::Io(e.to_string()))?; + } + if let Some(hook) = evidence { + hook(OperationEvidence::MutationRolledBack); + } + return Ok(()); + } + if portable_boundary_mutated_install_root(&boundary) { + if let Some(hook) = evidence { + hook(OperationEvidence::MutationStarted); + } + } match boundary { PortableBoundary::BeforeMoveOld { install_root, @@ -1325,6 +1391,9 @@ fn install_portable_after_stage( ) .map_err(|e| codex_win_engine::EngineError::Io(e.to_string()))?; tx = Some(started); + if let Some(hook) = phase { + hook(OperationPhase::Committing); + } Ok(()) } PortableBoundary::AfterMoveOld { .. } => { @@ -1344,6 +1413,7 @@ fn install_portable_after_stage( } Ok(()) } + PortableBoundary::RollbackCompleted { .. } => unreachable!("handled above"), } }; let portable = install_portable_from_msix_with_observer( @@ -1449,6 +1519,22 @@ fn install_portable_after_stage( Ok(report) } +/// Whether a completed portable-engine boundary proves the install root was +/// mutated. An upgrade crosses this boundary after moving the old tree; a fresh +/// install has no old tree, so it crosses only after moving the new payload in. +fn portable_boundary_mutated_install_root(boundary: &PortableBoundary) -> bool { + matches!( + boundary, + PortableBoundary::AfterMoveOld { + had_previous: true, + .. + } | PortableBoundary::AfterMoveNew { + had_previous: false, + .. + } + ) +} + pub fn win_install_status(settings: &AppSettings) -> WinInstallStatus { let store = ProvenanceStore::load(); let installed = detect_managed_codex(settings, &store); @@ -1711,6 +1797,20 @@ pub fn retry_windows_ancillary( path: Option<&str>, purge_user_data: bool, ) -> Result { + retry_windows_ancillary_with_detector(actions, path, purge_user_data, || { + win_install_status(settings) + }) +} + +fn retry_windows_ancillary_with_detector( + actions: &[String], + path: Option<&str>, + purge_user_data: bool, + detect_status: F, +) -> Result +where + F: FnOnce() -> WinInstallStatus, +{ let mut outcome = OperationOutcome { primary_ok: true, app_state: "unknown".to_string(), @@ -1724,7 +1824,7 @@ pub fn retry_windows_ancillary( let mut messages = Vec::new(); if actions.iter().any(|a| a == recovery::RECORD_PROVENANCE) { - let status = win_install_status(settings); + let status = detect_status(); match status.installed { Some(installed) => { let recorded = @@ -1751,6 +1851,9 @@ pub fn retry_windows_ancillary( messages.push("未检测到 Codex,无法写入托管记录".to_string()); } } + // A failed record attempt is always retryable. In particular, the + // no-install-detected branch must not strand the UI without its CTA. + outcome.retain_failed_provenance_recovery(recovery::RECORD_PROVENANCE); } if actions.iter().any(|a| a == recovery::CLEAR_PROVENANCE) { @@ -1826,13 +1929,14 @@ pub fn retry_windows_ancillary( mod tests { use super::{ bind_manifest_checksums, check_win_update_abort, detect_existing_windows_install_at_path, - detect_managed_codex, outcome_from_portable_uninstall, WinAbortGuard, WinPerformAction, - WIN_UPDATE_ABORT, + detect_managed_codex, outcome_from_portable_uninstall, + portable_boundary_mutated_install_root, retry_windows_ancillary_with_detector, + WinAbortGuard, WinPerformAction, WinInstallStatus, WIN_UPDATE_ABORT, }; use crate::app::operation_outcome::{recovery, StepOutcome}; use crate::app::provenance::ProvenanceStore; use crate::domain::settings::AppSettings; - use codex_win_engine::{PortableUninstallReport, WindowsRelease}; + use codex_win_engine::{PortableBoundary, PortableUninstallReport, WindowsRelease}; use std::path::PathBuf; use std::sync::atomic::Ordering; @@ -1876,6 +1980,60 @@ mod tests { ); } + #[test] + fn portable_mutation_evidence_requires_a_successful_install_root_rename() { + let install_root = PathBuf::from(r"C:\Codex"); + let payload = PathBuf::from(r"C:\staging\Codex"); + let backup = PathBuf::from(r"C:\Codex.rollback"); + + assert!(!portable_boundary_mutated_install_root( + &PortableBoundary::BeforeMoveOld { + install_root: install_root.clone(), + payload: payload.clone(), + backup: backup.clone(), + had_previous: false, + } + )); + assert!(!portable_boundary_mutated_install_root( + &PortableBoundary::AfterMoveOld { + install_root: install_root.clone(), + payload: payload.clone(), + backup: backup.clone(), + had_previous: false, + } + )); + assert!(portable_boundary_mutated_install_root( + &PortableBoundary::AfterMoveOld { + install_root: install_root.clone(), + payload: payload.clone(), + backup: backup.clone(), + had_previous: true, + } + )); + assert!(!portable_boundary_mutated_install_root( + &PortableBoundary::BeforeMoveNew { + install_root: install_root.clone(), + payload: payload.clone(), + backup: backup.clone(), + had_previous: false, + } + )); + assert!(portable_boundary_mutated_install_root( + &PortableBoundary::AfterMoveNew { + install_root: install_root.clone(), + backup: backup.clone(), + had_previous: false, + } + )); + assert!(!portable_boundary_mutated_install_root( + &PortableBoundary::RollbackCompleted { + install_root, + backup, + had_previous: false, + } + )); + } + #[test] fn serializes_win_perform_actions_as_frontend_contract() { let cases = [ @@ -2106,4 +2264,26 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa OpenAI.Codex_2 assert!(!outcome.is_partial()); assert_eq!(outcome.app_state, "present"); } + + #[test] + fn retry_record_provenance_without_detected_install_keeps_retry_action() { + let report = retry_windows_ancillary_with_detector( + &[recovery::RECORD_PROVENANCE.to_string()], + None, + false, + || WinInstallStatus { + installed: None, + status: "none".to_string(), + }, + ) + .expect("a missing install is a retryable ancillary result"); + + assert!(report.outcome.provenance.is_failed()); + assert!(report + .outcome + .recovery_actions + .iter() + .any(|action| action == recovery::RECORD_PROVENANCE)); + + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b7e1d3b..5fa3c43 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -23,8 +23,8 @@ use crate::app::mac_update::{ use crate::app::op_phase::{OperationPhase, QuitPolicy}; use crate::app::operation_outcome::{AncillaryRetryReport, AncillaryRetryRequest}; use crate::app::oplock::{ - OperationError, OperationGuard, OperationKind, OperationManager, OperationProgress, - OperationSnapshot, OperationToken, + OperationCompletion, OperationError, OperationGuard, OperationKind, OperationManager, + OperationProgress, OperationSnapshot, OperationToken, }; use crate::app::paths; use crate::app::provenance::ProvenanceStore; @@ -40,7 +40,7 @@ use crate::app::win_update::{ retry_windows_ancillary, stage_windows_update_with_install_mode_and_network, uninstall_windows_codex, win_adopt as adopt_windows_install, win_adopt_path as adopt_windows_path, win_install_status, - DownloadProgress as WinDownloadProgress, WinAutoStageReport, WinInstallStatus, + DownloadProgress as WinDownloadProgress, OperationEvidence, WinAutoStageReport, WinInstallStatus, WinPerformExpectation, WinPerformReport, WinStageReport, WinUninstallReport, WinUpdateReport, }; use crate::domain::settings::AppSettings as DomainAppSettings; @@ -288,22 +288,42 @@ fn begin_guard(state: &ManagerState, kind: OperationKind) -> Result, } impl DetachedGuard { fn validate(state: &ManagerState, token: OperationToken) -> Result { + Self::validate_inner(state, token, false) + } + + fn validate_tracked(state: &ManagerState, token: OperationToken) -> Result { + Self::validate_inner(state, token, true) + } + + fn validate_inner( + state: &ManagerState, + token: OperationToken, + completion_tracking: bool, + ) -> Result { let operations = state.operations.clone(); operations .validate(&token) .map_err(destructive_token_error)?; Ok(Self { + completion_tracking, operations, + succeeded: false, token: Some(token), }) } + fn mark_succeeded(&mut self) { + self.succeeded = true; + } + fn set_phase(&self, phase: OperationPhase) { if let Some(token) = self.token.as_ref() { let _ = self.operations.set_phase(token, phase); @@ -363,6 +383,11 @@ fn emit_op_download_progress( impl Drop for DetachedGuard { fn drop(&mut self) { if let Some(token) = self.token.take() { + if self.completion_tracking { + if let Err(error) = self.operations.record_completion(&token, self.succeeded) { + log::error!("failed to record terminal operation outcome: {error}"); + } + } let _ = self.operations.end(token); } } @@ -1168,6 +1193,17 @@ pub fn get_operation_snapshot( Ok(state.operations.snapshot()) } +/// Token-keyed terminal evidence for a renderer that lost the original invoke +/// promise. `failed-before-commit` and `rolled-back` prove that retrying a fresh +/// install is safe; unresolved committing/finishing failures remain outcome-unknown. +#[tauri::command] +pub fn get_operation_completion( + state: State<'_, ManagerState>, + token: OperationToken, +) -> Result, CommandError> { + Ok(state.operations.completion(&token)) +} + /// The user confirmed quitting from the close dialog — flag it and exit so the /// CloseRequested / ExitRequested guards stop intercepting and let it go. /// Still refuses when the backend is in a non-interruptible install phase. @@ -1712,7 +1748,7 @@ pub async fn win_perform_update( AppError::Internal("拒绝执行:Windows 更新必须带显式 confirm".to_string()).into(), ); } - let op = DetachedGuard::validate(&state, token)?; + let mut op = DetachedGuard::validate_tracked(&state, token)?; op.set_phase(OperationPhase::Preparing); let endpoints = windows_endpoints_for_settings(&state)?; let mut settings = windows_domain_settings_for_persisted(&state); @@ -1755,6 +1791,20 @@ pub async fn win_perform_update( let _ = ops.set_phase(token, phase); } }; + let evidence_hook = |evidence: OperationEvidence| { + if let Some(token) = phase_token.as_ref() { + let result = match evidence { + OperationEvidence::MutationStarted => ops.mark_mutation_started(token), + OperationEvidence::MutationRolledBack => { + ops.mark_mutation_rolled_back(token) + } + OperationEvidence::OutcomeAmbiguous => ops.mark_outcome_ambiguous(token), + }; + if let Err(error) = result { + log::error!("failed to record Windows operation evidence: {error}"); + } + } + }; perform_windows_update_with_install_mode_network_and_phase( &endpoints, &settings, @@ -1764,6 +1814,7 @@ pub async fn win_perform_update( &report, &network, Some(&phase_hook), + Some(&evidence_hook), ) }) .await @@ -1785,6 +1836,7 @@ pub async fn win_perform_update( // must not turn a successful install into an error. let _ = crate::app::staging::clear_download_cache(); } + op.mark_succeeded(); Ok(report) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aff96e3..0198ff9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -172,6 +172,7 @@ pub fn run() { commands::arm_destructive, commands::end_operation, commands::get_operation_snapshot, + commands::get_operation_completion, commands::confirm_quit, commands::win_default_install_root, commands::win_pick_install_dir, diff --git a/src/app/i18n.test.tsx b/src/app/i18n.test.tsx index d26f9e4..ae7d4e3 100644 --- a/src/app/i18n.test.tsx +++ b/src/app/i18n.test.tsx @@ -2,7 +2,17 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import { CATALOG, dirOf, I18nProvider, LANGS, matchTag, pickLang, useI18n } from "./i18n"; +import { + CATALOG, + dirOf, + I18nProvider, + LANGS, + matchTag, + pickLang, + useI18n, + type Lang, + type TKey, +} from "./i18n"; describe("matchTag", () => { it("maps traditional-Chinese regions and scripts to zh-TW", () => { @@ -84,6 +94,111 @@ describe("catalog placeholders", () => { }); }); +const SAFETY_CRITICAL_PREFIXES = [ + "install.partial.", + "settings.health.", + "uninstall.status.", + "uninstall.partial.", +] as const; + +const SOURCE_LANGS = ["en", "zh-CN"] as const satisfies readonly Lang[]; +const SAFETY_SOURCE_COPY_ALLOWLIST = new Map([ + // Map each exception to the exact source key whose normalized copy may match. + ["zh-TW:settings.health.ok", "zh-CN:settings.health.ok"], // 「正常」 is idiomatic in both Chinese variants. + ["ja:settings.health.ok", "zh-CN:settings.health.ok"], // 「正常」 is also the native Japanese status label. +]); + +function normalizedCopy(value: string): string { + return value.normalize("NFC").replace(/\s+/g, " ").trim(); +} + +const safetyCriticalKeys = (Object.keys(CATALOG.en) as TKey[]).filter((key) => + SAFETY_CRITICAL_PREFIXES.some((prefix) => key.startsWith(prefix)), +); + +describe("safety-critical catalog copy", () => { + it("does not copy an English or Simplified-Chinese source paragraph into another locale", () => { + const translatedLangs = LANGS.map((item) => item.code).filter( + (lang) => !SOURCE_LANGS.includes(lang as (typeof SOURCE_LANGS)[number]), + ); + const sourceOwnersByCopy = new Map(); + for (const source of SOURCE_LANGS) { + for (const key of safetyCriticalKeys) { + const copy = normalizedCopy(CATALOG[source][key]); + sourceOwnersByCopy.set(copy, [ + ...(sourceOwnersByCopy.get(copy) ?? []), + `${source}:${key}`, + ]); + } + } + + const unapprovedCopies: string[] = []; + const usedAllowlistEntries = new Set(); + + for (const lang of translatedLangs) { + for (const key of safetyCriticalKeys) { + const candidate = normalizedCopy(CATALOG[lang][key]); + const copiedFrom = sourceOwnersByCopy.get(candidate); + const allowlistEntry = `${lang}:${key}`; + const approvedSourceOwner = SAFETY_SOURCE_COPY_ALLOWLIST.get(allowlistEntry); + + if (copiedFrom != null) { + if (approvedSourceOwner && copiedFrom.includes(approvedSourceOwner)) { + usedAllowlistEntries.add(allowlistEntry); + } else { + unapprovedCopies.push(`${allowlistEntry} duplicates ${copiedFrom.join(", ")}`); + } + } + } + } + + expect(unapprovedCopies).toEqual([]); + expect( + [...SAFETY_SOURCE_COPY_ALLOWLIST.keys()].filter( + (entry) => !usedAllowlistEntries.has(entry), + ), + ).toEqual([]); + }); + + it("keeps partial-uninstall recovery actions distinct in every language", () => { + const recoveryKeys = [ + "uninstall.partial.retryCleanup", + "uninstall.partial.retryProvenance", + "uninstall.partial.retryPurge", + "uninstall.partial.retryRecord", + ] as const satisfies readonly TKey[]; + + for (const { code } of LANGS) { + const labels = recoveryKeys.map((key) => normalizedCopy(CATALOG[code][key])); + expect(new Set(labels).size, code).toBe(recoveryKeys.length); + expect(CATALOG[code]["settings.health.resetConfirm.body.settings"], code).not.toBe( + CATALOG[code]["settings.health.resetConfirm.body.provenance"], + ); + expect(CATALOG[code]["settings.health.reset"], code).not.toBe( + CATALOG[code]["settings.health.clearProvenance"], + ); + } + }); + + it("keeps recovery instructions bound to their rendered action labels", () => { + for (const { code } of LANGS) { + expect(placeholders(CATALOG[code]["install.partial.note"]), code).toContain("action"); + expect(placeholders(CATALOG[code]["install.partial.pending"]), code).toContain("action"); + expect( + placeholders(CATALOG[code]["settings.health.resetConfirm.body.provenance"]), + code, + ).toContain("action"); + } + }); + + it("ships native safety-copy sentinels for the required flow locales", () => { + expect(CATALOG.fr["install.partial.note"]).toContain("enregistrement de gestion"); + expect(CATALOG.ar["settings.health.resetConfirm.body.provenance"]).toContain("سجلات التثبيت"); + expect(CATALOG.es["uninstall.partial.retryPurge"]).toContain("datos de usuario"); + expect(CATALOG["zh-TW"]["settings.health.restoreConfirm.body"]).toContain("目前損毀"); + }); +}); + function DirProbe() { const { setLang } = useI18n(); return ( diff --git a/src/app/i18n.tsx b/src/app/i18n.tsx index 96355f4..c6a79b3 100644 --- a/src/app/i18n.tsx +++ b/src/app/i18n.tsx @@ -133,7 +133,8 @@ const ZH = { "progress.finishing": "下载完成,正在安装,请勿关闭", "install.done.title": "已安装 Codex", "install.done.open": "打开 Codex", - "install.partial.note": "应用已装上,但托管记录写入失败。请点「开始管理」重试记录,勿重复安装。", + "install.partial.note": "应用已装上,但托管记录写入失败。请点「{action}」,勿重复安装。", + "install.partial.pending": "安装流程已结束,但暂时无法确认 Codex 是否已写入磁盘。请点「{action}」重新检测;确认前请勿重复安装。", "install.partial.record": "重试写入托管记录", "success.title": "已更新", @@ -208,11 +209,12 @@ const ZH = { "settings.health.unknownSource": "未知更新源已归一为自动", "settings.health.restore": "从 .bak 恢复", "settings.health.reset": "重置为默认", + "settings.health.clearProvenance": "清除托管记录", "settings.health.restoreConfirm.title": "从备份恢复 {which}?", "settings.health.restoreConfirm.body": "将用上一次成功保存的 .bak 覆盖当前文件。当前损坏或异常内容会被丢弃;恢复后会重新读取并核验。", "settings.health.resetConfirm.title": "重置 {which}?", "settings.health.resetConfirm.body.settings": "将丢弃当前设置并写回出厂默认(更新源、检查频率、安装方式等)。可再从 .bak 尝试恢复(若仍存在)。", - "settings.health.resetConfirm.body.provenance": "将清空本应用的托管记录。已安装的 Codex 不会被删除,但会被视为「未管理」,需重新「开始管理」后才能更新/卸载。", + "settings.health.resetConfirm.body.provenance": "将清空本应用的托管记录。已安装的 Codex 不会被删除,但会被视为「未管理」,需重新「{action}」后才能更新/卸载。", "settings.health.working": "处理中…", "settings.health.verified": "已重新读取并核验", "settings.health.noBackup": "没有可用的 .bak 备份", @@ -257,6 +259,9 @@ const ZH = { "uninstall.status.error": "无法确认安装状态(查询失败)。不会把它当成外部安装;请重试或回到主界面检查。", "uninstall.status.none": "未检测到由本应用管理的 Codex 安装。", "uninstall.partial.title": "应用已卸载,但部分清理未完成", + "uninstall.partial.summary": "Codex 已卸载,但附属清理尚未完成。请仅重试下方对应步骤,无需再次卸载。", + "uninstall.partial.retryPending": "仍有恢复步骤未完成。请仅重试下方剩余步骤。", + "uninstall.partial.retryDone": "所选恢复步骤已完成。", "uninstall.partial.retryCleanup": "仅重试清理(快捷方式/卸载项)", "uninstall.partial.retryProvenance": "仅重试清除托管记录", "uninstall.partial.retryPurge": "仅重试清除用户数据", @@ -411,7 +416,8 @@ const EN: Record = { "progress.finishing": "Download complete — installing, don't close", "install.done.title": "Codex installed", "install.done.open": "Open Codex", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", + "install.partial.note": "The app is on disk, but the managed record could not be saved. Use “{action}” — do not reinstall.", + "install.partial.pending": "Installation finished, but Codex could not yet be confirmed on disk. Use “{action}” to check again; do not reinstall until it is confirmed.", "install.partial.record": "Retry managed record", "success.title": "Updated", @@ -486,11 +492,12 @@ const EN: Record = { "settings.health.unknownSource": "Unknown update source was normalized to Automatic", "settings.health.restore": "Restore from .bak", "settings.health.reset": "Reset to defaults", + "settings.health.clearProvenance": "Clear managed records", "settings.health.restoreConfirm.title": "Restore {which} from backup?", "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", "settings.health.resetConfirm.title": "Reset {which}?", "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", + "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you use “{action}” again.", "settings.health.working": "Working…", "settings.health.verified": "Re-read and verified", "settings.health.noBackup": "No .bak backup available", @@ -535,6 +542,9 @@ const EN: Record = { "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", "uninstall.status.none": "No manager-managed Codex install was detected.", "uninstall.partial.title": "App removed, but some cleanup did not finish", + "uninstall.partial.summary": "Codex was removed, but ancillary cleanup is incomplete. Retry only the matching step below; do not uninstall again.", + "uninstall.partial.retryPending": "Some recovery steps are still incomplete. Retry only the remaining steps below.", + "uninstall.partial.retryDone": "The selected recovery step completed.", "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", "uninstall.partial.retryProvenance": "Retry clearing managed records only", "uninstall.partial.retryPurge": "Retry purging user data only", @@ -686,8 +696,9 @@ const FR: Record = { "progress.finishing": "Téléchargement terminé — installation, ne pas fermer", "install.done.title": "Codex installé", "install.done.open": "Ouvrir Codex", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "L’application est installée, mais l’enregistrement de gestion n’a pas pu être sauvegardé. Utilisez « {action} » — ne réinstallez pas l’application.", + "install.partial.pending": "L’installation est terminée, mais la présence de Codex sur le disque n’a pas encore pu être confirmée. Utilisez « {action} » pour vérifier à nouveau ; ne réinstallez pas avant confirmation.", + "install.partial.record": "Réessayer l’enregistrement de gestion", "success.title": "Mis à jour", "success.flow": "Mis à jour {from} → {to}", @@ -751,25 +762,26 @@ const FR: Record = { "settings.more.configUnavailable": "Non disponible dans cette version", "settings.saveError": "Impossible d'enregistrer les réglages", "settings.retry": "Réessayer", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "État de la configuration", + "settings.health.ok": "Correct", + "settings.health.recovered": "Restauré depuis la sauvegarde", + "settings.health.corrupt": "Endommagé", + "settings.health.settings": "Fichier de paramètres", + "settings.health.provenance": "Enregistrements d’installation gérée", + "settings.health.detail": "Détails", + "settings.health.unknownSource": "La source de mise à jour inconnue a été remplacée par Automatique", + "settings.health.restore": "Restaurer depuis .bak", + "settings.health.reset": "Rétablir les valeurs par défaut", + "settings.health.clearProvenance": "Effacer les enregistrements de gestion", + "settings.health.restoreConfirm.title": "Restaurer {which} depuis la sauvegarde ?", + "settings.health.restoreConfirm.body": "Remplace le fichier actuel par le dernier .bak enregistré avec succès. Le contenu endommagé ou inattendu actuel sera supprimé ; l’état sera relu et vérifié ensuite.", + "settings.health.resetConfirm.title": "Réinitialiser {which} ?", + "settings.health.resetConfirm.body.settings": "Supprime les paramètres actuels et rétablit les valeurs d’usine (source de mise à jour, fréquence des vérifications, mode d’installation, etc.). Vous pourrez encore tenter une restauration .bak si une sauvegarde subsiste.", + "settings.health.resetConfirm.body.provenance": "Efface les enregistrements d’installation gérée de cette application. Codex n’est pas supprimé, mais apparaîtra comme non géré jusqu’à ce que vous utilisiez à nouveau « {action} » ; jusque-là, sa mise à jour et sa désinstallation seront indisponibles.", + "settings.health.working": "Traitement…", + "settings.health.verified": "Relu et vérifié", + "settings.health.noBackup": "Aucune sauvegarde .bak disponible", + "settings.health.banner": "La configuration requiert votre attention : l’état des paramètres ou des enregistrements d’installation gérée est anormal. Restaurez-les ou réinitialisez-les ci-dessous.", "settings.appearance.header": "Apparence", "settings.appearance.theme": "Thème", "settings.appearance.system": "Système", @@ -806,14 +818,17 @@ const FR: Record = { "uninstall.heading": "Désinstaller Codex", "uninstall.warn": "Ceci supprime l'application Codex de votre ordinateur.", "uninstall.needAdopt": "Ce Codex est une installation externe. Prenez-le en charge depuis l'écran principal, puis revenez pour le désinstaller.", - "uninstall.status.loading": "正在检查安装状态…", - "uninstall.status.error": "无法确认安装状态(查询失败)。不会把它当成外部安装;请重试或回到主界面检查。", - "uninstall.status.none": "未检测到由本应用管理的 Codex 安装。", - "uninstall.partial.title": "应用已卸载,但部分清理未完成", - "uninstall.partial.retryCleanup": "仅重试清理(快捷方式/卸载项)", - "uninstall.partial.retryProvenance": "仅重试清除托管记录", - "uninstall.partial.retryPurge": "仅重试清除用户数据", - "uninstall.partial.retryRecord": "仅重试写入托管记录", + "uninstall.status.loading": "Vérification de l’état de l’installation…", + "uninstall.status.error": "Impossible de confirmer l’état de l’installation (échec de la vérification). Elle n’est pas considérée comme une installation externe — réessayez ou vérifiez l’écran d’accueil.", + "uninstall.status.none": "Aucune installation de Codex gérée par cette application n’a été détectée.", + "uninstall.partial.title": "L’application a été supprimée, mais une partie du nettoyage n’est pas terminée", + "uninstall.partial.summary": "Codex a été supprimé, mais le nettoyage annexe est incomplet. Réessayez uniquement l’étape correspondante ci-dessous ; ne relancez pas la désinstallation.", + "uninstall.partial.retryPending": "Certaines étapes de récupération restent incomplètes. Réessayez uniquement les étapes restantes ci-dessous.", + "uninstall.partial.retryDone": "L’étape de récupération sélectionnée est terminée.", + "uninstall.partial.retryCleanup": "Réessayer uniquement le nettoyage (raccourcis / entrée de désinstallation)", + "uninstall.partial.retryProvenance": "Réessayer uniquement l’effacement des enregistrements de gestion", + "uninstall.partial.retryPurge": "Réessayer uniquement l’effacement des données utilisateur", + "uninstall.partial.retryRecord": "Réessayer uniquement l’écriture de l’enregistrement de gestion", "uninstall.keepData": "Conserver mes données", "uninstall.keepDataNote": "Connexion, sessions et configuration ({path}) sont conservées ; une réinstallation reprend où vous en étiez.", "uninstall.dataPath": "Emplacement des données", @@ -962,8 +977,9 @@ const ZH_TW: Record = { "progress.finishing": "下載完成,正在安裝,請勿關閉", "install.done.title": "Codex 已安裝", "install.done.open": "開啟 Codex", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "應用程式已安裝,但無法寫入受管理記錄。請按「{action}」,請勿重複安裝。", + "install.partial.pending": "安裝流程已結束,但目前無法確認 Codex 是否已寫入磁碟。請按「{action}」重新偵測;確認前請勿重複安裝。", + "install.partial.record": "重試寫入受管理記錄", "success.title": "更新完成", "success.flow": "已更新 {from} → {to}", @@ -1031,25 +1047,26 @@ const ZH_TW: Record = { "settings.more.configUnavailable": "目前版本尚未提供", "settings.saveError": "設定儲存失敗", "settings.retry": "重試", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "設定健康狀態", + "settings.health.ok": "正常", + "settings.health.recovered": "已從備份還原", + "settings.health.corrupt": "已損毀", + "settings.health.settings": "設定檔", + "settings.health.provenance": "受管理記錄", + "settings.health.detail": "詳細資料", + "settings.health.unknownSource": "未知的更新來源已正規化為自動", + "settings.health.restore": "從 .bak 還原", + "settings.health.reset": "重設為預設值", + "settings.health.clearProvenance": "清除受管理記錄", + "settings.health.restoreConfirm.title": "從備份還原 {which}?", + "settings.health.restoreConfirm.body": "將使用上一次成功儲存的 .bak 覆寫目前檔案。目前損毀或異常的內容會被捨棄;還原後會重新讀取並驗證狀態。", + "settings.health.resetConfirm.title": "重設 {which}?", + "settings.health.resetConfirm.body.settings": "將捨棄目前設定並寫入出廠預設值(更新來源、檢查頻率、安裝方式等)。若備份仍存在,可再嘗試從 .bak 還原。", + "settings.health.resetConfirm.body.provenance": "將清除本應用程式的受管理記錄。已安裝的 Codex 不會被刪除,但在重新「{action}」前會顯示為未管理,且無法更新或解除安裝。", + "settings.health.working": "處理中…", + "settings.health.verified": "已重新讀取並驗證", + "settings.health.noBackup": "沒有可用的 .bak 備份", + "settings.health.banner": "設定需要處理:設定檔或受管理記錄的狀態異常。請在下方還原或重設。", "settings.appearance.header": "外觀", "settings.appearance.theme": "主題", "settings.appearance.system": "跟隨系統", @@ -1087,14 +1104,17 @@ const ZH_TW: Record = { "uninstall.warn": "這將從你的電腦移除 Codex 應用程式。", "uninstall.needAdopt": "這是外部安裝的 Codex。請先在主畫面點選「開始管理」,再回來卸載。", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "正在檢查安裝狀態…", + "uninstall.status.error": "無法確認安裝狀態(查詢失敗)。不會將其視為外部安裝;請重試或返回主畫面檢查。", + "uninstall.status.none": "未偵測到由本應用程式管理的 Codex 安裝。", + "uninstall.partial.title": "應用程式已解除安裝,但部分清理尚未完成", + "uninstall.partial.summary": "Codex 已解除安裝,但附屬清理尚未完成。請僅重試下方對應步驟,無需再次解除安裝。", + "uninstall.partial.retryPending": "仍有復原步驟未完成。請僅重試下方剩餘步驟。", + "uninstall.partial.retryDone": "所選復原步驟已完成。", + "uninstall.partial.retryCleanup": "僅重試清理(捷徑/解除安裝項目)", + "uninstall.partial.retryProvenance": "僅重試清除受管理記錄", + "uninstall.partial.retryPurge": "僅重試清除使用者資料", + "uninstall.partial.retryRecord": "僅重試寫入受管理記錄", "uninstall.keepData": "保留我的資料", "uninstall.keepDataNote": "登入狀態、工作階段與設定({path})都會保留;重新安裝後可繼續使用。", @@ -1248,8 +1268,9 @@ const DE: Record = { "progress.finishing": "Download abgeschlossen – wird installiert, nicht schließen", "install.done.title": "Codex installiert", "install.done.open": "Codex öffnen", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "Die App ist installiert, aber der Verwaltungsdatensatz konnte nicht gespeichert werden. Wählen Sie „{action}“ – installieren Sie die App nicht erneut.", + "install.partial.pending": "Die Installation ist abgeschlossen, aber Codex konnte auf dem Datenträger noch nicht bestätigt werden. Wählen Sie „{action}“, um erneut zu prüfen; installieren Sie die App bis zur Bestätigung nicht erneut.", + "install.partial.record": "Verwaltungsdatensatz erneut schreiben", "success.title": "Aktualisiert", "success.flow": "Aktualisiert {from} → {to}", @@ -1317,25 +1338,26 @@ const DE: Record = { "settings.more.configUnavailable": "In dieser Version nicht verfügbar", "settings.saveError": "Einstellungen konnten nicht gespeichert werden", "settings.retry": "Erneut versuchen", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "Konfigurationszustand", + "settings.health.ok": "In Ordnung", + "settings.health.recovered": "Aus Sicherung wiederhergestellt", + "settings.health.corrupt": "Beschädigt", + "settings.health.settings": "Einstellungsdatei", + "settings.health.provenance": "Datensätze verwalteter Installationen", + "settings.health.detail": "Einzelheiten", + "settings.health.unknownSource": "Unbekannte Updatequelle wurde auf Automatisch zurückgesetzt", + "settings.health.restore": "Aus .bak wiederherstellen", + "settings.health.reset": "Auf Standardwerte zurücksetzen", + "settings.health.clearProvenance": "Verwaltungsdatensätze löschen", + "settings.health.restoreConfirm.title": "{which} aus Sicherung wiederherstellen?", + "settings.health.restoreConfirm.body": "Ersetzt die aktuelle Datei durch die letzte erfolgreich gespeicherte .bak-Datei. Beschädigte oder unerwartete aktuelle Inhalte werden verworfen; anschließend wird der Zustand neu eingelesen und überprüft.", + "settings.health.resetConfirm.title": "{which} zurücksetzen?", + "settings.health.resetConfirm.body.settings": "Verwirft die aktuellen Einstellungen und schreibt die Werkseinstellungen (Updatequelle, Prüfintervall, Installationsmodus usw.). Wenn noch eine Sicherung vorhanden ist, können Sie weiterhin eine Wiederherstellung aus .bak versuchen.", + "settings.health.resetConfirm.body.provenance": "Löscht die Datensätze verwalteter Installationen dieser App. Das installierte Codex wird nicht gelöscht, erscheint jedoch als nicht verwaltet, bis Sie erneut „{action}“ wählen; bis dahin sind Update und Deinstallation nicht möglich.", + "settings.health.working": "Wird verarbeitet…", + "settings.health.verified": "Neu eingelesen und überprüft", + "settings.health.noBackup": "Keine .bak-Sicherung verfügbar", + "settings.health.banner": "Die Konfiguration erfordert Aufmerksamkeit: Einstellungen oder Datensätze verwalteter Installationen sind fehlerhaft. Stellen Sie sie unten wieder her oder setzen Sie sie zurück.", "settings.appearance.header": "Darstellung", "settings.appearance.theme": "Design", "settings.appearance.system": "System", @@ -1373,14 +1395,17 @@ const DE: Record = { "uninstall.warn": "Dadurch wird die Codex App von deinem Computer entfernt.", "uninstall.needAdopt": "Dies ist ein extern installiertes Codex. Übernimm zuerst die Verwaltung im Hauptbildschirm, dann komme hierher zurück.", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "Installationsstatus wird geprüft…", + "uninstall.status.error": "Der Installationsstatus konnte nicht bestätigt werden (Prüfung fehlgeschlagen). Dies wird nicht als externe Installation behandelt – versuchen Sie es erneut oder prüfen Sie den Startbildschirm.", + "uninstall.status.none": "Keine von dieser App verwaltete Codex-Installation erkannt.", + "uninstall.partial.title": "App entfernt, aber einige Bereinigungen sind noch nicht abgeschlossen", + "uninstall.partial.summary": "Codex wurde entfernt, aber die zusätzliche Bereinigung ist unvollständig. Wiederholen Sie nur den passenden Schritt unten; deinstallieren Sie nicht erneut.", + "uninstall.partial.retryPending": "Einige Wiederherstellungsschritte sind noch unvollständig. Wiederholen Sie nur die verbleibenden Schritte unten.", + "uninstall.partial.retryDone": "Der ausgewählte Wiederherstellungsschritt wurde abgeschlossen.", + "uninstall.partial.retryCleanup": "Nur Bereinigung erneut versuchen (Verknüpfungen / Deinstallationseintrag)", + "uninstall.partial.retryProvenance": "Nur Löschen der Verwaltungsdatensätze erneut versuchen", + "uninstall.partial.retryPurge": "Nur Löschen der Benutzerdaten erneut versuchen", + "uninstall.partial.retryRecord": "Nur Schreiben des Verwaltungsdatensatzes erneut versuchen", "uninstall.keepData": "Meine Daten behalten", "uninstall.keepDataNote": "Anmeldung, Sitzungen und Konfiguration ({path}) bleiben erhalten; nach einer Neuinstallation kannst du weitermachen.", @@ -1534,8 +1559,9 @@ const KO: Record = { "progress.finishing": "다운로드 완료 — 설치 중, 닫지 마세요", "install.done.title": "Codex 설치 완료", "install.done.open": "Codex 열기", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "앱은 설치되었지만 관리 기록을 저장하지 못했습니다. ‘{action}’ 버튼을 눌러 주세요. 앱을 다시 설치하지 마세요.", + "install.partial.pending": "설치 작업은 끝났지만 디스크에 Codex가 설치되었는지 아직 확인하지 못했습니다. ‘{action}’ 버튼을 눌러 다시 확인하세요. 확인되기 전에는 다시 설치하지 마세요.", + "install.partial.record": "관리 기록 저장 다시 시도", "success.title": "업데이트 완료", "success.flow": "{from} → {to} 업데이트 완료", @@ -1603,25 +1629,26 @@ const KO: Record = { "settings.more.configUnavailable": "이 버전에서는 사용할 수 없습니다", "settings.saveError": "설정을 저장하지 못했습니다", "settings.retry": "다시 시도", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "구성 상태", + "settings.health.ok": "정상", + "settings.health.recovered": "백업에서 복원됨", + "settings.health.corrupt": "손상됨", + "settings.health.settings": "설정 파일", + "settings.health.provenance": "관리 설치 기록", + "settings.health.detail": "세부 정보", + "settings.health.unknownSource": "알 수 없는 업데이트 소스를 자동으로 변경함", + "settings.health.restore": ".bak에서 복원", + "settings.health.reset": "기본값으로 재설정", + "settings.health.clearProvenance": "관리 기록 삭제", + "settings.health.restoreConfirm.title": "백업에서 {which} 복원?", + "settings.health.restoreConfirm.body": "마지막으로 저장에 성공한 .bak 파일로 현재 파일을 덮어씁니다. 현재의 손상되거나 예상치 못한 내용은 삭제되며, 복원 후 상태를 다시 읽고 확인합니다.", + "settings.health.resetConfirm.title": "{which} 재설정?", + "settings.health.resetConfirm.body.settings": "현재 설정을 삭제하고 공장 기본값(업데이트 소스, 확인 주기, 설치 방식 등)을 기록합니다. 백업이 남아 있으면 .bak 복원을 다시 시도할 수 있습니다.", + "settings.health.resetConfirm.body.provenance": "이 앱의 관리 설치 기록을 삭제합니다. 설치된 Codex는 삭제되지 않지만 ‘{action}’을 다시 수행할 때까지 관리되지 않는 앱으로 표시되며, 그때까지 업데이트하거나 제거할 수 없습니다.", + "settings.health.working": "처리 중…", + "settings.health.verified": "다시 읽고 확인함", + "settings.health.noBackup": "사용 가능한 .bak 백업 없음", + "settings.health.banner": "구성을 확인해야 합니다. 설정 또는 관리 설치 기록 상태가 비정상입니다. 아래에서 복원하거나 재설정하세요.", "settings.appearance.header": "외관", "settings.appearance.theme": "테마", "settings.appearance.system": "시스템", @@ -1659,14 +1686,17 @@ const KO: Record = { "uninstall.warn": "이 작업은 컴퓨터에서 Codex 앱을 제거합니다.", "uninstall.needAdopt": "외부에서 설치된 Codex입니다. 먼저 메인 화면에서 '관리 시작'을 누른 후 제거해 주세요.", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "설치 상태 확인 중…", + "uninstall.status.error": "설치 상태를 확인하지 못했습니다(조회 실패). 외부 설치로 간주하지 않습니다. 다시 시도하거나 홈 화면에서 확인하세요.", + "uninstall.status.none": "이 앱에서 관리하는 Codex 설치를 찾지 못했습니다.", + "uninstall.partial.title": "앱은 제거되었지만 일부 정리가 완료되지 않았습니다", + "uninstall.partial.summary": "Codex는 제거되었지만 부가 정리가 완료되지 않았습니다. 아래에서 해당 단계만 다시 시도하고 제거를 반복하지 마세요.", + "uninstall.partial.retryPending": "일부 복구 단계가 아직 완료되지 않았습니다. 아래의 남은 단계만 다시 시도하세요.", + "uninstall.partial.retryDone": "선택한 복구 단계가 완료되었습니다.", + "uninstall.partial.retryCleanup": "정리만 다시 시도(바로 가기/제거 항목)", + "uninstall.partial.retryProvenance": "관리 기록 삭제만 다시 시도", + "uninstall.partial.retryPurge": "사용자 데이터 삭제만 다시 시도", + "uninstall.partial.retryRecord": "관리 기록 쓰기만 다시 시도", "uninstall.keepData": "데이터 유지", "uninstall.keepDataNote": "로그인 상태, 세션 및 구성({path})이 보존되며, 재설치 후에도 이어서 사용할 수 있습니다.", @@ -1812,8 +1842,9 @@ const JA: Record = { "progress.finishing": "ダウンロード完了 — インストール中です。閉じないでください", "install.done.title": "Codex をインストールしました", "install.done.open": "Codex を開く", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "アプリはインストールされていますが、管理記録を保存できませんでした。「{action}」を選んでください。再インストールはしないでください。", + "install.partial.pending": "インストール処理は完了しましたが、Codex がディスク上にあることをまだ確認できません。「{action}」を選んで再確認してください。確認できるまで再インストールしないでください。", + "install.partial.record": "管理記録の保存を再試行", "success.title": "アップデート完了", "success.flow": "{from} → {to} に更新", "success.manualLaunch": "Codex を手動で起動してください", @@ -1878,25 +1909,26 @@ const JA: Record = { "settings.more.configUnavailable": "このリリースでは利用できません", "settings.saveError": "設定を保存できませんでした", "settings.retry": "再試行", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "構成の状態", + "settings.health.ok": "正常", + "settings.health.recovered": "バックアップから復元済み", + "settings.health.corrupt": "破損", + "settings.health.settings": "設定ファイル", + "settings.health.provenance": "管理対象インストールの記録", + "settings.health.detail": "詳細", + "settings.health.unknownSource": "不明な更新元を「自動」に変更しました", + "settings.health.restore": ".bak から復元", + "settings.health.reset": "既定値にリセット", + "settings.health.clearProvenance": "管理記録を消去", + "settings.health.restoreConfirm.title": "バックアップから {which} を復元しますか?", + "settings.health.restoreConfirm.body": "現在のファイルを、最後に正常に保存された .bak で上書きします。現在の破損した内容や予期しない内容は破棄され、復元後に状態を再読み込みして検証します。", + "settings.health.resetConfirm.title": "{which} をリセットしますか?", + "settings.health.resetConfirm.body.settings": "現在の設定を破棄し、工場出荷時の既定値(更新元、確認頻度、インストール方式など)を書き込みます。バックアップが残っている場合は、引き続き .bak からの復元を試せます。", + "settings.health.resetConfirm.body.provenance": "このアプリの管理対象インストール記録を消去します。インストール済みの Codex は削除されませんが、もう一度「{action}」するまで未管理として表示され、更新やアンインストールはできません。", + "settings.health.working": "処理中…", + "settings.health.verified": "再読み込みして検証済み", + "settings.health.noBackup": "利用できる .bak バックアップがありません", + "settings.health.banner": "構成を確認してください。設定または管理対象インストール記録の状態に問題があります。以下から復元またはリセットしてください。", "settings.appearance.header": "外観", "settings.appearance.theme": "テーマ", "settings.appearance.system": "システム設定に従う", @@ -1931,14 +1963,17 @@ const JA: Record = { "uninstall.heading": "Codex をアンインストール", "uninstall.warn": "Codex アプリをこのコンピューターから削除します。", "uninstall.needAdopt": "これは外部インストールの Codex です。まずメイン画面で「管理を開始」してから、アンインストールに戻ってください。", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "インストール状態を確認中…", + "uninstall.status.error": "インストール状態を確認できませんでした(確認に失敗)。外部インストールとは見なしません。再試行するか、ホーム画面で確認してください。", + "uninstall.status.none": "このアプリが管理する Codex のインストールは検出されませんでした。", + "uninstall.partial.title": "アプリは削除されましたが、一部のクリーンアップが完了していません", + "uninstall.partial.summary": "Codex は削除されましたが、付随するクリーンアップが完了していません。以下の該当手順だけを再試行し、アンインストールを繰り返さないでください。", + "uninstall.partial.retryPending": "一部の復旧手順がまだ完了していません。以下の残りの手順だけを再試行してください。", + "uninstall.partial.retryDone": "選択した復旧手順が完了しました。", + "uninstall.partial.retryCleanup": "クリーンアップのみ再試行(ショートカット/アンインストール項目)", + "uninstall.partial.retryProvenance": "管理記録の消去のみ再試行", + "uninstall.partial.retryPurge": "ユーザーデータの消去のみ再試行", + "uninstall.partial.retryRecord": "管理記録の書き込みのみ再試行", "uninstall.keepData": "データを保持する", "uninstall.keepDataNote": "サインイン情報、セッション、設定({path})は保持されます。再インストール後も続きから使えます。", "uninstall.dataPath": "データの場所", @@ -2078,8 +2113,9 @@ const RU: Record = { "progress.finishing": "Загрузка завершена — установка, не закрывайте", "install.done.title": "Codex установлен", "install.done.open": "Открыть Codex", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "Приложение установлено, но запись об управлении не удалось сохранить. Нажмите «{action}» — не переустанавливайте приложение.", + "install.partial.pending": "Установка завершена, но наличие Codex на диске пока не подтверждено. Нажмите «{action}», чтобы проверить снова; не переустанавливайте приложение до подтверждения.", + "install.partial.record": "Повторить сохранение записи об управлении", "success.title": "Обновлено", "success.flow": "Обновлено {from} → {to}", "success.manualLaunch": "Запустите Codex вручную", @@ -2144,25 +2180,26 @@ const RU: Record = { "settings.more.configUnavailable": "Недоступно в этом выпуске", "settings.saveError": "Не удалось сохранить настройки", "settings.retry": "Повторить", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "Состояние конфигурации", + "settings.health.ok": "Исправно", + "settings.health.recovered": "Восстановлено из резервной копии", + "settings.health.corrupt": "Повреждено", + "settings.health.settings": "Файл настроек", + "settings.health.provenance": "Записи об управляемых установках", + "settings.health.detail": "Подробности", + "settings.health.unknownSource": "Неизвестный источник обновлений заменён на автоматический", + "settings.health.restore": "Восстановить из .bak", + "settings.health.reset": "Сбросить до значений по умолчанию", + "settings.health.clearProvenance": "Удалить записи об управлении", + "settings.health.restoreConfirm.title": "Восстановить {which} из резервной копии?", + "settings.health.restoreConfirm.body": "Текущий файл будет заменён последним успешно сохранённым файлом .bak. Текущее повреждённое или непредвиденное содержимое будет удалено; затем состояние будет повторно прочитано и проверено.", + "settings.health.resetConfirm.title": "Сбросить {which}?", + "settings.health.resetConfirm.body.settings": "Текущие настройки будут удалены и заменены заводскими значениями (источник обновлений, частота проверки, способ установки и т. д.). Если резервная копия сохранилась, восстановление из .bak по-прежнему можно будет выполнить.", + "settings.health.resetConfirm.body.provenance": "Записи об управляемых установках этого приложения будут удалены. Установленный Codex удалён не будет, но будет отображаться как неуправляемый, пока вы снова не выберете «{action}»; до этого обновление и удаление будут недоступны.", + "settings.health.working": "Обработка…", + "settings.health.verified": "Повторно прочитано и проверено", + "settings.health.noBackup": "Нет доступной резервной копии .bak", + "settings.health.banner": "Конфигурация требует внимания: состояние настроек или записей об управляемых установках нарушено. Восстановите или сбросьте их ниже.", "settings.appearance.header": "Внешний вид", "settings.appearance.theme": "Тема", "settings.appearance.system": "Системная", @@ -2197,14 +2234,17 @@ const RU: Record = { "uninstall.heading": "Удаление Codex", "uninstall.warn": "Это удалит приложение Codex с вашего компьютера.", "uninstall.needAdopt": "Этот Codex установлен внешним образом. Сначала нажмите «Взять под управление» на главном экране, затем вернитесь сюда для удаления.", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "Проверка состояния установки…", + "uninstall.status.error": "Не удалось подтвердить состояние установки (сбой проверки). Она не считается внешней установкой — повторите попытку или проверьте главный экран.", + "uninstall.status.none": "Управляемая этим приложением установка Codex не обнаружена.", + "uninstall.partial.title": "Приложение удалено, но часть очистки не завершена", + "uninstall.partial.summary": "Codex удалён, но дополнительная очистка не завершена. Повторите только соответствующий шаг ниже; не запускайте удаление снова.", + "uninstall.partial.retryPending": "Некоторые шаги восстановления ещё не завершены. Повторите только оставшиеся шаги ниже.", + "uninstall.partial.retryDone": "Выбранный шаг восстановления завершён.", + "uninstall.partial.retryCleanup": "Повторить только очистку (ярлыки / запись удаления)", + "uninstall.partial.retryProvenance": "Повторить только удаление записей об управлении", + "uninstall.partial.retryPurge": "Повторить только удаление пользовательских данных", + "uninstall.partial.retryRecord": "Повторить только сохранение записи об управлении", "uninstall.keepData": "Сохранить мои данные", "uninstall.keepDataNote": "Данные для входа, сессии и конфигурация ({path}) сохранятся; после переустановки всё продолжит работать.", "uninstall.dataPath": "Расположение данных", @@ -2344,8 +2384,9 @@ const AR: Record = { "progress.finishing": "اكتمل التنزيل — جارٍ التثبيت، لا تغلق النافذة", "install.done.title": "تم تثبيت Codex", "install.done.open": "فتح Codex", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "تم تثبيت التطبيق، لكن تعذّر حفظ سجل الإدارة. استخدم «{action}» — لا تُعِد تثبيت التطبيق.", + "install.partial.pending": "انتهت عملية التثبيت، لكن لم يتأكد بعد وجود \u2066Codex\u2069 على القرص. استخدم «{action}» لإعادة التحقق؛ لا تُعِد التثبيت قبل التأكد.", + "install.partial.record": "إعادة محاولة حفظ سجل الإدارة", "success.title": "تم التحديث", "success.flow": "تم التحديث {from} ← {to}", "success.manualLaunch": "يُرجى تشغيل Codex يدوياً", @@ -2410,25 +2451,26 @@ const AR: Record = { "settings.more.configUnavailable": "غير متوفر في هذا الإصدار", "settings.saveError": "تعذر حفظ الإعدادات", "settings.retry": "إعادة المحاولة", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "سلامة الإعدادات", + "settings.health.ok": "سليم", + "settings.health.recovered": "تمت الاستعادة من النسخة الاحتياطية", + "settings.health.corrupt": "تالف", + "settings.health.settings": "ملف الإعدادات", + "settings.health.provenance": "سجلات التثبيت المُدار", + "settings.health.detail": "التفاصيل", + "settings.health.unknownSource": "تم تحويل مصدر التحديث غير المعروف إلى «تلقائي»", + "settings.health.restore": "استعادة من \u2066.bak\u2069", + "settings.health.reset": "إعادة الضبط إلى الإعدادات الافتراضية", + "settings.health.clearProvenance": "مسح سجلات الإدارة", + "settings.health.restoreConfirm.title": "استعادة {which} من النسخة الاحتياطية؟", + "settings.health.restoreConfirm.body": "يستبدل الملف الحالي بآخر ملف \u2066.bak\u2069 حُفظ بنجاح. سيُحذف المحتوى الحالي التالف أو غير المتوقع، ثم تُعاد قراءة الحالة والتحقق منها.", + "settings.health.resetConfirm.title": "إعادة ضبط {which}؟", + "settings.health.resetConfirm.body.settings": "يؤدي ذلك إلى حذف الإعدادات الحالية وكتابة إعدادات المصنع الافتراضية (مصدر التحديث، وتكرار الفحص، ووضع التثبيت، وما إلى ذلك). يمكنك محاولة الاستعادة من \u2066.bak\u2069 إذا بقيت نسخة احتياطية.", + "settings.health.resetConfirm.body.provenance": "يؤدي ذلك إلى مسح سجلات التثبيت المُدار لهذا التطبيق. لن يُحذف \u2066Codex\u2069 المثبّت، لكنه سيظهر كتثبيت غير مُدار إلى أن تستخدم «{action}» مرة أخرى؛ وحتى ذلك الحين لن تتمكن من تحديثه أو إلغاء تثبيته.", + "settings.health.working": "جارٍ المعالجة…", + "settings.health.verified": "تمت إعادة القراءة والتحقق", + "settings.health.noBackup": "لا تتوفر نسخة احتياطية \u2066.bak\u2069", + "settings.health.banner": "تحتاج الإعدادات إلى الانتباه: حالة ملف الإعدادات أو سجلات التثبيت المُدار غير سليمة. استعدها أو أعد ضبطها أدناه.", "settings.appearance.header": "المظهر", "settings.appearance.theme": "السمة", "settings.appearance.system": "حسب النظام", @@ -2463,14 +2505,17 @@ const AR: Record = { "uninstall.heading": "إلغاء تثبيت Codex", "uninstall.warn": "سيُزيل هذا تطبيق Codex من جهازك.", "uninstall.needAdopt": "هذا Codex مثبَّت خارجياً. ابدأ إدارته من الشاشة الرئيسية أولاً، ثم عد لإلغاء التثبيت.", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "جارٍ التحقق من حالة التثبيت…", + "uninstall.status.error": "تعذّر تأكيد حالة التثبيت (فشل الفحص). لن يُعامل كتثبيت خارجي — أعد المحاولة أو تحقّق من الشاشة الرئيسية.", + "uninstall.status.none": "لم يُكتشف أي تثبيت لـ \u2066Codex\u2069 يديره هذا التطبيق.", + "uninstall.partial.title": "تمت إزالة التطبيق، لكن بعض أعمال التنظيف لم تكتمل", + "uninstall.partial.summary": "تمت إزالة \u2066Codex\u2069، لكن التنظيف الإضافي لم يكتمل. أعد محاولة الخطوة المطابقة أدناه فقط، ولا تُعِد إلغاء التثبيت.", + "uninstall.partial.retryPending": "لا تزال بعض خطوات الاسترداد غير مكتملة. أعد محاولة الخطوات المتبقية أدناه فقط.", + "uninstall.partial.retryDone": "اكتملت خطوة الاسترداد المحددة.", + "uninstall.partial.retryCleanup": "إعادة محاولة التنظيف فقط (الاختصارات / إدخال إلغاء التثبيت)", + "uninstall.partial.retryProvenance": "إعادة محاولة مسح سجلات الإدارة فقط", + "uninstall.partial.retryPurge": "إعادة محاولة مسح بيانات المستخدم فقط", + "uninstall.partial.retryRecord": "إعادة محاولة كتابة سجل الإدارة فقط", "uninstall.keepData": "الاحتفاظ ببياناتي", "uninstall.keepDataNote": "سيُحتفظ بتسجيل الدخول والجلسات والإعدادات ({path})؛ يمكن استئنافها بعد إعادة التثبيت.", "uninstall.dataPath": "موقع البيانات", @@ -2610,8 +2655,9 @@ const ES: Record = { "progress.finishing": "Descarga completa — instalando, no cierres", "install.done.title": "Codex instalado", "install.done.open": "Abrir Codex", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "La aplicación está instalada, pero no se pudo guardar el registro de administración. Usa «{action}»; no reinstales la aplicación.", + "install.partial.pending": "La instalación terminó, pero aún no se pudo confirmar que Codex esté en el disco. Usa «{action}» para comprobarlo de nuevo; no reinstales la aplicación hasta confirmarlo.", + "install.partial.record": "Reintentar el registro de administración", "success.title": "Actualizado", "success.flow": "Actualizado {from} → {to}", "success.manualLaunch": "Inicia Codex manualmente", @@ -2676,25 +2722,26 @@ const ES: Record = { "settings.more.configUnavailable": "No disponible en esta versión", "settings.saveError": "No se pudo guardar la configuración", "settings.retry": "Reintentar", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "Estado de la configuración", + "settings.health.ok": "Correcta", + "settings.health.recovered": "Restaurada desde la copia de seguridad", + "settings.health.corrupt": "Dañada", + "settings.health.settings": "Archivo de ajustes", + "settings.health.provenance": "Registros de instalaciones administradas", + "settings.health.detail": "Detalles", + "settings.health.unknownSource": "La fuente de actualizaciones desconocida se cambió a Automática", + "settings.health.restore": "Restaurar desde .bak", + "settings.health.reset": "Restablecer valores predeterminados", + "settings.health.clearProvenance": "Borrar registros de administración", + "settings.health.restoreConfirm.title": "¿Restaurar {which} desde la copia de seguridad?", + "settings.health.restoreConfirm.body": "Sustituye el archivo actual por el último .bak guardado correctamente. Se descartará el contenido actual dañado o inesperado; después se volverá a leer y verificar el estado.", + "settings.health.resetConfirm.title": "¿Restablecer {which}?", + "settings.health.resetConfirm.body.settings": "Descarta los ajustes actuales y escribe los valores de fábrica (fuente de actualizaciones, frecuencia de comprobación, modo de instalación, etc.). Si aún existe una copia, podrás intentar restaurar el .bak.", + "settings.health.resetConfirm.body.provenance": "Elimina los registros de instalaciones administradas de esta aplicación. Codex no se desinstala, pero aparecerá como no administrado hasta que vuelvas a usar «{action}»; hasta entonces no podrás actualizarlo ni desinstalarlo.", + "settings.health.working": "Procesando…", + "settings.health.verified": "Leída de nuevo y verificada", + "settings.health.noBackup": "No hay ninguna copia .bak disponible", + "settings.health.banner": "La configuración requiere atención: los ajustes o los registros de instalaciones administradas no están en buen estado. Restáuralos o restablécelos a continuación.", "settings.appearance.header": "Apariencia", "settings.appearance.theme": "Tema", "settings.appearance.system": "Sistema", @@ -2729,14 +2776,17 @@ const ES: Record = { "uninstall.heading": "Desinstalar Codex", "uninstall.warn": "Esto elimina la app Codex de tu equipo.", "uninstall.needAdopt": "Este Codex es externo. Primero gestiónalo desde la pantalla principal y luego vuelve para desinstalarlo.", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "Comprobando el estado de la instalación…", + "uninstall.status.error": "No se pudo confirmar el estado de la instalación (falló la comprobación). No se considera una instalación externa; reinténtalo o comprueba la pantalla de inicio.", + "uninstall.status.none": "No se detectó ninguna instalación de Codex administrada por esta aplicación.", + "uninstall.partial.title": "La aplicación se eliminó, pero parte de la limpieza no terminó", + "uninstall.partial.summary": "Codex se eliminó, pero la limpieza auxiliar no terminó. Reintenta solo el paso correspondiente de abajo; no vuelvas a desinstalar.", + "uninstall.partial.retryPending": "Algunos pasos de recuperación siguen incompletos. Reintenta solo los pasos restantes de abajo.", + "uninstall.partial.retryDone": "El paso de recuperación seleccionado terminó.", + "uninstall.partial.retryCleanup": "Reintentar solo la limpieza (accesos directos / entrada de desinstalación)", + "uninstall.partial.retryProvenance": "Reintentar solo la eliminación de los registros de administración", + "uninstall.partial.retryPurge": "Reintentar solo la eliminación de los datos de usuario", + "uninstall.partial.retryRecord": "Reintentar solo la escritura del registro de administración", "uninstall.keepData": "Conservar mis datos", "uninstall.keepDataNote": "El inicio de sesión, las sesiones y la configuración ({path}) se conservan; al reinstalar, continúas donde lo dejaste.", "uninstall.dataPath": "Ubicación de datos", @@ -2876,8 +2926,9 @@ const PT_BR: Record = { "progress.finishing": "Download concluído — instalando, não feche", "install.done.title": "Codex instalado", "install.done.open": "Abrir Codex", - "install.partial.note": "The app is on disk, but the managed record could not be saved. Use Start managing to retry the record — do not reinstall.", - "install.partial.record": "Retry managed record", + "install.partial.note": "O aplicativo está instalado, mas não foi possível salvar o registro de gerenciamento. Use “{action}” — não reinstale o aplicativo.", + "install.partial.pending": "A instalação terminou, mas ainda não foi possível confirmar o Codex no disco. Use “{action}” para verificar novamente; não reinstale o aplicativo até a confirmação.", + "install.partial.record": "Tentar salvar o registro de gerenciamento novamente", "success.title": "Atualizado", "success.flow": "Atualizado {from} → {to}", "success.manualLaunch": "Inicie o Codex manualmente", @@ -2942,25 +2993,26 @@ const PT_BR: Record = { "settings.more.configUnavailable": "Indisponível nesta versão", "settings.saveError": "Não foi possível salvar as configurações", "settings.retry": "Tentar novamente", - "settings.health.header": "Configuration health", - "settings.health.ok": "OK", - "settings.health.recovered": "Recovered from backup", - "settings.health.corrupt": "Corrupt", - "settings.health.settings": "Settings file", - "settings.health.provenance": "Managed-install records", - "settings.health.detail": "Details", - "settings.health.unknownSource": "Unknown update source was normalized to Automatic", - "settings.health.restore": "Restore from .bak", - "settings.health.reset": "Reset to defaults", - "settings.health.restoreConfirm.title": "Restore {which} from backup?", - "settings.health.restoreConfirm.body": "Replaces the current file with the last successful .bak. Current corrupt or unexpected content is discarded; health is re-read and verified afterward.", - "settings.health.resetConfirm.title": "Reset {which}?", - "settings.health.resetConfirm.body.settings": "Discards current settings and writes factory defaults (update source, check cadence, install mode, etc.). You can still try .bak restore if a backup remains.", - "settings.health.resetConfirm.body.provenance": "Clears this app's managed-install records. Installed Codex is not deleted, but will appear unmanaged until you Start managing again.", - "settings.health.working": "Working…", - "settings.health.verified": "Re-read and verified", - "settings.health.noBackup": "No .bak backup available", - "settings.health.banner": "Configuration needs attention: settings or managed-install records look unhealthy. Restore or reset below.", + "settings.health.header": "Integridade da configuração", + "settings.health.ok": "Íntegra", + "settings.health.recovered": "Restaurada do backup", + "settings.health.corrupt": "Corrompida", + "settings.health.settings": "Arquivo de configurações", + "settings.health.provenance": "Registros de instalações gerenciadas", + "settings.health.detail": "Detalhes", + "settings.health.unknownSource": "A fonte de atualização desconhecida foi alterada para Automático", + "settings.health.restore": "Restaurar do .bak", + "settings.health.reset": "Redefinir para os padrões", + "settings.health.clearProvenance": "Limpar registros de gerenciamento", + "settings.health.restoreConfirm.title": "Restaurar {which} do backup?", + "settings.health.restoreConfirm.body": "Substitui o arquivo atual pelo último .bak salvo com sucesso. O conteúdo atual corrompido ou inesperado será descartado; depois, a integridade será relida e verificada.", + "settings.health.resetConfirm.title": "Redefinir {which}?", + "settings.health.resetConfirm.body.settings": "Descarta as configurações atuais e grava os padrões de fábrica (fonte de atualização, frequência de verificação, modo de instalação etc.). Se ainda houver um backup, você poderá tentar restaurar o .bak.", + "settings.health.resetConfirm.body.provenance": "Limpa os registros de instalações gerenciadas deste aplicativo. O Codex instalado não será removido, mas aparecerá como não gerenciado até você usar “{action}” novamente; até lá, não poderá atualizá-lo nem desinstalá-lo.", + "settings.health.working": "Processando…", + "settings.health.verified": "Relida e verificada", + "settings.health.noBackup": "Nenhum backup .bak disponível", + "settings.health.banner": "A configuração requer atenção: as configurações ou os registros de instalações gerenciadas não estão íntegros. Restaure-os ou redefina-os abaixo.", "settings.appearance.header": "Aparência", "settings.appearance.theme": "Tema", "settings.appearance.system": "Sistema", @@ -2995,14 +3047,17 @@ const PT_BR: Record = { "uninstall.heading": "Desinstalar Codex", "uninstall.warn": "Isso remove o aplicativo Codex do seu computador.", "uninstall.needAdopt": "Este é um Codex externo. Primeiro clique em \"Começar a gerenciar\" na tela principal e depois volte para desinstalar.", - "uninstall.status.loading": "Checking install status…", - "uninstall.status.error": "Could not confirm install status (probe failed). This is not treated as an external install — retry or check the home screen.", - "uninstall.status.none": "No manager-managed Codex install was detected.", - "uninstall.partial.title": "App removed, but some cleanup did not finish", - "uninstall.partial.retryCleanup": "Retry cleanup only (shortcuts / uninstall entry)", - "uninstall.partial.retryProvenance": "Retry clearing managed records only", - "uninstall.partial.retryPurge": "Retry purging user data only", - "uninstall.partial.retryRecord": "Retry writing managed record only", + "uninstall.status.loading": "Verificando o estado da instalação…", + "uninstall.status.error": "Não foi possível confirmar o estado da instalação (falha na verificação). Ela não será tratada como instalação externa — tente novamente ou verifique a tela inicial.", + "uninstall.status.none": "Nenhuma instalação do Codex gerenciada por este aplicativo foi detectada.", + "uninstall.partial.title": "O aplicativo foi removido, mas parte da limpeza não foi concluída", + "uninstall.partial.summary": "O Codex foi removido, mas a limpeza auxiliar não foi concluída. Tente novamente apenas a etapa correspondente abaixo; não desinstale outra vez.", + "uninstall.partial.retryPending": "Algumas etapas de recuperação ainda não foram concluídas. Tente novamente apenas as etapas restantes abaixo.", + "uninstall.partial.retryDone": "A etapa de recuperação selecionada foi concluída.", + "uninstall.partial.retryCleanup": "Tentar novamente apenas a limpeza (atalhos / entrada de desinstalação)", + "uninstall.partial.retryProvenance": "Tentar novamente apenas a remoção dos registros de gerenciamento", + "uninstall.partial.retryPurge": "Tentar novamente apenas a remoção dos dados do usuário", + "uninstall.partial.retryRecord": "Tentar novamente apenas a gravação do registro de gerenciamento", "uninstall.keepData": "Manter meus dados", "uninstall.keepDataNote": "Login, sessões e configurações ({path}) são mantidos; reinstalar retoma de onde parou.", "uninstall.dataPath": "Local dos dados", diff --git a/src/app/views/Home.tsx b/src/app/views/Home.tsx index 67420ad..71be6e2 100644 --- a/src/app/views/Home.tsx +++ b/src/app/views/Home.tsx @@ -259,7 +259,7 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) { // install on disk; never treat it as a hard failure (would invite reinstall). const outcome = status.outcome; if (outcome?.primaryOk && outcome.recoveryActions.includes("record_provenance")) { - setNotice(t("install.partial.note")); + setNotice(t("install.partial.note", { action: t("install.partial.record") })); setJustInstalled(true); } else { setJustInstalled(true); @@ -575,7 +575,9 @@ function MacHome({ onOpenSettings }: { onOpenSettings: () => void }) {
{actionError ? : null} {notice || needsRecord ? ( - {notice ?? t("install.partial.note")} + + {notice ?? t("install.partial.note", { action: t("install.partial.record") })} + ) : null}
diff --git a/src/app/views/Settings.test.tsx b/src/app/views/Settings.test.tsx index 652833a..caf2566 100644 --- a/src/app/views/Settings.test.tsx +++ b/src/app/views/Settings.test.tsx @@ -3,8 +3,8 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { managerApi } from "../../services/managerApi"; -import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types"; -import { I18nProvider } from "../i18n"; +import { DEFAULT_SETTINGS, type AppSettings, type ConfigHealth } from "../../shared/types"; +import { CATALOG, I18nProvider, type Lang } from "../i18n"; import { ThemeProvider } from "../theme"; import { Settings } from "./Settings"; @@ -17,6 +17,9 @@ vi.mock("../../services/managerApi", async (importOriginal) => { setSettings: vi.fn(), getAutostart: vi.fn(), setAutostart: vi.fn(), + getConfigHealth: vi.fn(), + restoreConfigBackup: vi.fn(), + resetConfig: vi.fn(), winDefaultInstallRoot: vi.fn(), winPickInstallDir: vi.fn(), winSetInstallRoot: vi.fn(), @@ -27,6 +30,26 @@ vi.mock("../../services/managerApi", async (importOriginal) => { const api = vi.mocked(managerApi); +const HEALTHY_CONFIG: ConfigHealth = { + settingsStatus: "ok", + provenanceStatus: "ok", + unknownSource: null, + detail: null, + settingsBackupAvailable: false, + provenanceBackupAvailable: false, +}; + +const BROKEN_CONFIG: ConfigHealth = { + settingsStatus: "corrupt", + provenanceStatus: "corrupt", + unknownSource: null, + detail: "invalid JSON", + settingsBackupAvailable: true, + provenanceBackupAvailable: true, +}; + +const SAFETY_FLOW_LANGS = ["fr", "ar", "es", "zh-TW"] as const satisfies readonly Lang[]; + function settings(overrides: Partial = {}): AppSettings { return { ...DEFAULT_SETTINGS, ...overrides }; } @@ -63,8 +86,14 @@ describe("Settings runtime contract", () => { api.setSettings.mockReset(); api.getAutostart.mockReset(); api.setAutostart.mockReset(); + api.getConfigHealth.mockReset(); + api.restoreConfigBackup.mockReset(); + api.resetConfig.mockReset(); api.getAutostart.mockResolvedValue(false); api.setSettings.mockImplementation(async (next) => next); + api.getConfigHealth.mockResolvedValue(HEALTHY_CONFIG); + api.restoreConfigBackup.mockResolvedValue(HEALTHY_CONFIG); + api.resetConfig.mockResolvedValue(HEALTHY_CONFIG); }); it("keeps the form non-editable until settings hydrate", async () => { @@ -225,4 +254,44 @@ describe("Settings runtime contract", () => { ); expect(screen.getByRole("radio", { name: /镜像/ })).toHaveAttribute("aria-checked", "true"); }); + + it.each(SAFETY_FLOW_LANGS)( + "shows and completes the managed-record reset consequence flow in %s", + async (lang) => { + const user = userEvent.setup(); + localStorage.setItem("cam.lang", lang); + api.getSettings.mockResolvedValue(settings()); + api.getConfigHealth + .mockResolvedValueOnce(BROKEN_CONFIG) + .mockResolvedValue(HEALTHY_CONFIG); + + renderSettings(); + + expect(await screen.findByText(CATALOG[lang]["settings.health.banner"])).toBeInTheDocument(); + const resetLabel = CATALOG[lang]["settings.health.clearProvenance"]; + expect( + screen.getByRole("button", { name: CATALOG[lang]["settings.health.reset"] }), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: resetLabel })); + + const title = CATALOG[lang]["settings.health.resetConfirm.title"].replace( + "{which}", + CATALOG[lang]["settings.health.provenance"], + ); + const dialog = screen.getByRole("dialog", { name: title }); + const provenanceBody = CATALOG[lang]["settings.health.resetConfirm.body.provenance"].replace( + "{action}", + CATALOG[lang]["home.external.cta"], + ); + expect( + within(dialog).getByText(provenanceBody), + ).toBeInTheDocument(); + + await user.click(within(dialog).getByRole("button", { name: resetLabel })); + + await waitFor(() => expect(api.resetConfig).toHaveBeenCalledWith("provenance")); + expect(await screen.findByText(CATALOG[lang]["settings.health.verified"])).toBeInTheDocument(); + expect(document.documentElement.dir).toBe(lang === "ar" ? "rtl" : "ltr"); + }, + ); }); diff --git a/src/app/views/Settings.tsx b/src/app/views/Settings.tsx index 7440806..330391c 100644 --- a/src/app/views/Settings.tsx +++ b/src/app/views/Settings.tsx @@ -394,7 +394,11 @@ export function Settings({ > {healthBusy === `reset:${row.which}` ? t("settings.health.working") - : t("settings.health.reset")} + : t( + row.which === "provenance" + ? "settings.health.clearProvenance" + : "settings.health.reset", + )}
@@ -921,7 +925,9 @@ export function Settings({ ? t("settings.health.restoreConfirm.body") : healthConfirm.which === "settings" ? t("settings.health.resetConfirm.body.settings") - : t("settings.health.resetConfirm.body.provenance")} + : t("settings.health.resetConfirm.body.provenance", { + action: t("home.external.cta"), + })}

diff --git a/src/app/views/Uninstall.test.tsx b/src/app/views/Uninstall.test.tsx index 9803c1f..d6f8201 100644 --- a/src/app/views/Uninstall.test.tsx +++ b/src/app/views/Uninstall.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { managerApi } from "../../services/managerApi"; import { emptyOperationOutcome } from "../../shared/types"; -import { I18nProvider } from "../i18n"; +import { CATALOG, I18nProvider, type Lang } from "../i18n"; import { Uninstall } from "./Uninstall"; vi.mock("../../services/managerApi", () => ({ @@ -24,6 +24,7 @@ const winStatus = vi.mocked(managerApi.winStatus); const macUninstall = vi.mocked(managerApi.macUninstall); const winUninstall = vi.mocked(managerApi.winUninstall); const retryAncillary = vi.mocked(managerApi.retryAncillary); +const SAFETY_FLOW_LANGS = ["fr", "ar", "es", "zh-TW"] as const satisfies readonly Lang[]; function setPlatform(platform: string) { Object.defineProperty(navigator, "platform", { configurable: true, value: platform }); @@ -236,4 +237,134 @@ describe("Uninstall", () => { ), ); }); + + it("keeps both an unattempted action and the attempted action when retry still fails", async () => { + const user = userEvent.setup(); + setPlatform("MacIntel"); + macUninstall.mockResolvedValue({ + removed: true, + keptCodexHome: true, + message: "removed with two ancillary failures", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "absent", + installClass: "none", + provenance: { state: "failed", detail: "record failed" }, + cleanup: { state: "failed", detail: "cleanup failed" }, + recoveryActions: ["cleanup_metadata", "record_provenance"], + }), + }); + // Deliberately omit recoveryActions: the failed step status must still keep + // the attempted CTA, while the untouched cleanup action is merged back in. + retryAncillary.mockResolvedValue({ + message: "record still failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "disk full" }, + recoveryActions: [], + }), + }); + + renderUninstall(); + await screen.findByText("Data location"); + await user.click(screen.getByRole("button", { name: "Uninstall" })); + await user.click(screen.getByRole("button", { name: "Continue" })); + await user.click( + screen.getByRole("button", { name: "Retry writing managed record only" }), + ); + + expect( + await screen.findByText(/some recovery steps are still incomplete/i), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Retry cleanup only (shortcuts / uninstall entry)" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Retry writing managed record only" }), + ).toBeInTheDocument(); + }); + + it.each(SAFETY_FLOW_LANGS)( + "preserves unattempted recovery actions after one successful retry in %s", + async (lang) => { + const user = userEvent.setup(); + localStorage.setItem("cam.lang", lang); + setPlatform("MacIntel"); + macUninstall.mockResolvedValue({ + removed: true, + keptCodexHome: true, + message: "removed with recovery actions", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "absent", + installClass: "none", + cleanup: { state: "failed", detail: "ancillary cleanup failed" }, + recoveryActions: [ + "cleanup_metadata", + "clear_provenance", + "purge_user_data", + "record_provenance", + ], + warnings: ["ancillary cleanup failed"], + }), + }); + + renderUninstall(); + + await screen.findByText(CATALOG[lang]["uninstall.dataPath"]); + await user.click(screen.getByRole("button", { name: CATALOG[lang]["uninstall.confirm"] })); + await user.click(screen.getByRole("button", { name: CATALOG[lang]["uninstall.continue"] })); + + expect( + await screen.findByText(CATALOG[lang]["uninstall.partial.title"]), + ).toBeInTheDocument(); + expect( + screen.getByText(CATALOG[lang]["uninstall.partial.summary"], { selector: ".desc" }), + ).toBeInTheDocument(); + expect( + screen.getByText("removed with recovery actions", { selector: ".errdetails" }), + ).toBeInTheDocument(); + const recoveryKeys = [ + "uninstall.partial.retryCleanup", + "uninstall.partial.retryProvenance", + "uninstall.partial.retryPurge", + "uninstall.partial.retryRecord", + ] as const; + for (const key of recoveryKeys) { + expect(screen.getByRole("button", { name: CATALOG[lang][key] })).toBeInTheDocument(); + } + + await user.click( + screen.getByRole("button", { + name: CATALOG[lang]["uninstall.partial.retryProvenance"], + }), + ); + await waitFor(() => + expect(retryAncillary).toHaveBeenCalledWith( + expect.objectContaining({ actions: ["clear_provenance"], purgeUserData: false }), + ), + ); + expect( + await screen.findByText(CATALOG[lang]["uninstall.partial.retryPending"], { + selector: ".desc", + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { + name: CATALOG[lang]["uninstall.partial.retryProvenance"], + }), + ).not.toBeInTheDocument(); + for (const key of [ + "uninstall.partial.retryCleanup", + "uninstall.partial.retryPurge", + "uninstall.partial.retryRecord", + ] as const) { + expect(screen.getByRole("button", { name: CATALOG[lang][key] })).toBeInTheDocument(); + } + expect(screen.getByText("cleanup ok", { selector: ".errdetails" })).toBeInTheDocument(); + expect(document.documentElement.dir).toBe(lang === "ar" ? "rtl" : "ltr"); + }, + ); }); diff --git a/src/app/views/Uninstall.tsx b/src/app/views/Uninstall.tsx index fe5dd63..a40c003 100644 --- a/src/app/views/Uninstall.tsx +++ b/src/app/views/Uninstall.tsx @@ -18,6 +18,45 @@ function hasTauriRuntime(): boolean { ); } +const PROVENANCE_RECOVERY_ACTIONS = new Set(["record_provenance", "clear_provenance"]); +const CLEANUP_RECOVERY_ACTIONS = new Set(["cleanup_metadata", "purge_user_data"]); + +/** Merge a scoped ancillary retry back into the original partial outcome. + * The backend reports only the actions attempted in this request; actions the + * user has not retried yet must remain visible, and a failed attempted action + * remains available even if a backend forgets to echo its recovery key. */ +function mergeAncillaryRetryOutcome( + previous: OperationOutcome, + attempted: string[], + retried: OperationOutcome, +): OperationOutcome { + const attemptedSet = new Set(attempted); + const unattempted = previous.recoveryActions.filter((action) => !attemptedSet.has(action)); + const failedAttempted = attempted.filter((action) => { + if (PROVENANCE_RECOVERY_ACTIONS.has(action)) return retried.provenance.state === "failed"; + if (CLEANUP_RECOVERY_ACTIONS.has(action)) return retried.cleanup.state === "failed"; + return false; + }); + const recoveryActions = [ + ...new Set([...unattempted, ...retried.recoveryActions, ...failedAttempted]), + ]; + const hasUnattemptedProvenance = unattempted.some((action) => + PROVENANCE_RECOVERY_ACTIONS.has(action), + ); + const hasUnattemptedCleanup = unattempted.some((action) => + CLEANUP_RECOVERY_ACTIONS.has(action), + ); + + return { + ...retried, + path: retried.path ?? previous.path, + provenance: hasUnattemptedProvenance ? previous.provenance : retried.provenance, + cleanup: hasUnattemptedCleanup ? previous.cleanup : retried.cleanup, + warnings: [...new Set([...previous.warnings, ...retried.warnings])], + recoveryActions, + }; +} + export function Uninstall({ onBack }: { onBack: () => void }) { const { t } = useI18n(); const platform = currentPlatform(); @@ -28,6 +67,7 @@ export function Uninstall({ onBack }: { onBack: () => void }) { const [keepData, setKeepData] = useState(true); const [busy, setBusy] = useState(false); const [done, setDone] = useState(null); + const [doneDetail, setDoneDetail] = useState(null); const [error, setError] = useState(null); const [pathCopied, setPathCopied] = useState(false); // Install probe: Loading / Managed / External / None / Error — never treat a @@ -68,6 +108,7 @@ export function Uninstall({ onBack }: { onBack: () => void }) { setBusy(true); setError(null); setPartialOutcome(null); + setDoneDetail(null); try { // mac keeps ~/.codex via keepCodexHome; win purges via purgeUserData (the // inverse) — both surface the backend message as the source of truth. @@ -75,7 +116,8 @@ export function Uninstall({ onBack }: { onBack: () => void }) { const r = await managerApi.winUninstall(true, !keepData); if (r.success && outcomeIsPartial(r.outcome)) { setPartialOutcome(r.outcome); - setDone(r.message); + setDone(t("uninstall.partial.summary")); + setDoneDetail(r.message); return; } if (!r.success) { @@ -87,7 +129,8 @@ export function Uninstall({ onBack }: { onBack: () => void }) { const r = await managerApi.macUninstall(keepData); if (r.removed && outcomeIsPartial(r.outcome)) { setPartialOutcome(r.outcome); - setDone(r.message); + setDone(t("uninstall.partial.summary")); + setDoneDetail(r.message); return; } if (!r.removed) { @@ -113,12 +156,17 @@ export function Uninstall({ onBack }: { onBack: () => void }) { path: partialOutcome?.path ?? null, purgeUserData: actions.includes("purge_user_data"), }); - setDone(report.message); - if (outcomeIsPartial(report.outcome)) { - setPartialOutcome(report.outcome); + const mergedOutcome = partialOutcome + ? mergeAncillaryRetryOutcome(partialOutcome, actions, report.outcome) + : report.outcome; + if (mergedOutcome.recoveryActions.length > 0 || outcomeIsPartial(mergedOutcome)) { + setDone(t("uninstall.partial.retryPending")); + setPartialOutcome(mergedOutcome); } else { + setDone(t("uninstall.partial.retryDone")); setPartialOutcome(null); } + setDoneDetail(report.message); } catch (cause) { setError(userErrorMessage(cause, t)); } finally { @@ -162,6 +210,12 @@ export function Uninstall({ onBack }: { onBack: () => void }) {
{t("uninstall.heading")}
{done}
+ {doneDetail ? ( +
+ {t("home.error.details")} +
{doneDetail}
+
+ ) : null} {partialOutcome ? ( <> {t("uninstall.partial.title")} diff --git a/src/app/views/WinHome.test.tsx b/src/app/views/WinHome.test.tsx index 223ddfb..025f39b 100644 --- a/src/app/views/WinHome.test.tsx +++ b/src/app/views/WinHome.test.tsx @@ -9,6 +9,7 @@ import type { AppSettings, CapabilityCheck, InstalledWindowsCodex, + OperationCompletion, WinCapabilityReport, WindowsUpdatePlan, WinInstallStatus, @@ -27,9 +28,11 @@ vi.mock("../../services/managerApi", async (importOriginal) => { return { ...actual, managerApi: { + armDestructive: vi.fn(), getSettings: vi.fn(), setSettings: vi.fn(), getOperationSnapshot: vi.fn(() => Promise.resolve(null)), + getOperationCompletion: vi.fn(() => Promise.resolve(null)), winStatus: vi.fn(), winPlanUpdate: vi.fn(), winPerformUpdate: vi.fn(), @@ -155,9 +158,22 @@ function renderWinHome() { ); } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe("WinHome state machine", () => { beforeEach(() => { localStorage.setItem("cam.lang", "zh-CN"); + sessionStorage.clear(); + api.armDestructive.mockResolvedValue("win-op-1"); + api.getOperationCompletion.mockResolvedValue(null); api.getSettings.mockResolvedValue(settings()); api.winDefaultInstallRoot.mockResolvedValue(DEFAULT_SETTINGS.installRoot); api.winStatus.mockResolvedValue(STATUS_MANAGED); @@ -187,6 +203,7 @@ describe("WinHome state machine", () => { route: "msix-sideload", }, undefined, + "win-op-1", ), ); }); @@ -211,6 +228,417 @@ describe("WinHome state machine", () => { await waitFor(() => expect(api.winAdopt).toHaveBeenCalledTimes(1)); }); + it("keeps partial-install guidance aligned with the rendered recovery action", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate + .mockResolvedValueOnce(report({ installed: null })) + .mockResolvedValue( + report({ plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "1.0.0" } }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + message: "installed; managed record failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "write failed" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + expect(await screen.findByText(/请点「开始管理」,勿重复安装/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "开始管理" })).toBeInTheDocument(); + expect(screen.getByText("已安装 Codex", { selector: ".rb-title" })).toBeInTheDocument(); + expect( + screen.queryByText("installed; managed record failed", { selector: ".rb-detail" }), + ).not.toBeInTheDocument(); + const diagnostics = screen + .getByText("installed; managed record failed", { selector: ".errdetails" }) + .closest("details"); + expect(diagnostics).not.toHaveAttribute("open"); + }); + + it("keeps English backend prose collapsed in a non-English partial-install UI", async () => { + localStorage.setItem("cam.lang", "fr"); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate.mockResolvedValueOnce(report({ installed: null })).mockResolvedValue( + report({ + plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "1.0.0" }, + }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + message: "installed; managed record failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "write failed" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /Installer Codex/ })); + + expect(screen.getByText("Codex installé", { selector: ".rb-title" })).toBeInTheDocument(); + expect( + screen.queryByText("installed; managed record failed", { selector: ".rb-detail" }), + ).not.toBeInTheDocument(); + const diagnostics = screen + .getByText("installed; managed record failed", { selector: ".errdetails" }) + .closest("details"); + expect(diagnostics).not.toHaveAttribute("open"); + }); + + it("does not let a pre-update managed snapshot clear a new partial-outcome guard", async () => { + api.getSettings.mockResolvedValue(settings({ askBefore: false })); + api.winStatus + .mockResolvedValueOnce(STATUS_MANAGED) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate + .mockResolvedValueOnce(report()) + .mockResolvedValue( + report({ plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "2.0.0" } }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + message: "updated; managed record failed", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "present", + installClass: "external", + provenance: { state: "failed", detail: "write failed" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /立即更新/ })); + + expect(await screen.findByText(/请点「开始管理」,勿重复安装/)).toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("win-op-1"); + }); + + it("requires re-detection instead of offering reinstall when a partial install is not found", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockResolvedValue({ installed: INSTALLED, status: "external" }); + api.winPlanUpdate + .mockResolvedValueOnce(report({ installed: null })) + .mockResolvedValueOnce(report({ installed: null })) + .mockResolvedValue( + report({ plan: { ...PLAN_UPDATE, upToDate: true, latestVersion: "1.0.0" } }), + ); + api.winPerformUpdate.mockResolvedValue({ + ...PERFORM_OK, + installed: null, + message: "installed but not detected for managed record", + outcome: emptyOperationOutcome({ + primaryOk: true, + appState: "unknown", + installClass: null, + provenance: { state: "failed", detail: "install not detected" }, + recoveryActions: ["record_provenance"], + }), + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toHaveTextContent( + "请点「重新检查」重新检测;确认前请勿重复安装", + ); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "重新检查" })); + + expect(await screen.findByText(/请点「开始管理」,勿重复安装/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "开始管理" })).toBeInTheDocument(); + }); + + it("keeps the partial-install recovery lock across a renderer reload", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.winPerformUpdate.mockReturnValue(new Promise(() => {})); + + const user = userEvent.setup(); + const firstRenderer = renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalledTimes(1)); + + firstRenderer.unmount(); + renderWinHome(); + + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "重新检查" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + }); + + it("keeps reinstall blocked when invoke rejects after commit with an unknown outcome", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "finishing", + state: "outcome-unknown", + }); + api.winPerformUpdate.mockRejectedValue({ + code: "internal_error", + message: "install-root settings save failed after install", + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("win-op-1"); + }); + + it("releases the guard after a backend-proven pre-commit failure and allows retry", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + }); + api.winPerformUpdate + .mockRejectedValueOnce({ code: "network_error", message: "download failed" }) + .mockResolvedValueOnce(PERFORM_OK); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + const retry = await screen.findByRole("button", { name: /安装 Codex/ }); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toBeNull(); + await user.click(retry); + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalledTimes(2)); + }); + + it("releases the guard after a backend-proven rollback and allows retry", async () => { + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "finishing", + state: "rolled-back", + }); + api.winPerformUpdate + .mockRejectedValueOnce({ + code: "internal_error", + message: "portable health check failed; absent state restored", + }) + .mockResolvedValueOnce(PERFORM_OK); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + + const retry = await screen.findByRole("button", { name: /安装 Codex/ }); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toBeNull(); + await user.click(retry); + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalledTimes(2)); + }); + + it("releases a reloaded guard after backend proves the install failed before commit", async () => { + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "failed-op" }), + ); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus.mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockImplementation((token) => + Promise.resolve( + token === "failed-op" + ? { + id: "failed-op", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + } + : null, + ), + ); + api.armDestructive.mockResolvedValue("retry-op"); + + const user = userEvent.setup(); + renderWinHome(); + const install = await screen.findByRole("button", { name: /安装 Codex/ }); + await user.click(install); + + await waitFor(() => expect(api.winPerformUpdate).toHaveBeenCalled()); + }); + + it("does not let an old reconciliation clear a newer token", async () => { + const oldCompletion = deferred(); + const newCompletion = deferred(); + const oldStatusProbe = deferred(); + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "old-op" }), + ); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockReturnValueOnce(oldStatusProbe.promise) + .mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockImplementation((token) => + token === "old-op" ? oldCompletion.promise : newCompletion.promise, + ); + + renderWinHome(); + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("old-op")); + + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "new-op" }), + ); + await act(async () => { + oldCompletion.resolve({ + id: "old-op", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + }); + await oldCompletion.promise; + }); + + // Clearing old-op adopts the replacement marker into React state. Keep its + // reconciliation pending while the old status probe and old finally settle: + // neither is allowed to release the newer generation's busy state. + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("new-op")); + await act(async () => { + oldStatusProbe.resolve({ installed: null, status: "none" }); + await oldStatusProbe.promise; + }); + + await waitFor(() => + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("new-op"), + ); + expect(screen.getByText("正在检查…", { selector: ".headline" })).toBeInTheDocument(); + + await act(async () => { + newCompletion.resolve(null); + await newCompletion.promise; + }); + expect(await screen.findByText(/暂时无法确认 Codex 是否已写入磁盘/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + }); + + it("keeps recovery busy until both status and plan probes settle", async () => { + const statusProbe = deferred(); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockReturnValueOnce(statusProbe.promise); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.getOperationCompletion.mockResolvedValue({ + id: "win-op-1", + kind: "update", + phase: "finishing", + state: "outcome-unknown", + }); + api.winPerformUpdate.mockRejectedValue({ + code: "internal_error", + message: "invoke returned after commit", + }); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + await waitFor(() => expect(api.winPlanUpdate).toHaveBeenCalledTimes(2)); + + // The plan probe has completed, but the status probe still owns this + // recovery generation. Reinstall must remain unavailable. + expect(screen.queryByRole("button", { name: /安装 Codex/ })).not.toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("win-op-1"); + + await act(async () => { + statusProbe.resolve({ installed: null, status: "none" }); + await statusProbe.promise; + }); + expect(await screen.findByRole("button", { name: "重新检查" })).toBeInTheDocument(); + }); + + it("does not let an old perform finally release a newer recovery generation", async () => { + const oldCompletion = deferred(); + const newCompletion = deferred(); + const oldStatusProbe = deferred(); + api.getSettings.mockResolvedValue(settings({ checkOnStartup: false })); + api.winStatus + .mockResolvedValueOnce({ installed: null, status: "none" }) + .mockReturnValueOnce(oldStatusProbe.promise) + .mockResolvedValue({ installed: null, status: "none" }); + api.winPlanUpdate.mockResolvedValue(report({ installed: null })); + api.winPerformUpdate.mockRejectedValue({ code: "network_error", message: "late failure" }); + api.getOperationCompletion.mockImplementation((token) => + token === "win-op-1" ? oldCompletion.promise : newCompletion.promise, + ); + + const user = userEvent.setup(); + renderWinHome(); + await user.click(await screen.findByRole("button", { name: /安装 Codex/ })); + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("win-op-1")); + + sessionStorage.setItem( + "cam.win.provenance-recovery", + JSON.stringify({ state: "unknown", token: "new-op" }), + ); + await act(async () => { + oldCompletion.resolve({ + id: "win-op-1", + kind: "update", + phase: "downloading", + state: "failed-before-commit", + }); + await oldCompletion.promise; + }); + await waitFor(() => expect(api.getOperationCompletion).toHaveBeenCalledWith("new-op")); + + await act(async () => { + oldStatusProbe.resolve({ installed: null, status: "none" }); + await oldStatusProbe.promise; + }); + // The old run has now unwound through its finally. Its generation no longer + // owns busy/resetStop, so the newer reconciliation remains visibly active. + expect(screen.getByText("正在检查…", { selector: ".headline" })).toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toContain("new-op"); + + await act(async () => { + newCompletion.resolve(null); + await newCompletion.promise; + }); + expect(await screen.findByRole("button", { name: "重新检查" })).toBeInTheDocument(); + }); + it("treats a stale expectation as a notice and re-checks", async () => { const user = userEvent.setup(); api.getSettings.mockResolvedValue(settings({ askBefore: false })); @@ -226,6 +654,7 @@ describe("WinHome state machine", () => { expect( await screen.findByText("已是最新", { selector: ".headline" }), ).toBeInTheDocument(); + expect(sessionStorage.getItem("cam.win.provenance-recovery")).toBeNull(); }); it("warns when MSIX is planned but App Installer is missing, and flips to portable", async () => { diff --git a/src/app/views/WinHome.tsx b/src/app/views/WinHome.tsx index a2eb536..096d55b 100644 --- a/src/app/views/WinHome.tsx +++ b/src/app/views/WinHome.tsx @@ -12,7 +12,7 @@ import type { WinPerformReport, WinUpdateReport, } from "../../shared/types"; -import { DEFAULT_SETTINGS } from "../../shared/types"; +import { DEFAULT_SETTINGS, outcomeIsPartial } from "../../shared/types"; import { resolveFailure, userErrorMessage, type FailureSurface } from "../errorCopy"; import { Icon, CodexGlyph } from "../icons"; import { useI18n, dirOf, type TKey } from "../i18n"; @@ -32,6 +32,40 @@ import { useFocusRecheck, installIdentity } from "./useFocusRecheck"; import { useOperationReattach } from "./useOperationReattach"; type Kind = "loading" | "error" | "none" | "idle" | "update" | "external" | "uptodate"; +type Busy = "plan" | "perform" | "adopt" | "install" | "launch" | null; +type ProvenanceRecoveryState = "present" | "unknown"; +interface ProvenanceRecovery { + state: ProvenanceRecoveryState; + token: string | null; +} + +const WIN_PROVENANCE_RECOVERY_KEY = "cam.win.provenance-recovery"; + +function readStoredProvenanceRecovery(): ProvenanceRecovery | null { + try { + const value = window.sessionStorage.getItem(WIN_PROVENANCE_RECOVERY_KEY); + if (value === "present" || value === "unknown") { + return { state: value, token: null }; + } + const parsed = value ? (JSON.parse(value) as Partial) : null; + return parsed && + (parsed.state === "present" || parsed.state === "unknown") && + (typeof parsed.token === "string" || parsed.token === null) + ? { state: parsed.state, token: parsed.token } + : null; + } catch { + return null; + } +} + +function storeProvenanceRecovery(value: ProvenanceRecovery | null) { + try { + if (value) window.sessionStorage.setItem(WIN_PROVENANCE_RECOVERY_KEY, JSON.stringify(value)); + else window.sessionStorage.removeItem(WIN_PROVENANCE_RECOVERY_KEY); + } catch { + // The in-memory guard still protects this renderer when storage is unavailable. + } +} // Windows counterpart of MacHome — same design system + state machine, driven by // the win_* backend (codex-win-engine): MSIX sideload or portable fallback. @@ -45,12 +79,16 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const [updatedVer, setUpdatedVer] = useState<{ from: string; to: string } | null>(null); const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [defaultInstallRoot, setDefaultInstallRoot] = useState(DEFAULT_SETTINGS.installRoot); - const [busy, setBusy] = useState<"plan" | "perform" | "adopt" | "install" | "launch" | null>( - null, - ); + const [busy, setBusy] = useState(null); const [checkError, setCheckError] = useState(null); const [actionError, setActionError] = useState(null); const [notice, setNotice] = useState(null); + // A successful install whose managed record could not be written stays in a + // guarded recovery mode until adoption succeeds. Persist the marker for the + // lifetime of this app window so a renderer reload cannot expose reinstall. + const [provenanceRecovery, setProvenanceRecovery] = + useState(readStoredProvenanceRecovery); + const provenanceRecoveryPending = provenanceRecovery !== null; const [confirmOpen, setConfirmOpen] = useState(false); const [installDirOpen, setInstallDirOpen] = useState(false); const [installDirBusy, setInstallDirBusy] = useState(false); @@ -69,6 +107,39 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { // Synchronous guard for launch double-clicks (state alone can miss a second // click before setBusy("launch") re-renders). const launchInFlightRef = useRef(false); + // A recovery token created by this renderer is reconciled by runPerform + // itself. Only a replacement renderer should run the mount-time recovery + // probe; otherwise setting the marker would immediately duplicate the + // command's own final status/plan probes. + const locallyStartedOperationRef = useRef(null); + // Every async owner of the main action/progress surface gets a monotonically + // increasing generation. A late completion may update neither busy nor + // transfer state after a newer operation has taken ownership. + const operationGenerationRef = useRef(0); + const busyRef = useRef(null); + const reattachGenerationRef = useRef(null); + const reattachEndedAsOwnerRef = useRef(false); + const beginOperation = useCallback((next: Exclude) => { + const generation = operationGenerationRef.current + 1; + operationGenerationRef.current = generation; + busyRef.current = next; + setBusy(next); + return generation; + }, []); + const ownsOperation = useCallback( + (generation: number) => operationGenerationRef.current === generation, + [], + ); + const setOwnedBusy = useCallback((generation: number, next: Busy) => { + if (operationGenerationRef.current !== generation) return false; + busyRef.current = next; + setBusy(next); + return true; + }, []); + const finishOperation = useCallback( + (generation: number) => setOwnedBusy(generation, null), + [setOwnedBusy], + ); const confirmTitleId = useId(); const confirmBodyId = useId(); const installDirTitleId = useId(); @@ -99,34 +170,122 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { onError: setActionError, }); - const check = useCallback(async () => { - setBusy("plan"); + const runCheck = useCallback(async (generation: number) => { + if (!ownsOperation(generation)) return false; setCheckError(null); setActionError(null); setNotice(null); try { - setReport(await managerApi.winPlanUpdate()); + const next = await managerApi.winPlanUpdate(); + if (!ownsOperation(generation)) return false; + setReport(next); return true; } catch (cause) { + if (!ownsOperation(generation)) return false; setReport(null); setCheckError(resolveFailure(cause, t)); return false; + } + }, [ownsOperation, t]); + + const check = useCallback(async () => { + if (busyRef.current !== null) return false; + const generation = beginOperation("plan"); + try { + return await runCheck(generation); } finally { - setBusy(null); + finishOperation(generation); } - }, [t]); + }, [beginOperation, finishOperation, runCheck]); - const refreshStatus = useCallback(async () => { + const refreshStatus = useCallback(async (generation?: number) => { + const canApply = () => generation === undefined || ownsOperation(generation); try { - setStatus(await managerApi.winStatus()); - setStatusFailed(false); + const next = await managerApi.winStatus(); + if (canApply()) { + setStatus(next); + setStatusFailed(false); + } + return next; } catch { - setStatusFailed(true); + if (canApply()) setStatusFailed(true); + return null; } finally { - setStatusLoaded(true); + if (canApply()) setStatusLoaded(true); } + }, [ownsOperation]); + + const clearProvenanceRecovery = useCallback((expectedToken: string | null) => { + // Storage is the cross-renderer authority. Re-read it at clear time so an + // old reconciliation can never remove a marker written by a newer run. + const stored = readStoredProvenanceRecovery(); + const storedMatches = stored?.token === expectedToken; + if (storedMatches) storeProvenanceRecovery(null); + if (locallyStartedOperationRef.current === expectedToken) { + locallyStartedOperationRef.current = null; + } + setProvenanceRecovery((current) => { + if (current?.token !== expectedToken) return current; + // If another renderer/run replaced storage while this probe was pending, + // adopt that newer marker into memory instead of clearing the guard. + return stored && !storedMatches ? stored : null; + }); }, []); + const reconcileProvenanceRecovery = useCallback( + async (token: string, ownerGeneration?: number) => { + const managesBusy = ownerGeneration === undefined; + const generation = ownerGeneration ?? beginOperation("plan"); + try { + const returnedCompletion = await managerApi.getOperationCompletion(token).catch(() => null); + const completion = returnedCompletion?.id === token ? returnedCompletion : null; + if (!ownsOperation(generation)) return completion; + if ( + completion?.state === "failed-before-commit" || + completion?.state === "rolled-back" + ) { + clearProvenanceRecovery(token); + } + // A succeeded or mutation-ambiguous rejected command still needs disk truth: + // managed clears the guard, external offers adoption, and unknown/none + // stays guarded so reinstall is never inferred from an invoke failure. + // Neither probe owns busy independently: reconciliation keeps the same + // generation until BOTH have settled. + const [nextStatus] = await Promise.all([ + refreshStatus(generation), + runCheck(generation), + ]); + if ( + ownsOperation(generation) && + nextStatus?.installed && + nextStatus.status === "managed" + ) { + clearProvenanceRecovery(token); + } + return completion; + } finally { + if (managesBusy) finishOperation(generation); + } + }, + [ + beginOperation, + clearProvenanceRecovery, + finishOperation, + ownsOperation, + refreshStatus, + runCheck, + ], + ); + + const refreshStatusAndPlan = useCallback(async () => { + const generation = beginOperation("plan"); + try { + return await Promise.all([refreshStatus(generation), runCheck(generation)]); + } finally { + finishOperation(generation); + } + }, [beginOperation, finishOperation, refreshStatus, runCheck]); + useEffect(() => { void (async () => { const s = await managerApi.getSettings().catch(() => DEFAULT_SETTINGS); @@ -161,10 +320,6 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { useEffect(() => { reportRef.current = report; }, [report]); - const busyRef = useRef(busy); - useEffect(() => { - busyRef.current = busy; - }, [busy]); const checkRef = useRef(check); useEffect(() => { checkRef.current = check; @@ -174,13 +329,45 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { // by operation id, and poll until the backend lease ends. useOperationReattach({ startDlListen, - applySnapshotProgress, - resetStop, - setBusy: (next) => setBusy(next), - setPaused, + applySnapshotProgress: (progress) => { + const generation = reattachGenerationRef.current; + if (generation !== null && ownsOperation(generation)) { + applySnapshotProgress(progress); + } + }, + resetStop: () => { + const generation = reattachGenerationRef.current; + if (generation !== null && ownsOperation(generation)) resetStop(); + }, + setBusy: (next) => { + const generation = reattachGenerationRef.current; + if (next === null) { + reattachEndedAsOwnerRef.current = + generation !== null && finishOperation(generation); + reattachGenerationRef.current = null; + return; + } + if (generation === null) { + reattachGenerationRef.current = beginOperation(next); + } else { + setOwnedBusy(generation, next); + } + }, + setPaused: (next) => { + const generation = reattachGenerationRef.current; + if ( + (generation !== null && ownsOperation(generation)) || + (generation === null && reattachEndedAsOwnerRef.current) + ) { + setPaused(next); + } + }, onOperationEnded: () => { - void checkRef.current(); - void refreshStatus(); + if (!reattachEndedAsOwnerRef.current) return; + reattachEndedAsOwnerRef.current = false; + const token = readStoredProvenanceRecovery()?.token; + if (token) void reconcileProvenanceRecovery(token); + else void refreshStatusAndPlan(); }, isLocallyBusy: () => { const b = busyRef.current; @@ -188,6 +375,16 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { }, }); + useEffect(() => { + const token = provenanceRecovery?.token; + if (!token || locallyStartedOperationRef.current === token) return; + void (async () => { + const active = await managerApi.getOperationSnapshot().catch(() => null); + if (active?.id === token) return; + await reconcileProvenanceRecovery(token); + })(); + }, [provenanceRecovery?.token, reconcileProvenanceRecovery]); + // Window focus → silently re-detect the local install and re-check if the // install identity (version OR path) drifted out-of-band. Parity with the mac // home, which has had this since the atomic-snapshot rework; without it the @@ -230,18 +427,30 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { }, [settings.periodicCheck, settings.periodicCheckIntervalSeconds]); const adopt = useCallback(async () => { - setBusy("adopt"); + const generation = beginOperation("adopt"); + const recoveryToken = readStoredProvenanceRecovery()?.token ?? provenanceRecovery?.token ?? null; setCheckError(null); setActionError(null); setNotice(null); try { - setStatus(await managerApi.winAdopt()); + const next = await managerApi.winAdopt(); + if (ownsOperation(generation)) { + setStatus(next); + clearProvenanceRecovery(recoveryToken); + } } catch (cause) { - setActionError(resolveFailure(cause, t)); + if (ownsOperation(generation)) setActionError(resolveFailure(cause, t)); } finally { - setBusy(null); + finishOperation(generation); } - }, [t]); + }, [ + beginOperation, + clearProvenanceRecovery, + finishOperation, + ownsOperation, + provenanceRecovery?.token, + t, + ]); // The probe recommended MSIX, but this PC looks like it's missing the Store / // App Installer components — the MSIX can install yet fail to launch (the very @@ -264,7 +473,14 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { // MSIX sideload or portable fallback — is decided by the backend plan). const runPerform = useCallback( async (mode: "perform" | "install", installRoot?: string) => { - setBusy(mode); + // React state may not have committed between two discrete clicks yet; + // the synchronous ref closes that double-start window. + if (busyRef.current !== null) return; + const generation = beginOperation(mode); + // The new owner starts from a clean transfer state. Any older finally is + // generation-guarded and therefore cannot erase progress emitted after + // this point. + resetStop(); setActionError(null); setNotice(null); setPaused(null); @@ -275,8 +491,15 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const fromVersion = mode === "perform" ? report?.installed?.version ?? status?.installed?.version ?? "" : ""; const toVersion = report?.plan?.latestVersion ?? ""; - const unlisten = await startDlListen(); + let unlisten = () => {}; + let operationToken: string | null = null; try { + const attachedUnlisten = await startDlListen(); + if (!ownsOperation(generation)) { + attachedUnlisten(); + return; + } + unlisten = attachedUnlisten; const expected = report?.plan ? { currentVersion: report.plan.currentVersion, @@ -285,26 +508,51 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { route: report.plan.route, } : undefined; - const result = await managerApi.winPerformUpdate(true, expected, installRoot); - setPerform(result); - setUpdatedVer( - mode === "perform" && fromVersion && toVersion - ? { from: fromVersion, to: toVersion } - : null, + operationToken = await managerApi.armDestructive("update"); + locallyStartedOperationRef.current = operationToken; + const armedRecovery: ProvenanceRecovery = { state: "unknown", token: operationToken }; + storeProvenanceRecovery(armedRecovery); + setProvenanceRecovery(armedRecovery); + const result = await managerApi.winPerformUpdate( + true, + expected, + installRoot, + operationToken, ); - setConfirmOpen(false); - setInstallDirOpen(false); // Partial success (app installed, provenance failed): keep success path // and surface a recovery notice — never treat as hard failure. - if ( + const needsProvenanceRecovery = result.success && - result.outcome?.recoveryActions?.includes("record_provenance") - ) { - setNotice(t("install.partial.note")); + result.outcome?.recoveryActions?.includes("record_provenance"); + if (ownsOperation(generation)) { + setPerform(result); + setUpdatedVer( + mode === "perform" && fromVersion && toVersion + ? { from: fromVersion, to: toVersion } + : null, + ); + setConfirmOpen(false); + setInstallDirOpen(false); + } + if (needsProvenanceRecovery && ownsOperation(generation)) { + // Guard the action area before either probe can settle; there must be + // no render where a completed-but-unrecorded install offers reinstall. + const recoveryState: ProvenanceRecoveryState = + result.outcome?.appState === "present" || result.installed ? "present" : "unknown"; + const recovery = { state: recoveryState, token: operationToken }; + storeProvenanceRecovery(recovery); + setProvenanceRecovery(recovery); + } else if (!needsProvenanceRecovery) { + clearProvenanceRecovery(operationToken); } - await refreshStatus(); - await check(); + await refreshStatus(generation); + await runCheck(generation); } catch (cause) { + const code = errorCode(cause); + const explicitlyPreMutation = code === "stale_expectation" || isDownloadCancelled(cause); + if (operationToken && explicitlyPreMutation) clearProvenanceRecovery(operationToken); + else if (operationToken) await reconcileProvenanceRecovery(operationToken, generation); + if (!ownsOperation(generation)) return; setConfirmOpen(false); setInstallDirOpen(false); const stop = downloadStopRef.current; @@ -314,9 +562,9 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { setPaused({ kind: mode, dl: dlRef.current, installRoot }); } else if (stop && isDownloadCancelled(cause)) { setNotice(t("progress.cancelled")); - } else if (errorCode(cause) === "stale_expectation") { - await refreshStatus(); - if (await check()) { + } else if (code === "stale_expectation") { + await refreshStatus(generation); + if (await runCheck(generation)) { setNotice(t("home.stale.rechecked")); } } else { @@ -324,11 +572,25 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { } } finally { unlisten(); - setBusy(null); - resetStop(); + if (finishOperation(generation)) resetStop(); } }, - [status, report, refreshStatus, check, startDlListen, resetStop, dlRef, downloadStopRef, t], + [ + status, + report, + refreshStatus, + runCheck, + startDlListen, + resetStop, + dlRef, + downloadStopRef, + clearProvenanceRecovery, + reconcileProvenanceRecovery, + beginOperation, + finishOperation, + ownsOperation, + t, + ], ); // 〔继续〕from the paused state — re-run the same operation (same install @@ -363,20 +625,28 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { if (report?.plan?.route === "msix-sideload") { return false; } - setBusy("plan"); + const generation = beginOperation("plan"); setCheckError(null); setActionError(null); try { const next = await managerApi.winPlanUpdate(); + if (!ownsOperation(generation)) return null; setReport(next); return next.plan?.route === "portable-fallback"; } catch (cause) { - setCheckError(resolveFailure(cause, t)); + if (ownsOperation(generation)) setCheckError(resolveFailure(cause, t)); return null; } finally { - setBusy(null); + finishOperation(generation); } - }, [report?.plan?.route, settings.windowsInstallMode, t]); + }, [ + beginOperation, + finishOperation, + ownsOperation, + report?.plan?.route, + settings.windowsInstallMode, + t, + ]); const requestInstall = useCallback(async () => { const needsLocation = await freshInstallNeedsLocation(); @@ -390,6 +660,16 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { await runPerform("install"); }, [freshInstallNeedsLocation, runPerform]); + const recheckProvenanceRecovery = useCallback(async () => { + const token = provenanceRecovery?.token; + if (token) await reconcileProvenanceRecovery(token); + else await refreshStatusAndPlan(); + }, [ + provenanceRecovery?.token, + reconcileProvenanceRecovery, + refreshStatusAndPlan, + ]); + const installToCurrentRoot = useCallback(async () => { await runPerform("install", settings.installRoot); }, [runPerform, settings.installRoot]); @@ -424,6 +704,14 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { installed.version === status.installed.version, ); const isManaged = statusMatchesInstalled && status?.status === "managed"; + useEffect(() => { + // Token-bearing guards are released only by reconcile's fresh backend + // status. The currently rendered managed snapshot may predate an in-flight + // update and is not authoritative for that token. This effect exists only + // for legacy token-less markers written by older renderers. + if (!provenanceRecovery || provenanceRecovery.token !== null || !isManaged) return; + clearProvenanceRecovery(null); + }, [clearProvenanceRecovery, isManaged, provenanceRecovery]); const skippedCandidate = useMemo(() => winSkippedUpdateCandidate(plan), [plan]); const updateSuppressed = skippedUpdateMatches(settings.skippedCodexUpdate, skippedCandidate); const updateAvailable = Boolean(plan) && !plan?.upToDate && !updateSuppressed; @@ -467,6 +755,9 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const version = installed?.version || plan?.latestVersion || ""; const sourceLabel = t(`source.${settings.source}` as TKey); const installRootIsDefault = samePath(settings.installRoot, defaultInstallRoot); + const provenanceRecoveryAction = t( + kind === "external" ? "home.external.cta" : "home.recheck", + ); // A re-check (or the first auto-check) while an app is already known: the hero // morphs to the checking state so the status visibly reacts, then settles back. @@ -514,6 +805,7 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const adoptManualExisting = useCallback(async () => { if (!manualExistingCandidate) return; + const recoveryToken = readStoredProvenanceRecovery()?.token ?? provenanceRecovery?.token ?? null; setManualExistingBusy("adopt"); setManualExistingError(null); try { @@ -521,6 +813,7 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { setStatus(next); setStatusLoaded(true); setStatusFailed(false); + clearProvenanceRecovery(recoveryToken); setManualExistingOpen(false); setManualExistingCandidate(null); await check(); @@ -529,7 +822,13 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { } finally { setManualExistingBusy(null); } - }, [check, manualExistingCandidate, t]); + }, [ + check, + clearProvenanceRecovery, + manualExistingCandidate, + provenanceRecovery?.token, + t, + ]); const skipCurrentUpdate = useCallback(async () => { if (!skippedCandidate) return; @@ -546,18 +845,20 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { } }, [settings, skippedCandidate, t]); const onLaunch = () => { - if (busy !== null || launchInFlightRef.current) return; + if (busyRef.current !== null || launchInFlightRef.current) return; // Surface a failed open (PowerShell/AUMID or portable-exe error) via the // error banner like every other action, not an unhandled rejection. launchInFlightRef.current = true; setActionError(null); - setBusy("launch"); + const generation = beginOperation("launch"); void managerApi .winLaunch() - .catch((cause) => setActionError(resolveFailure(cause, t))) + .catch((cause) => { + if (ownsOperation(generation)) setActionError(resolveFailure(cause, t)); + }) .finally(() => { launchInFlightRef.current = false; - setBusy(null); + finishOperation(generation); }); }; const launching = busy === "launch"; @@ -589,12 +890,23 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { const success = !rechecking && kind === "uptodate"; // A Windows install/update is "clean" only when it actually changed something // without a detour — not a stale-plan no-op (stage.upToDate) and not an - // MSIX→portable fallback. Non-clean successes keep the backend's explanation - // (message + notes) and stay pinned; only clean ones self-dismiss. + // MSIX→portable fallback. Non-clean successes stay pinned; only clean ones + // self-dismiss. A partial outcome's backend prose is diagnostic evidence, not + // localized safety guidance, so it lives behind the disclosure below. + const winPartial = Boolean(perform && outcomeIsPartial(perform.outcome)); const winClean = - Boolean(perform?.success) && !perform?.stage?.upToDate && !perform?.fallbackAttempted; + Boolean(perform?.success) && + !perform?.stage?.upToDate && + !perform?.fallbackAttempted && + !winPartial; const winResultDetail = - perform && !winClean ? perform.notes.filter(Boolean).join(" · ") || undefined : undefined; + perform && !winClean && !winPartial + ? perform.notes.filter(Boolean).join(" · ") || undefined + : undefined; + const winResultDiagnostics = + perform && winPartial + ? [perform.message, ...perform.notes].filter(Boolean).join("\n") || undefined + : undefined; // Char-split only LTR scripts — splitting cursive RTL (Arabic) breaks joining. const splitHeadline = !isShimmer && dirOf(lang) === "ltr"; useHomeMotion(scopeRef, scene, { splitHeadline, success }); @@ -641,25 +953,44 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { inert={confirmOpen || installDirOpen || manualExistingOpen ? true : undefined} > {perform ? ( - { - setPerform(null); - setUpdatedVer(null); - }} - /> + <> + { + setPerform(null); + setUpdatedVer(null); + }} + /> + {winResultDiagnostics ? ( +
+ {t("home.error.details")} +
{winResultDiagnostics}
+
+ ) : null} + + ) : null} + {provenanceRecoveryPending ? ( + + {t( + provenanceRecovery?.state === "unknown" && kind !== "external" + ? "install.partial.pending" + : "install.partial.note", + { action: provenanceRecoveryAction }, + )} + ) : null} {notice ? {notice} : null} {actionError ? : null} @@ -789,7 +1120,13 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { ) : null} -
+
{/* While a check runs we keep a STABLE pair of buttons so nothing reflows under the hero. */} {rechecking ? ( @@ -801,7 +1138,17 @@ export function WinHome({ onOpenSettings }: { onOpenSettings: () => void }) { ) : null} - {!rechecking && kind === "update" ? ( + {!rechecking && provenanceRecoveryPending && kind !== "external" ? ( + + ) : null} + {!rechecking && !provenanceRecoveryPending && kind === "update" ? ( <> ) : null} - {!rechecking && kind === "idle" ? ( + {!rechecking && !provenanceRecoveryPending && kind === "idle" ? ( <> {launchButton("primary")} ) : null} - {!rechecking && kind === "uptodate" ? ( + {!rechecking && !provenanceRecoveryPending && kind === "uptodate" ? ( <> {launchButton("primary")}