diff --git a/crates/codex-win-engine/src/portable.rs b/crates/codex-win-engine/src/portable.rs
index a6cec4a..35a53d8 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
>
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")}