From 9b82ebcb0f26a5255d87444b28b689d135750184 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 08:50:52 -0300 Subject: [PATCH] fix: silence the quit warning on update relaunch; activate on app-level notification click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in yesterday's crash-recovery work, surfaced by today's correctness audit (docs/audits/codebase-audit-2026-08-20.md, H4 and M1): - persistSessionForUpdateRelaunch never set isQuitWarningConfirmed, so a default-config user got the modal quit warning in the middle of a silent update relaunch — and a Sparkle force-kill past the stalled dialog left cleanShutdown=false, firing the crash-recovery notice as a false positive on the next launch. Installing the update IS the quit consent; record it. - handleNotificationResponse's tabId guard was a bare return, so clicking the crash-recovery notice (which carries no tab routing) did nothing beyond the OS's default activation — and action-button clicks did nothing at all. The no-tabId branch now activates the app explicitly. --- Sources/AppDelegate.swift | 13 +++++++++++++ Sources/TerminalNotificationStore.swift | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 287c9829..469ec5fa 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1602,6 +1602,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser func persistSessionForUpdateRelaunch() { isTerminatingApp = true + // The user already consented to this termination by choosing to install + // the update — without this, applicationShouldTerminate shows the modal + // "Quit Programa?" warning in the middle of the update relaunch for + // default-config users, and if Sparkle force-kills past its timeout the + // stale cleanShutdown=false snapshot fires the crash-recovery notice as + // a false positive on the next launch (audit 2026-08-20, H4). + isQuitWarningConfirmed = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } @@ -8683,6 +8690,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private func handleNotificationResponse(_ response: UNNotificationResponse) { guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, let tabId = UUID(uuidString: tabIdString) else { + // App-level notification (crash-recovery notice, no tab routing): + // bring the app forward explicitly. macOS activates on a default + // click, but a bare return here left action-button clicks and + // already-active-but-windowless states doing nothing (audit + // 2026-08-20, M1). + NSApp.activate(ignoringOtherApps: true) return } let surfaceId: UUID? = { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index d626475a..0ccdecdb 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -584,8 +584,8 @@ final class TerminalNotificationStore: ObservableObject { /// App-level (not tab-scoped) system notification — crash-recovery notice and /// similar app-lifecycle events. Uses the same authorization flow as terminal - /// notifications but carries no tab routing: the response handler's tabId guard - /// makes activation a plain app-activate, never a tab jump. + /// notifications but carries no tab routing: the response handler's no-tabId + /// branch activates the app explicitly and never jumps to a tab. func postAppNotification(title: String, body: String) { ensureAuthorization(origin: .notificationDelivery) { [weak self] authorized in guard let self, authorized else { return }