diff --git a/crates/codex-mac-engine/src/swap.rs b/crates/codex-mac-engine/src/swap.rs index 72765ed..c3642b7 100644 --- a/crates/codex-mac-engine/src/swap.rs +++ b/crates/codex-mac-engine/src/swap.rs @@ -27,6 +27,13 @@ pub fn codex_running() -> bool { /// Ask Codex to quit gracefully (AppleScript), polling up to `timeout_secs`. /// Never force-kills. +/// +/// Codex may answer the quit event with its own in-app confirmation dialog +/// instead of quitting (e.g. "Quit Codex? Enabled automations won't run…" +/// when automations are enabled). When Codex isn't frontmost that dialog sits +/// on a window the user never sees, so the quit silently stalls. After a short +/// grace period we therefore `activate` Codex — bringing the pending dialog +/// frontmost so the user can answer it — and keep waiting until the timeout. pub fn quit_codex(timeout_secs: u64) -> Result<(), EngineError> { if !codex_running() { return Ok(()); @@ -35,14 +42,26 @@ pub fn quit_codex(timeout_secs: u64) -> Result<(), EngineError> { .args(["-e", r#"tell application "Codex" to quit"#]) .status(); - for _ in 0..(timeout_secs * 4) { + // 250ms ticks; if Codex is still running after ~5s it is most likely + // waiting on its quit-confirmation dialog — surface it. + let activate_tick = 5 * 4; + for tick in 0..(timeout_secs * 4) { if !codex_running() { return Ok(()); } + if tick == activate_tick { + let _ = Command::new("osascript") + .args(["-e", r#"tell application "Codex" to activate"#]) + .status(); + } std::thread::sleep(std::time::Duration::from_millis(250)); } Err(EngineError::Io( - "Codex did not quit within the timeout (left running to protect in-flight work)".to_string(), + "Codex did not quit within the timeout — it may be waiting on its own \ + quit-confirmation dialog (e.g. \"Quit Codex?\" when automations are \ + enabled); confirm the quit in Codex and retry (left running to \ + protect in-flight work)" + .to_string(), )) } diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index 212b5f8..6dd367d 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -296,6 +296,21 @@ fn host_of(url: &str) -> String { /// No-op progress sink for downloads whose progress isn't surfaced (e.g. stage). fn no_progress(_p: DownloadProgress) {} +/// Graceful quit with a user-actionable failure message. Codex often answers +/// the quit event with its own confirmation dialog ("Quit Codex?" when +/// automations are enabled); the engine brings that dialog frontmost after a +/// grace period, and if the user still hasn't confirmed by the timeout we say +/// exactly what to click instead of a bare timeout. Never force-kills. +fn quit_codex_gracefully() -> Result<(), AppError> { + quit_codex(30).map_err(|_| { + AppError::Engine( + "Codex 未在限时内退出——它可能正在等待退出确认(如「Quit Codex?」对话框,已尝试将其带到前台)。\ + 请在 Codex 中确认退出后重试;为保护进行中的会话,不会强制结束 Codex" + .to_string(), + ) + }) +} + fn download_and_verify( url: &str, size: u64, @@ -622,7 +637,7 @@ pub fn perform_macos_update( // the swap fails after the quit, swap_in_place has restored the old // bundle in place — bring the user's app back before surfacing the error. let was_running = codex_running(); - quit_codex(30).map_err(|e| AppError::Engine(e.to_string()))?; + quit_codex_gracefully()?; if let Err(err) = swap_in_place(&install_path, &out_app, &backup) { if was_running { let _ = relaunch(&install_path); @@ -874,7 +889,7 @@ pub fn uninstall_macos(keep_codex_home: bool) -> Result = { "prov.external": "Unmanaged", "confirm.title": "Update to {version}?", - "confirm.body": "Codex will close and reopen automatically — about a minute.", + "confirm.body": "Codex will close and reopen automatically — about a minute. If Codex asks you to confirm quitting, click Quit there.", "confirm.cancel": "Cancel", "confirm.ok": "Update", @@ -359,7 +359,7 @@ const FR: Record = { "prov.external": "Non géré", "confirm.title": "Mettre à jour vers {version} ?", - "confirm.body": "Codex va se fermer puis redémarrer automatiquement — environ une minute.", + "confirm.body": "Codex va se fermer puis redémarrer automatiquement — environ une minute. Si Codex demande de confirmer la fermeture, confirmez-la dans Codex.", "confirm.cancel": "Annuler", "confirm.ok": "Mettre à jour", @@ -507,7 +507,7 @@ const ZH_TW: Record = { "prov.external": "未管理", "confirm.title": "更新至 {version}?", - "confirm.body": "更新時 Codex 會自動關閉並重新啟動,約需一分鐘。", + "confirm.body": "更新時 Codex 會自動關閉並重新啟動,約需一分鐘。若 Codex 跳出結束確認視窗,請在 Codex 中點按確認。", "confirm.cancel": "取消", "confirm.ok": "更新", @@ -665,7 +665,7 @@ const DE: Record = { "prov.external": "Nicht verwaltet", "confirm.title": "Auf {version} aktualisieren?", - "confirm.body": "Codex wird geschlossen und automatisch neu gestartet — dauert etwa eine Minute.", + "confirm.body": "Codex wird geschlossen und automatisch neu gestartet — dauert etwa eine Minute. Falls Codex eine Beenden-Bestätigung anzeigt, bestätigen Sie diese in Codex.", "confirm.cancel": "Abbrechen", "confirm.ok": "Aktualisieren", @@ -823,7 +823,7 @@ const KO: Record = { "prov.external": "미관리", "confirm.title": "{version}으로 업데이트할까요?", - "confirm.body": "업데이트 중 Codex가 종료되며, 완료 후 자동으로 재시작됩니다 — 약 1분 소요.", + "confirm.body": "업데이트 중 Codex가 종료되며, 완료 후 자동으로 재시작됩니다 — 약 1분 소요. Codex가 종료 확인 창을 표시하면 Codex에서 확인을 눌러 주세요.", "confirm.cancel": "취소", "confirm.ok": "업데이트", @@ -975,7 +975,7 @@ const JA: Record = { "prov.managed": "管理済み", "prov.external": "未管理", "confirm.title": "{version} にアップデートしますか?", - "confirm.body": "アップデート中は Codex が閉じ、完了後に自動で再起動します — 約 1 分かかります。", + "confirm.body": "アップデート中は Codex が閉じ、完了後に自動で再起動します — 約 1 分かかります。Codex が終了確認を表示した場合は、Codex 側で確認してください。", "confirm.cancel": "キャンセル", "confirm.ok": "アップデート", "close.confirm.title": "マネージャーを閉じますか?", @@ -1113,7 +1113,7 @@ const RU: Record = { "prov.managed": "Управляется", "prov.external": "Не управляется", "confirm.title": "Обновить до {version}?", - "confirm.body": "Codex закроется и перезапустится автоматически — около минуты.", + "confirm.body": "Codex закроется и перезапустится автоматически — около минуты. Если Codex запросит подтверждение выхода, подтвердите его в Codex.", "confirm.cancel": "Отмена", "confirm.ok": "Обновить", "close.confirm.title": "Закрыть менеджер?", @@ -1251,7 +1251,7 @@ const AR: Record = { "prov.managed": "مُدار", "prov.external": "غير مُدار", "confirm.title": "تحديث إلى {version}؟", - "confirm.body": "سيُغلق Codex ثم يُعاد تشغيله تلقائياً — دقيقة تقريباً.", + "confirm.body": "سيُغلق Codex ثم يُعاد تشغيله تلقائياً — دقيقة تقريباً. إذا طلب Codex تأكيد الإنهاء، فأكِّده في Codex.", "confirm.cancel": "إلغاء", "confirm.ok": "تحديث", "close.confirm.title": "إغلاق المدير؟", @@ -1389,7 +1389,7 @@ const ES: Record = { "prov.managed": "Gestionado", "prov.external": "No gestionado", "confirm.title": "¿Actualizar a {version}?", - "confirm.body": "Codex se cerrará y se reabrirá automáticamente — tarda alrededor de un minuto.", + "confirm.body": "Codex se cerrará y se reabrirá automáticamente — tarda alrededor de un minuto. Si Codex pide confirmar el cierre, confírmalo en Codex.", "confirm.cancel": "Cancelar", "confirm.ok": "Actualizar", "close.confirm.title": "¿Cerrar el gestor?", @@ -1527,7 +1527,7 @@ const PT_BR: Record = { "prov.managed": "Gerenciado", "prov.external": "Não gerenciado", "confirm.title": "Atualizar para {version}?", - "confirm.body": "O Codex vai fechar e reabrir automaticamente — cerca de um minuto.", + "confirm.body": "O Codex vai fechar e reabrir automaticamente — cerca de um minuto. Se o Codex pedir confirmação para sair, confirme no Codex.", "confirm.cancel": "Cancelar", "confirm.ok": "Atualizar", "close.confirm.title": "Fechar o gerenciador?",