fix(desktop): stop a permission prompt quitting the app during onboarding
Summary
A fresh install could not finish onboarding. Granting the microphone ended the process — with no crash report, because nothing crashed. The app quit itself.
Root cause
requestMicrophonePermission calls ShellSummon.suspendForPermissionPrompt(), which orders the shell out so macOS can present the TCC dialog. That leaves the app with no windows, AppKit fires applicationShouldTerminateAfterLastWindowClosed, and the handler returned !hasCompletedOnboarding — true during onboarding — so the app terminated at the instant the user was granting the permission onboarding had just asked for.
restoreAfterPermissionPrompt() in the request's completion handler never ran, because the process was already gone.
The log stopped mid-flow, identically, every time:
Requesting microphone permission, current status: 0
AppDelegate: Last onboarding window closed — terminating instead of keeping a background menu bar process
ResourceMonitor: [app_terminating]
No signal, no .ips report, nothing in the system log — which is why it reads as a crash and isn't one.
Why the fix is at the boundary
Eight call sites across five files suspend the shell for a permission prompt — ScreenCaptureService (2), ChatToolExecutor (3), AppState+Permissions, AppState+SystemActions (2). Every one reaches this handler, so a guard per call site would be seven more chances to miss one. One guard where the decision is made covers all of them.
The decision is now a pure function. The delegate hook it serves only runs inside a live NSApplication during a system permission prompt, which no hermetic test can stand up; the policy can be proven without AppKit.
Behaviour outside a prompt is unchanged. A window the user actually closed before onboarding completes still quits, because there is no menu-bar residency to fall back on yet. That case has its own test so the guard can't quietly widen.
Verification
swift test --filter ShellSummonTests -> 29 passed (26 existing + 3 new)
swift build -> clean
Guard proven by removing it. With the isSuspendedForPermissionPrompt check deleted, testPermissionPromptDoesNotEndOnboarding fails as intended — a guard that has never failed isn't a guard.
Exercised end to end on a fresh named bundle with no existing TCC grant, which is the state that reproduces the bug:
09:45:54.377 Requesting microphone permission, current status: 0
09:45:54.407 Last window closed for a permission prompt — staying alive to receive the answer
09:45:56.252 Microphone permission request completed, granted: true
09:45:56.253 Microphone permission granted
Before the fix, the log ended at the first of those lines. The guard fired again for the later prompts in the same session, so the other suspend callers are covered by the same boundary.
Full desktop suite: 4 failures, all pre-existing. Re-ran each with this diff stashed; they reproduce identically on main:
DesktopAutomationSecondaryActionTests.testSecondarySnapshotActionsAreRegisteredFloatingBarNotificationPreviewPolicyTests.testDirectorDeliveryWithDisabledCategoryToggleIsSuppressedAtTheEntryPointPermissionsPagePresentationTests.testSettingsListOrderPlacesPermissionsWithAccessSettingsRewindCaptureExclusionGenerationTests.testOwnerSnapshotStaysCurrentWhenAuthLeadsUnresolvedRewindDatabase
This change adds no new failures.
Product invariants affected
- INV-AUTH-1
Cited because OmiApp.swift is a path the invariant governs. Nothing here touches session
ownership: the change is confined to applicationShouldTerminateAfterLastWindowClosed, which
decides whether losing the last window ends the process. It reads no auth state and reaches no
credential path — the only inputs are the onboarding-complete flag and whether the shell was
ordered out for a permission prompt. AuthSessionCoordinator remains the sole owner of session
death; this change means the process is still alive to hear the permission answer.
Failure class (fixes)
Failure-Class: none
Line-Count-Exception: desktop/macos/Desktop/Sources/OmiApp.swift | 1620 -> 1625 | The terminate handler now consults the shared policy and logs the permission-prompt case distinctly from the user-closed-window case, so the two reasons for losing the last window are separable in a log.