diff --git a/Scripts/Fixtures/test-suite-contract-ledger.tsv b/Scripts/Fixtures/test-suite-contract-ledger.tsv index ca365c336..3b0019384 100644 --- a/Scripts/Fixtures/test-suite-contract-ledger.tsv +++ b/Scripts/Fixtures/test-suite-contract-ledger.tsv @@ -2139,6 +2139,32 @@ root/RepoPromptTests.PromptMigrationRemovalTests/testLegacyDiffFormattingSection root/RepoPromptTests.PromptMigrationRemovalTests/testMissingCopyPresetSelectionFallsBackToDocumentedStandardDefault root Tests/RepoPromptTests/Prompt/PromptMigrationRemovalTests.swift RepoPromptTests.PromptMigrationRemovalTests testMissingCopyPresetSelectionFallsBackToDocumentedStandardDefault Prompt unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 6 root/RepoPromptTests.PromptMigrationRemovalTests/testPromptSectionOrderFallsBackToCurrentDefaultForMissingOrInvalidOrder root Tests/RepoPromptTests/Prompt/PromptMigrationRemovalTests.swift RepoPromptTests.PromptMigrationRemovalTests testPromptSectionOrderFallsBackToCurrentDefaultForMissingOrInvalidOrder Prompt unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 26 root/RepoPromptTests.PromptMigrationRemovalTests/testPromptSectionOrderUsesValidStoredOrderWithoutOldDefaultMigration root Tests/RepoPromptTests/Prompt/PromptMigrationRemovalTests.swift RepoPromptTests.PromptMigrationRemovalTests testPromptSectionOrderUsesValidStoredOrderWithoutOldDefaultMigration Prompt unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 11 +root/RepoPromptTests.QuitHoldDecisionTests/testEventsWhileQuittingAreIgnoredAndStaysQuitting root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testEventsWhileQuittingAreIgnoredAndStaysQuitting App hold-to-quit.decision.terminal S-001 unit_core root_swiftpm routine 1 any event in .quitting returns .ignore, state stays .quitting Terminal quitting state could be escapable after quit committed 0 test_case retain 0 decision terminal state +root/RepoPromptTests.QuitHoldDecisionTests/testFlagsChangedCommandHeldWhileHoldingIsIgnoredAndStaysHolding root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testFlagsChangedCommandHeldWhileHoldingIsIgnoredAndStaysHolding App hold-to-quit.decision.flagsnoise_ignored S-010 unit_core root_swiftpm routine 1 flagsChanged(commandDown:true) while holding returns .ignore, state .holding A spurious flags event mid-hold could cancel or quit 0 test_case retain 0 S-010 decision layer (flags noise) +root/RepoPromptTests.QuitHoldDecisionTests/testFlagsChangedCommandReleasedWhileHoldingCancelsAndReturnsToIdle root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testFlagsChangedCommandReleasedWhileHoldingCancelsAndReturnsToIdle App hold-to-quit.decision.command_release_cancel S-002 unit_core root_swiftpm routine 1 flagsChanged(commandDown:false) while holding returns .cancel and state .idle Releasing Command could fail to cancel the hold 0 test_case retain 0 S-002 decision layer (command release) +root/RepoPromptTests.QuitHoldDecisionTests/testKeyDownToggleOffReturnsIgnoreAndStaysIdle root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testKeyDownToggleOffReturnsIgnoreAndStaysIdle App hold-to-quit.decision.toggle_off_passthrough S-003 unit_core root_swiftpm routine 1 keyDown(toggleOn:false) returns .ignore and state stays .idle Gate-off keyDown could wrongly begin a hold, forcing a hold despite the user opting out 0 test_case retain 0 S-003 decision layer +root/RepoPromptTests.QuitHoldDecisionTests/testKeyDownToggleOnBeginsHoldAndEntersHolding root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testKeyDownToggleOnBeginsHoldAndEntersHolding App hold-to-quit.decision.begin_hold S-001 unit_core root_swiftpm routine 1 keyDown(toggleOn:true) returns .beginHold(threshold:1.0) and state becomes .holding First hold ⌘Q could fail to begin the hold or use the wrong threshold 0 test_case retain 0 S-001 decision layer +root/RepoPromptTests.QuitHoldDecisionTests/testKeyUpWhileHoldingCancelsAndReturnsToIdle root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testKeyUpWhileHoldingCancelsAndReturnsToIdle App hold-to-quit.decision.release_cancel S-002 unit_core root_swiftpm routine 1 keyUp while holding returns .cancel and state returns to .idle Releasing Q could fail to cancel, quitting on a short tap 0 test_case retain 0 S-002 decision layer (Q keyUp) +root/RepoPromptTests.QuitHoldDecisionTests/testNonKeyDownEventsWhileIdleAreIgnoredAndStaysIdle root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testNonKeyDownEventsWhileIdleAreIgnoredAndStaysIdle App hold-to-quit.decision.idle_noop S-002 unit_core root_swiftpm routine 1 keyUp/flagsChanged/timerElapsed while idle return .ignore, state stays .idle Idle noise could spuriously begin/cancel/quit 0 test_case retain 0 decision idle no-op +root/RepoPromptTests.QuitHoldDecisionTests/testRepeatKeyDownWhileHoldingIsIgnoredAndStaysHolding root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testRepeatKeyDownWhileHoldingIsIgnoredAndStaysHolding App hold-to-quit.decision.repeat_ignored S-010 unit_core root_swiftpm routine 1 repeat keyDown while holding returns .ignore and state stays .holding A repeat ⌘Q could reset the timer or quit early 0 test_case retain 0 S-010 decision layer +root/RepoPromptTests.SettingsJSONOnlyPersistenceTests/testWarnBeforeCmdQCodableRoundTripsAndLegacyDecodesToNil root Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift RepoPromptTests.SettingsJSONOnlyPersistenceTests testWarnBeforeCmdQCodableRoundTripsAndLegacyDecodesToNil Root hold-to-quit.settings.legacy_decode S-007 persistence_round_trip root_swiftpm routine 1 UISettings(warnBeforeCmdQ:false) round-trips; legacy JSON without the key decodes to nil Adding the field could break legacy decode or lose the value 0 test_case retain 0 S-007 backward-compatible Codable +root/RepoPromptTests.SettingsJSONOnlyPersistenceTests/testWarnBeforeCmdQDefaultsOnWithoutPersisting root Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift RepoPromptTests.SettingsJSONOnlyPersistenceTests testWarnBeforeCmdQDefaultsOnWithoutPersisting Root hold-to-quit.settings.default_on S-007 persistence_round_trip root_swiftpm routine 1 Fresh store reads warnBeforeCmdQ()==true and reading does not mutate the persisted doc Default could read off, or a no-op read could write to disk 0 test_case retain 0 S-007 default-on + no write-on-read +root/RepoPromptTests.SettingsJSONOnlyPersistenceTests/testWarnBeforeCmdQRoundTripsFalseAndTrue root Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift RepoPromptTests.SettingsJSONOnlyPersistenceTests testWarnBeforeCmdQRoundTripsFalseAndTrue Root hold-to-quit.settings.round_trip S-007 persistence_round_trip root_swiftpm routine 1 set false->reads false, set true->reads true, survives store reload The toggle could fail to persist or not round-trip 0 test_case retain 0 S-007 persistence round-trip +root/RepoPromptTests.QuitHoldDecisionTests/testFlagsChangedCommandHeldWhileArmedIsIgnoredAndStaysArmed root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testFlagsChangedCommandHeldWhileArmedIsIgnoredAndStaysArmed App hold-to-quit.decision.cmd_held_armed_ignored S-010 unit_core root_swiftpm routine 1 flagsChanged(commandDown:true) from .armed returns .ignore and state stays .armed A stray cmd-held event after arming could quit spuriously 0 test_case retain 0 armed flags-noise ignored +root/RepoPromptTests.QuitHoldDecisionTests/testFlagsChangedCommandReleasedWhileArmedQuitsAndEntersQuitting root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testFlagsChangedCommandReleasedWhileArmedQuitsAndEntersQuitting App hold-to-quit.decision.cmd_release_quit S-001 unit_core root_swiftpm routine 1 flagsChanged(commandDown:false) from .armed returns .quit and state .quitting Command release after threshold could fail to quit 0 test_case retain 0 S-001 decision layer (cmd release→quit) +root/RepoPromptTests.QuitHoldDecisionTests/testKeyUpWhileArmedQuitsAndEntersQuitting root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testKeyUpWhileArmedQuitsAndEntersQuitting App hold-to-quit.decision.release_quit S-001 unit_core root_swiftpm routine 1 keyUp from .armed returns .quit and state becomes .quitting Release after threshold could fail to quit 0 test_case retain 0 S-001 decision layer (release→quit) +root/RepoPromptTests.QuitHoldDecisionTests/testRepeatKeyDownWhileArmedIsIgnoredAndStaysArmed root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testRepeatKeyDownWhileArmedIsIgnoredAndStaysArmed App hold-to-quit.decision.repeat_armed_ignored S-010 unit_core root_swiftpm routine 1 repeat keyDown from .armed returns .ignore and state stays .armed A repeat ⌘Q after arming could quit early or re-arm 0 test_case retain 0 S-010 decision layer (armed repeat) +root/RepoPromptTests.QuitHoldDecisionTests/testTimerElapsedWhileHoldingArmsAndEntersArmed root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testTimerElapsedWhileHoldingArmsAndEntersArmed App hold-to-quit.decision.timer_arm S-001 unit_core root_swiftpm routine 1 timerElapsed from .holding returns .arm and state becomes .armed Threshold could quit immediately (must be release-gated) or fail to arm 0 test_case retain 0 S-001 decision layer (arm) +root/RepoPromptTests.QuitHoldDecisionTests/testExternalCancelWhileArmedDismissesAndReturnsToIdle root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testExternalCancelWhileArmedDismissesAndReturnsToIdle App hold-to-quit.decision.external_cancel_armed S-010 unit_core root_swiftpm routine 1 externalCancel from .armed returns .dismiss and state .idle Stale .armed could let a later tap quit 0 test_case retain 0 external cancel (armed) — stale-state fix +root/RepoPromptTests.QuitHoldDecisionTests/testExternalCancelWhileHoldingDismissesAndReturnsToIdle root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testExternalCancelWhileHoldingDismissesAndReturnsToIdle App hold-to-quit.decision.external_cancel S-002 unit_core root_swiftpm routine 1 externalCancel from .holding returns .dismiss and state .idle Owner-close/deactivate during a hold could leave state stuck 0 test_case retain 0 external cancel (holding) +root/RepoPromptTests.QuitHoldDecisionTests/testExternalCancelWhileIdleIsIgnored root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testExternalCancelWhileIdleIsIgnored App hold-to-quit.decision.external_cancel_idle S-010 unit_core root_swiftpm routine 1 externalCancel from .idle returns .ignore and stays .idle Spurious external cancel could disrupt idle 0 test_case retain 0 external cancel (idle) +root/RepoPromptTests.QuitHoldDecisionTests/testExternalCancelWhileQuittingIsIgnored root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testExternalCancelWhileQuittingIsIgnored App hold-to-quit.decision.external_cancel_quitting S-001 unit_core root_swiftpm routine 1 externalCancel from .quitting returns .ignore and stays .quitting External cancel could escape terminal quitting 0 test_case retain 0 external cancel (quitting) +root/RepoPromptTests.QuitHoldDecisionTests/testTapAfterExternalCancelFromArmedDoesNotQuit root Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift RepoPromptTests.QuitHoldDecisionTests testTapAfterExternalCancelFromArmedDoesNotQuit App hold-to-quit.decision.no_stale_quit S-010 unit_core root_swiftpm routine 1 After armed->externalCancel->idle, a tap (keyDown->keyUp) yields beginHold then cancel, never quit A tap after an aborted armed hold could quit from stale state 0 test_case retain 0 regression: stale-armed quit +root/RepoPromptTests.QuitHoldControllerTests/testExternalCancellationWhileHoldingResetsToIdle root Tests/RepoPromptTests/App/QuitHoldControllerTests.swift RepoPromptTests.QuitHoldControllerTests testExternalCancellationWhileHoldingResetsToIdle App hold-to-quit.controller.external_cancel_reset S-010 unit_core root_swiftpm routine 1 After keyDown->holding, handleExternalCancellation() -> holdState .idle Owner-close/deactivate could leave the controller in stale .holding 0 test_case retain 0 controller lifecycle seam +root/RepoPromptTests.QuitHoldControllerTests/testTapAfterExternalCancellationDoesNotQuit root Tests/RepoPromptTests/App/QuitHoldControllerTests.swift RepoPromptTests.QuitHoldControllerTests testTapAfterExternalCancellationDoesNotQuit App hold-to-quit.controller.no_stale_quit S-010 unit_core root_swiftpm routine 1 After external cancel, a quick tap (keyDown->keyUp) leaves holdState .idle, not .quitting A tap after an aborted hold could quit from stale .armed 0 test_case retain 0 controller regression: stale-armed quit +root/RepoPromptTests.QuitHoldEventFilterTests/testCmdQWithIgnorableModifiersIsSwallowedWhenToggleOn root Tests/RepoPromptTests/App/QuitHoldEventFilterTests.swift RepoPromptTests.QuitHoldEventFilterTests testCmdQWithIgnorableModifiersIsSwallowedWhenToggleOn App hold-to-quit.filter.normalize S-001 unit_core root_swiftpm routine 1 ⌘Q with capsLock/function/numericPad (and plain) + toggleOn -> swallow (normalized) An ignorable modifier (capsLock/Fn/numpad) could block the gate 0 test_case retain 0 exact-modifier normalization (table-driven) +root/RepoPromptTests.QuitHoldEventFilterTests/testCmdQWithToggleOffIsNotSwallowed root Tests/RepoPromptTests/App/QuitHoldEventFilterTests.swift RepoPromptTests.QuitHoldEventFilterTests testCmdQWithToggleOffIsNotSwallowed App hold-to-quit.filter.toggle_off_passthrough S-003 unit_core root_swiftpm routine 1 ⌘Q + toggleOn:false -> false (default menu fires) Toggle-off ⌘Q could be swallowed, blocking immediate quit 0 test_case retain 0 S-003 swallow policy +root/RepoPromptTests.QuitHoldEventFilterTests/testNonQuitGesturesAreNotSwallowed root Tests/RepoPromptTests/App/QuitHoldEventFilterTests.swift RepoPromptTests.QuitHoldEventFilterTests testNonQuitGesturesAreNotSwallowed App hold-to-quit.filter.reject_non_quit S-011 unit_core root_swiftpm routine 1 ⌘⇧/⌘⌥/⌘⌃Q, ⌘W, ⇧⌘W, Q-alone -> false (pass through) A non-quit gesture (extra modifier / wrong key) could be swallowed 0 test_case retain 0 exact-modifier + wrong-key passthrough (table-driven) root/RepoPromptTests.RecommendationProviderFilterNormalizationTests/testRecommendationProviderFilterNormalizationMatrix root Tests/RepoPromptTests/AgentMode/RecommendationProviderFilterNormalizationTests.swift RepoPromptTests.RecommendationProviderFilterNormalizationTests testRecommendationProviderFilterNormalizationMatrix AgentMode unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.000000 unreviewed retain_pending_review 0 initial census source line 6 root/RepoPromptTests.ReplayEvidenceHarnessTests/testFocusedLargeBurstRoutesImmediateWithCappedDefaultChunks root Tests/RepoPromptTests/WorkspaceContext/ReplayEvidenceHarnessTests.swift RepoPromptTests.ReplayEvidenceHarnessTests testFocusedLargeBurstRoutesImmediateWithCappedDefaultChunks WorkspaceContext unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.009000 unreviewed retain_pending_review 0 initial census source line 7 root/RepoPromptTests.ReplayEvidenceHarnessTests/testRemovedFolderBurstUsesOneBatchedDescendantLookup root Tests/RepoPromptTests/WorkspaceContext/ReplayEvidenceHarnessTests.swift RepoPromptTests.ReplayEvidenceHarnessTests testRemovedFolderBurstUsesOneBatchedDescendantLookup WorkspaceContext unreviewed unreviewed root_swiftpm routine 1 unreviewed unreviewed 0.016000 unreviewed retain_pending_review 0 initial census source line 87 diff --git a/Sources/RepoPrompt/App/AppDelegate.swift b/Sources/RepoPrompt/App/AppDelegate.swift index ab220708d..3fd9a67ff 100644 --- a/Sources/RepoPrompt/App/AppDelegate.swift +++ b/Sources/RepoPrompt/App/AppDelegate.swift @@ -20,6 +20,11 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate { private var terminationInProgress = false private let dockMenuController = DockMenuController() + /// Gates the keyboard ⌘Q quit gesture behind a hold (held by the AppDelegate + /// for the app's lifetime). Installed only in normal (non-launch-suppressed) + /// sessions so UI-test/launch-suppressed launches keep today's behavior. + private let quitHoldController = QuitHoldController() + // New global routing/settings services (kept alive by the AppDelegate) private var windowRoutingService: WindowRoutingService? private var appSettingsMCPService: AppSettingsMCPService? @@ -99,6 +104,14 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate { } } + // Install the hold-⌘Q-to-quit keyboard gate once for both DEBUG and + // release paths. Skipped for launch-suppressed / UI-test sessions so they + // keep today's ungated behavior. The gate lives entirely in the local + // NSEvent monitor; applicationShouldTerminate is unchanged. + if !launchConfiguration.suppressesNonessentialLaunchSideEffects { + quitHoldController.install() + } + #if DEBUG sparkleManager.startUpdater() if !launchConfiguration.suppressesNonessentialLaunchSideEffects { diff --git a/Sources/RepoPrompt/App/QuitHoldController.swift b/Sources/RepoPrompt/App/QuitHoldController.swift new file mode 100644 index 000000000..b476d824a --- /dev/null +++ b/Sources/RepoPrompt/App/QuitHoldController.swift @@ -0,0 +1,270 @@ +// +// QuitHoldController.swift +// RepoPrompt +// +// Gates the keyboard ⌘Q quit gesture so a single tap no longer quits the app. +// + +import AppKit + +// MARK: - Pure event filter + +/// Pure, side-effect-free policy that decides whether a keyboard `keyDown` +/// should be swallowed by the hold-⌘Q-to-quit gate. +/// +/// A `keyDown` is swallowed **iff** the toggle is on, the key is Q +/// (`kVK_ANSI_Q == 0x0C`), and the modifier flags — after normalizing to the +/// device-independent bits and removing Caps Lock / numeric pad / Fn — equal +/// *exactly* `.command`. This is the exact-modifier-match rule from +/// `docs/spec/hold-to-quit.md`: ⌘Q is still recognized with Caps Lock / Fn / +/// numeric pad active, while ⌘⇧Q, ⌘⌥Q, ⌘⌃Q are **not** the gesture and pass +/// through untouched (which also protects ⌘W / ⇧⌘W). +/// +/// This helper has no AppKit singleton or I/O coupling, so its full behavior is +/// unit-testable at the lowest faithful layer (see `QuitHoldEventFilterTests`). +enum QuitHoldEventFilter { + /// Carbon `kVK_ANSI_Q`. Hard-coded to avoid importing Carbon. + static let quitKeyCode: UInt16 = 0x0C + + /// Returns `true` when the given `keyDown` is an exact-modifier ⌘Q and the + /// toggle is on (so the controller should swallow it); otherwise `false`. + static func shouldSwallowKeyDown( + modifierFlags: NSEvent.ModifierFlags, + keyCode: UInt16, + toggleOn: Bool + ) -> Bool { + guard toggleOn, keyCode == quitKeyCode else { return false } + let normalized = modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.capsLock, .numericPad, .function]) + return normalized == .command + } +} + +// MARK: - Controller + +// App-lifecycle controller that gates the **keyboard** ⌘Q quit gesture. +// +// It installs a raw `NSEvent` **local** monitor on `.keyDown` / `.keyUp` / +// `.flagsChanged`. On a keyboard ⌘Q `keyDown` with the toggle on it swallows +// the event (returns `nil`) so the default "Quit RepoPrompt ⌘Q" menu key +// equivalent never fires; it then runs a pure `QuitHoldDecision` state machine, +// shows a non-focus-stealing overlay, and starts a hold timer. Menu / Dock / +// status / programmatic quits never generate a keyboard `keyDown` for ⌘Q, so +// they call `terminate(_:)` directly and bypass this gate entirely. +// +// The controller owns gesture orchestration only — the overlay panel, its +// linger/fade dismissal, and the owner-window-close observer live in +// `QuitHoldOverlay`. +// +// ## ⚠️ LOAD-BEARING — read before "optimizing" this controller +// +// Two constraints below are non-obvious and hard-won. Either one, if +// "simplified", silently breaks quit (the app wedges, or quits the wrong +// thing). Full diagnosis + the dead ends that were tried: +// `docs/investigations/hold-to-quit-shutdown-wedge-2026-07-01.md`. +// +// 1. `NSApp.terminate(nil)` is called SYNCHRONOUSLY from within this monitor's +// event handler (during AppKit's `sendEvent`) — see `fireTermination`. It +// must NOT be deferred to a `DispatchQueue`/`Task` callback (sync or +// `asyncAfter`): AppKit's `.terminateLater` wait then starves the Swift +// main-actor executor, the shutdown `Task { @MainActor }` never runs, and +// `reply(true)` is never called → the app wedges mid-shutdown (Quit menu +// disabled, process alive). Initiating terminate during `sendEvent` — like +// the menu and Apple-Event quit paths — is the only context in which the +// shutdown Task reliably drains. Do NOT "fix" a deferred call by pumping the +// run loop or making the shutdown synchronous; both create worse bugs. +// +// 2. Quit fires on ⌘Q RELEASE (`keyUp`), NOT at the threshold while the key is +// still held. Quitting while ⌘Q is held propagates the held key to the next +// focused app — that app's ⌘Q then fires too (confirmed by experiment; the +// at-threshold-while-holding variant was reverted for exactly this reason). +// Chrome also quits on keyUp (its window-close-at-threshold is only visual +// feedback; the actual quit waits for release). So the threshold only ARMS +// (overlay → "Release ⌘Q to Quit"); the release fires `terminate`. +// (We deliberately do NOT close windows at the threshold like Chrome: RPCE +// persists the window session on terminate, so closing windows early would +// risk restoring to an empty session and would buy nothing.) +// +// **External cancellation:** if the owner window closes or the app resigns +// active mid-hold, an `.externalCancel` event resets the decision to `idle` +// and dismisses the overlay immediately (no linger), so a later ⌘Q can't arm +// or quit from stale `.holding`/`.armed` state. +// +// Per the spec's event-swallowing policy, the controller swallows a `keyDown` +// based only on the toggle + exact-modifier match — *regardless* of the +// `QuitHoldDecision` intent — so a repeated ⌘Q `keyDown` during a hold +// (intent `.ignore`) is still swallowed (S-010). `keyUp` and `flagsChanged` +// are never swallowed. + +@MainActor +final class QuitHoldController { + /// The local `NSEvent` monitor token. `nonisolated(unsafe)` so `deinit` can + /// remove it; every read/write happens on the main thread for this + /// main-actor-owned, app-lifetime object. + private nonisolated(unsafe) var monitor: Any? + + private var decision = QuitHoldDecision() + /// Current gesture phase (inspection/test seam). + var holdState: QuitHoldDecision.State { + decision.state + } + + private var holdWorkItem: DispatchWorkItem? + /// App-lifetime observer that aborts an in-flight hold when the app loses + /// focus (the local key monitor won't see the keyUp then). + private var resignActiveObserver: NSObjectProtocol? + + private let overlay = QuitHoldOverlay() + + private let warnBeforeCmdQ: @MainActor () -> Bool + + init(warnBeforeCmdQ: @escaping @MainActor () -> Bool = { GlobalSettingsStore.shared.warnBeforeCmdQ() }) { + self.warnBeforeCmdQ = warnBeforeCmdQ + } + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + if let resignActiveObserver { + NotificationCenter.default.removeObserver(resignActiveObserver) + } + } + + // MARK: - Lifecycle + + /// Installs the keyboard ⌘Q local monitor. Safe to call once. + func install() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event -> NSEvent? in + // Local monitors run on the main thread, so bridge into the + // @MainActor controller synchronously (precedent: TooltipBubble). + MainActor.assumeIsolated { + guard let self else { return event } + return self.handle(event) + } + } + resignActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + // Runs on `.main`; abort any in-flight hold so state can't get + // stuck when the keyUp is never delivered (app no longer frontmost). + MainActor.assumeIsolated { self?.handleExternalCancellation() } + } + } + + // MARK: - Event handling + + func handle(_ event: NSEvent) -> NSEvent? { + switch event.type { + case .keyDown: + // Swallow policy is independent of the decision intent: swallow iff + // exact-modifier ⌘Q + toggle on (S-003 / S-010). + let toggleOn = warnBeforeCmdQ() + let swallow = QuitHoldEventFilter.shouldSwallowKeyDown( + modifierFlags: event.modifierFlags, + keyCode: event.keyCode, + toggleOn: toggleOn + ) + guard swallow else { return event } + apply(decision.handle(.keyDown(toggleOn: true))) + return nil + + case .keyUp: + // Only Q's keyUp is a release signal (spec "held semantics"); any + // other keyUp passes through untouched (S-002). + if event.keyCode == QuitHoldEventFilter.quitKeyCode { + apply(decision.handle(.keyUp)) + } + return event + + case .flagsChanged: + apply(decision.handle(.flagsChanged(commandDown: event.modifierFlags.contains(.command)))) + return event + + default: + return event + } + } + + /// External condition (owner window closed / app resigned active): feed + /// an `.externalCancel` so the decision resets to idle and the overlay is + /// dismissed immediately (no linger). The resign-active observer and the + /// overlay's owner-close observer call this; tests call it directly as the + /// lifecycle seam. + func handleExternalCancellation() { + apply(decision.handle(.externalCancel)) + } + + /// Translates a decision intent into side effects. + private func apply(_ intent: QuitHoldIntent) { + switch intent { + case let .beginHold(threshold): + overlay.show(message: "Hold ⌘Q to Quit") { [weak self] in + self?.handleExternalCancellation() + } + scheduleHoldTimer(threshold: threshold) + case .arm: + // Threshold reached while still holding: arm the quit and prompt + // release. Do NOT terminate here (see class doc). + overlay.update("Release ⌘Q to Quit") + case .cancel: + cancelHold() + case .quit: + fireTermination() + case .dismiss: + // External cancellation (owner window closed / app resigned active): + // state was already reset to idle by the decision; tear down the + // overlay immediately — no linger. + cancelHoldTimer() + overlay.hide() + case .ignore: + break + } + } + + private func scheduleHoldTimer(threshold: TimeInterval) { + holdWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + // DispatchQueue.main.asyncAfter runs this on the main thread. + MainActor.assumeIsolated { + guard let self else { return } + self.apply(self.decision.handle(.timerElapsed)) + } + } + holdWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + threshold, execute: work) + } + + /// Sole owner of `holdWorkItem` teardown; `overlay.hide()` owns the overlay + /// + linger teardown. Cancels and nils the in-flight hold timer. + private func cancelHoldTimer() { + holdWorkItem?.cancel() + holdWorkItem = nil + } + + /// Release after the threshold: dismiss the overlay and call + /// `NSApp.terminate(nil)` SYNCHRONOUSLY (during `sendEvent` — see class + /// doc) so the normal shutdown path drains cleanly. + private func fireTermination() { + cancelHoldTimer() + overlay.hide() + // Call terminate SYNCHRONOUSLY here. This runs inside the local NSEvent + // monitor closure, which AppKit invokes during sendEvent — the same + // dispatch context the menu/Apple-Event quit paths use, and the only + // context in which terminateLater's shutdown Task reliably drains. + // Deferring to a DispatchQueue callback left the app wedged in + // terminateLater (the Task body never serviced). + NSApp.terminate(nil) + } + + private func cancelHold() { + cancelHoldTimer() + // Chrome-style: keep the overlay readable for a beat on early release, + // then fade it out instead of dismissing it instantly. + overlay.cancelWithLinger() + } +} diff --git a/Sources/RepoPrompt/App/QuitHoldDecision.swift b/Sources/RepoPrompt/App/QuitHoldDecision.swift new file mode 100644 index 000000000..e29079485 --- /dev/null +++ b/Sources/RepoPrompt/App/QuitHoldDecision.swift @@ -0,0 +1,132 @@ +import Foundation + +// A pure state machine that decides what to do during a hold-⌘Q-to-quit +// gesture. +// +// `QuitHoldDecision` has no AppKit, timer, or singleton coupling. It only maps +// discrete input events (`QuitHoldEvent`) to observable intents +// (`QuitHoldIntent`), so its full behavior is unit-testable with no mocks. The +// `QuitHoldController` (App layer) owns the real `NSEvent` local monitor, the +// hold timer, and the overlay, and feeds events into this value type. +// +// Quit timing: the threshold ARMS the quit; the actual quit intent is produced +// only when the user releases ⌘Q (keyUp or Command flag release) AFTER the +// threshold. Releasing before the threshold cancels. An `.externalCancel` +// (owner window closed, app resigned active, etc.) resets from `.holding` or +// `.armed` to `.idle` with a `.dismiss` intent so the controller can tear down +// the overlay immediately and a later ⌘Q can't quit from stale state. +// +// Semantics are pinned by `docs/spec/hold-to-quit.md` (scenarios S-001, S-002, +// S-003, S-010) and mirrored by `QuitHoldDecisionTests`. + +/// Discrete input events the hold-decision state machine reacts to. +public enum QuitHoldEvent { + /// A keyboard keyDown. `toggleOn` carries the current value of the + /// "Warn before quitting with ⌘Q" setting at the moment of the event. + case keyDown(toggleOn: Bool) + /// A keyboard keyUp of Q. + case keyUp + /// A modifier-flag change. `commandDown` carries whether Command is held. + case flagsChanged(commandDown: Bool) + /// The hold timer elapsed (threshold reached). + case timerElapsed + /// An external condition aborted the gesture (owner window closed, app + /// resigned active, …). Resets an in-flight hold; no-op if idle/already + /// quitting. + case externalCancel +} + +/// Observable side effects the controller should perform in response to an event. +public enum QuitHoldIntent: Equatable { + /// Do nothing. + case ignore + /// Show the hold overlay and start the hold timer at `threshold`. + case beginHold(threshold: TimeInterval) + /// Threshold reached while still holding: keep the overlay and update it to + /// a "release to quit" message. Do NOT terminate yet. + case arm + /// Release before the threshold: dismiss the overlay (linger/fade), cancel + /// the timer, and keep running. + case cancel + /// Release after the threshold: dismiss the overlay and quit through the + /// normal shutdown path. + case quit + /// External cancellation: dismiss the overlay immediately (no linger) and + /// reset to idle. + case dismiss +} + +public struct QuitHoldDecision { + /// The hold threshold in seconds, shared by the decision and its controller. + public static let threshold: TimeInterval = 1.0 + + /// The current phase of the gesture. + public enum State: Equatable { + case idle + case holding + /// Threshold reached; waiting for ⌘Q / Command release to terminate. + case armed + case quitting + } + + public private(set) var state: State = .idle + + public init() {} + + /// Feeds an event to the state machine, mutating state and returning the + /// intent the controller should act on. + @discardableResult + public mutating func handle(_ event: QuitHoldEvent) -> QuitHoldIntent { + switch state { + case .idle: + switch event { + case .keyDown(toggleOn: true): + state = .holding + return .beginHold(threshold: Self.threshold) + case .keyDown(toggleOn: false): + return .ignore + case .keyUp, .flagsChanged(commandDown: _), .timerElapsed, .externalCancel: + return .ignore + } + + case .holding: + switch event { + case .timerElapsed: + // Threshold reached while still holding: arm, don't terminate. + state = .armed + return .arm + case .keyUp, .flagsChanged(commandDown: false): + // Released before the threshold: cancel. + state = .idle + return .cancel + case .externalCancel: + // Aborted externally: reset to idle, dismiss immediately. + state = .idle + return .dismiss + case .keyDown(toggleOn: _), .flagsChanged(commandDown: true): + return .ignore + } + + case .armed: + switch event { + case .keyUp, .flagsChanged(commandDown: false): + // Released after the threshold: quit. Quit MUST wait for + // release rather than firing at the threshold while the key is + // held — quitting mid-hold propagates the held ⌘Q to the next + // focused app. See QuitHoldController's load-bearing notes. + state = .quitting + return .quit + case .externalCancel: + // Aborted externally: reset to idle, dismiss immediately. Without + // this, a later ⌘Q tap could quit from stale `.armed` state. + state = .idle + return .dismiss + case .keyDown(toggleOn: _), .flagsChanged(commandDown: true), .timerElapsed: + return .ignore + } + + case .quitting: + return .ignore + } + } +} diff --git a/Sources/RepoPrompt/App/QuitHoldOverlay.swift b/Sources/RepoPrompt/App/QuitHoldOverlay.swift new file mode 100644 index 000000000..2149fbe6c --- /dev/null +++ b/Sources/RepoPrompt/App/QuitHoldOverlay.swift @@ -0,0 +1,192 @@ +// +// QuitHoldOverlay.swift +// RepoPrompt +// +// Non-focus-stealing AppKit overlay shown during a hold-⌘Q-to-quit gesture. +// Owns the overlay panel, the linger/fade dismissal animation, and the +// owner-window-close observer. Extracted from QuitHoldController to isolate +// AppKit concerns from gesture orchestration. +// + +import AppKit +import SwiftUI + +// MARK: - Overlay + +/// Owns the "Hold ⌘Q to Quit" / "Release ⌘Q to Quit" overlay panel and its +/// chrome-style linger → fade → hide dismissal. +/// +/// The controller presents it via `show(message:onOwnerClose:)` (wiring the +/// owner-window-close callback to its external-cancellation path), swaps the +/// message via `update(_:)`, dismisses with a linger via `cancelWithLinger()` +/// on an early release, and tears it down immediately via `hide()` on a +/// confirmed quit or external cancel. `hide()` is the sole owner of +/// `lingerWorkItem` and the owner-close observer teardown. +@MainActor +final class QuitHoldOverlay { + private var overlayPanel: QuitHoldPanel? + private var ownerWillCloseObserver: NSObjectProtocol? + /// Scheduled "dwell then fade" removal of the overlay after an early + /// release; cancelled by `hide()` so a new hold or a confirmed quit still + /// use the immediate-dismiss path. + private var lingerWorkItem: DispatchWorkItem? + /// Chrome-style overlay dismissal on early release: the message stays + /// readable for `cancelLingerSeconds`, then fades over `cancelFadeSeconds` + /// (~2s total) instead of vanishing instantly. + private static let cancelLingerSeconds: TimeInterval = 1.7 + private static let cancelFadeSeconds: TimeInterval = 0.3 + + /// Presents the overlay with `message`. `onOwnerClose` is invoked if the + /// current key window (the anchor owner) closes while the overlay is shown; + /// the controller wires it to its external-cancellation path. The observer + /// is added here and removed in `hide()` (identical lifecycle to the inline + /// form previously in QuitHoldController.showOverlay). + func show(message: String, onOwnerClose: @escaping @Sendable @MainActor () -> Void) { + hide() + + let panel = QuitHoldPanel() + overlayPanel = panel + configure(panel, message: message) + + if let owner = NSApp.keyWindow, owner.isVisible { + ownerWillCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: owner, + queue: .main + ) { _ in + // Runs on `.main`; the owner window is closing, so abort the + // hold (reset state + dismiss overlay) — don't leave the gate + // in a stale .holding/.armed state. + MainActor.assumeIsolated { onOwnerClose() } + } + } + + panel.orderFrontRegardless() + } + + /// Swaps the overlay's message (e.g. "Hold…" → "Release…") without + /// dismissing it. No-op if no overlay is showing. + func update(_ message: String) { + guard let panel = overlayPanel else { return } + configure(panel, message: message) + } + + /// On early release, lingers the overlay at full opacity so the message is + /// readable, then fades and removes it. No-op if no overlay is showing. + func cancelWithLinger() { + startCancelLinger() + } + + /// Immediate teardown: cancels any in-flight linger/fade, removes the + /// owner-close observer, and dismisses the panel. + func hide() { + lingerWorkItem?.cancel() + lingerWorkItem = nil + if let observer = ownerWillCloseObserver { + NotificationCenter.default.removeObserver(observer) + ownerWillCloseObserver = nil + } + overlayPanel?.orderOut(nil) + overlayPanel = nil + } + + private func configure(_ panel: QuitHoldPanel, message: String) { + let hosting = NSHostingView(rootView: QuitHoldOverlayView(text: message)) + panel.contentView = hosting + // Size the panel to the SwiftUI content's natural size (no fixed frame). + let fit = hosting.fittingSize + panel.setContentSize(NSSize(width: ceil(fit.width), height: ceil(fit.height))) + positionOverlay(panel) + } + + private func startCancelLinger() { + guard let panel = overlayPanel else { return } + lingerWorkItem?.cancel() + let dwell = DispatchWorkItem { [weak self] in + // DispatchQueue.main.asyncAfter runs this on the main thread. + MainActor.assumeIsolated { self?.beginFadeOut(of: panel) } + } + lingerWorkItem = dwell + DispatchQueue.main.asyncAfter(deadline: .now() + Self.cancelLingerSeconds, execute: dwell) + } + + private func beginFadeOut(of panel: QuitHoldPanel) { + lingerWorkItem = nil + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.cancelFadeSeconds + panel.animator().alphaValue = 0 + } completionHandler: { [weak self] in + // Runs on the main thread. Only remove this panel if it is still + // current — a new hold started during the fade replaces it. + MainActor.assumeIsolated { + if self?.overlayPanel === panel { + self?.hide() + } + } + } + } + + private func positionOverlay(_ panel: NSPanel) { + let size = panel.frame.size + let anchor: NSRect = { + if let keyWindow = NSApp.keyWindow, keyWindow.isVisible { + return keyWindow.frame + } + return NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: size.width, height: size.height) + }() + panel.setFrameOrigin(NSPoint( + x: anchor.midX - size.width / 2, + y: anchor.midY - size.height / 2 + )) + } +} + +// MARK: - Overlay panel + SwiftUI view + +/// Non-activating panel that can never become key/main, so it cannot steal +/// keyboard focus from the active window (S-008). +private final class QuitHoldPanel: NSPanel { + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 236, height: 96), + styleMask: [.nonactivatingPanel], + backing: .buffered, + defer: false + ) + isOpaque = false + backgroundColor = .clear + hasShadow = true + isMovable = false + isFloatingPanel = true + level = .floating + hidesOnDeactivate = false + ignoresMouseEvents = true + collectionBehavior = [.canJoinAllSpaces, .stationary] + isReleasedWhenClosed = false + } + + override var canBecomeKey: Bool { + false + } + + override var canBecomeMain: Bool { + false + } +} + +/// "Hold ⌘Q to Quit" / "Release ⌘Q to Quit" card — text only (like Chrome). +private struct QuitHoldOverlayView: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 34, weight: .semibold)) + .padding(.horizontal, 28) + .padding(.vertical, 18) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.1), lineWidth: 0.5) + ) + } +} diff --git a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift index 77f6226c3..41427b72e 100644 --- a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift +++ b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsDocument.swift @@ -164,6 +164,7 @@ struct GlobalScalarPreferences: Codable, Equatable { var useTransparency: Bool? var collapseLatestFileChanges: Bool? var showTooltips: Bool? + var warnBeforeCmdQ: Bool? var experimentalAttributedTextEditor: Bool? var fileMentionPickerStyle: String? var enableKeyboardShortcuts: Bool? @@ -175,6 +176,7 @@ struct GlobalScalarPreferences: Codable, Equatable { useTransparency: Bool? = nil, collapseLatestFileChanges: Bool? = nil, showTooltips: Bool? = nil, + warnBeforeCmdQ: Bool? = nil, experimentalAttributedTextEditor: Bool? = nil, fileMentionPickerStyle: String? = nil, enableKeyboardShortcuts: Bool? = nil, @@ -185,6 +187,7 @@ struct GlobalScalarPreferences: Codable, Equatable { self.useTransparency = useTransparency self.collapseLatestFileChanges = collapseLatestFileChanges self.showTooltips = showTooltips + self.warnBeforeCmdQ = warnBeforeCmdQ self.experimentalAttributedTextEditor = experimentalAttributedTextEditor self.fileMentionPickerStyle = fileMentionPickerStyle self.enableKeyboardShortcuts = enableKeyboardShortcuts diff --git a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift index a7347c946..7b9b23bf1 100644 --- a/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift +++ b/Sources/RepoPrompt/Features/Settings/Models/GlobalSettingsManager.swift @@ -27,6 +27,9 @@ enum SettingKeys { /// Whether hover tooltips are shown globally. static let showTooltips = "showTooltips" + /// Whether to require holding ⌘Q before quitting (default on). + static let warnBeforeCmdQ = "warnBeforeCmdQ" + /// Whether the MCP Oracle UI exposes the Model Presets affordance. /// Referenced by ChatSettingsView, MCPSettingsView, and the inline MCP toggle. static let mcpShowModelPresets = "mcpShowModelPresets" @@ -452,6 +455,16 @@ class GlobalSettingsStore: ObservableObject { } } + func warnBeforeCmdQ() -> Bool { + scalarPreferences.ui?.warnBeforeCmdQ ?? true + } + + func setWarnBeforeCmdQ(_ enabled: Bool, commit: Bool = true) { + updateUIScalar(commit: commit) { settings in + settings.warnBeforeCmdQ = enabled + } + } + func showDatesInMessageTimestamps() -> Bool { scalarPreferences.ui?.showDatesInMessageTimestamps ?? false } diff --git a/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift b/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift index 1760ddc77..f87387503 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/AdvancedSettingsView.swift @@ -39,6 +39,13 @@ struct AdvancedSettingsView: View { ) } + private var warnBeforeCmdQBinding: Binding { + Binding( + get: { globalSettings.warnBeforeCmdQ() }, + set: { globalSettings.setWarnBeforeCmdQ($0) } + ) + } + private var canonicalURLPrefix: String { "\(AppDeepLinkURLScheme.canonical)://" } @@ -122,6 +129,11 @@ struct AdvancedSettingsView: View { keyboardShortcutsSection + Divider() + .padding(.horizontal, -16) + + quitSection + Divider() .padding(.horizontal, -16) @@ -312,6 +324,19 @@ struct AdvancedSettingsView: View { } } + private var quitSection: some View { + SettingSection( + title: "Quit", + description: "Prevent accidental quits that kill in-flight agent sessions." + ) { + SettingToggle( + title: "Warn before quitting with ⌘Q", + description: "When on, hold ⌘Q to quit (a quick tap is ignored). Quit from the menu, Dock, or system still quits immediately.", + isOn: warnBeforeCmdQBinding + ) + } + } + // MARK: - URL Opener private var urlOpenerSection: some View { diff --git a/Tests/RepoPromptTests/App/QuitHoldControllerTests.swift b/Tests/RepoPromptTests/App/QuitHoldControllerTests.swift new file mode 100644 index 000000000..c0ac8e117 --- /dev/null +++ b/Tests/RepoPromptTests/App/QuitHoldControllerTests.swift @@ -0,0 +1,51 @@ +import AppKit +@testable import RepoPrompt +import XCTest + +/// Controller lifecycle seam: verifies an external cancellation (owner window +/// closed / app resigned active) resets an in-flight hold so a later ⌘Q can't +/// quit from stale `.armed`/`.holding` state. Drives the real `handle(_:)` +/// with synthetic NSEvents and a deterministic (injected) toggle. +@MainActor +final class QuitHoldControllerTests: XCTestCase { + private let qKeyCode: UInt16 = 0x0C + + private func keyEvent(_ type: NSEvent.EventType, keyCode: UInt16) -> NSEvent { + // Force-unwrap is fine in a test for a well-formed key event. + NSEvent.keyEvent( + with: type, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: 0, context: nil, + characters: "q", charactersIgnoringModifiers: "q", + isARepeat: false, keyCode: keyCode + )! + } + + override func setUp() { + super.setUp() + // The controller's overlay anchors to NSApp.keyWindow; ensure the app + // exists so panel creation is well-defined during the test. + _ = NSApplication.shared + } + + /// External cancellation while holding resets to idle. + func testExternalCancellationWhileHoldingResetsToIdle() { + let controller = QuitHoldController(warnBeforeCmdQ: { true }) + _ = controller.handle(keyEvent(.keyDown, keyCode: qKeyCode)) + XCTAssertEqual(controller.holdState, .holding) + controller.handleExternalCancellation() + XCTAssertEqual(controller.holdState, .idle) + } + + /// Regression: after an external cancel, a later quick ⌘Q tap must NOT quit + /// (no stale `.armed`). It begins a fresh hold and cancels on release. + func testTapAfterExternalCancellationDoesNotQuit() { + let controller = QuitHoldController(warnBeforeCmdQ: { true }) + _ = controller.handle(keyEvent(.keyDown, keyCode: qKeyCode)) // -> holding + controller.handleExternalCancellation() // -> idle + // A subsequent quick tap (keyDown then keyUp, no timer/threshold): + _ = controller.handle(keyEvent(.keyDown, keyCode: qKeyCode)) + XCTAssertEqual(controller.holdState, .holding) + _ = controller.handle(keyEvent(.keyUp, keyCode: qKeyCode)) + XCTAssertEqual(controller.holdState, .idle) // not .quitting + } +} diff --git a/Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift b/Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift new file mode 100644 index 000000000..85d081998 --- /dev/null +++ b/Tests/RepoPromptTests/App/QuitHoldDecisionTests.swift @@ -0,0 +1,190 @@ +@testable import RepoPrompt +import XCTest + +/// Pins the hold-⌘Q-to-quit state machine. The threshold ARMS the quit; the +/// quit intent is produced only on release AFTER the threshold. Releasing +/// before the threshold cancels. +final class QuitHoldDecisionTests: XCTestCase { + /// S-003: gate-off keyDown is ignored and never begins a hold. + func testKeyDownToggleOffReturnsIgnoreAndStaysIdle() { + var decision = QuitHoldDecision() + let intent = decision.handle(.keyDown(toggleOn: false)) + XCTAssertEqual(intent, .ignore) + XCTAssertEqual(decision.state, .idle) + } + + /// S-001 setup: gate-on keyDown begins the hold with the threshold. + func testKeyDownToggleOnBeginsHoldAndEntersHolding() { + var decision = QuitHoldDecision() + let intent = decision.handle(.keyDown(toggleOn: true)) + XCTAssertEqual(intent, .beginHold(threshold: QuitHoldDecision.threshold)) + XCTAssertEqual(decision.state, .holding) + } + + /// Threshold reached while holding ARMS the quit (does not quit immediately). + func testTimerElapsedWhileHoldingArmsAndEntersArmed() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + let intent = decision.handle(.timerElapsed) + XCTAssertEqual(intent, .arm) + XCTAssertEqual(decision.state, .armed) + } + + /// S-002: Q keyUp before the threshold cancels and returns to idle. + func testKeyUpWhileHoldingCancelsAndReturnsToIdle() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + let intent = decision.handle(.keyUp) + XCTAssertEqual(intent, .cancel) + XCTAssertEqual(decision.state, .idle) + } + + /// S-002 alt: Command release before the threshold cancels and returns to idle. + func testFlagsChangedCommandReleasedWhileHoldingCancelsAndReturnsToIdle() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + let intent = decision.handle(.flagsChanged(commandDown: false)) + XCTAssertEqual(intent, .cancel) + XCTAssertEqual(decision.state, .idle) + } + + /// S-010: a repeat ⌘Q keyDown while holding is ignored and stays holding. + func testRepeatKeyDownWhileHoldingIsIgnoredAndStaysHolding() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + let intent = decision.handle(.keyDown(toggleOn: true)) + XCTAssertEqual(intent, .ignore) + XCTAssertEqual(decision.state, .holding) + } + + /// A stray Command-held flags event while holding does not cancel. + func testFlagsChangedCommandHeldWhileHoldingIsIgnoredAndStaysHolding() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + let intent = decision.handle(.flagsChanged(commandDown: true)) + XCTAssertEqual(intent, .ignore) + XCTAssertEqual(decision.state, .holding) + } + + /// Idle noise (keyUp / flagsChanged / timerElapsed) is ignored. + func testNonKeyDownEventsWhileIdleAreIgnoredAndStaysIdle() { + var decision = QuitHoldDecision() + for event in [QuitHoldEvent.keyUp, .flagsChanged(commandDown: false), .flagsChanged(commandDown: true), .timerElapsed] { + var d = decision + XCTAssertEqual(d.handle(event), .ignore) + XCTAssertEqual(d.state, .idle) + } + XCTAssertEqual(decision.state, .idle) + } + + // MARK: - Armed (threshold reached, awaiting release) + + /// S-001: Q keyUp after the threshold quits and enters the terminal state. + func testKeyUpWhileArmedQuitsAndEntersQuitting() { + var decision = Self.armedDecision() + let intent = decision.handle(.keyUp) + XCTAssertEqual(intent, .quit) + XCTAssertEqual(decision.state, .quitting) + } + + /// S-001 alt: Command release after the threshold quits. + func testFlagsChangedCommandReleasedWhileArmedQuitsAndEntersQuitting() { + var decision = Self.armedDecision() + let intent = decision.handle(.flagsChanged(commandDown: false)) + XCTAssertEqual(intent, .quit) + XCTAssertEqual(decision.state, .quitting) + } + + /// S-010: a repeat ⌘Q keyDown after arming is ignored and stays armed. + func testRepeatKeyDownWhileArmedIsIgnoredAndStaysArmed() { + var decision = Self.armedDecision() + let intent = decision.handle(.keyDown(toggleOn: true)) + XCTAssertEqual(intent, .ignore) + XCTAssertEqual(decision.state, .armed) + } + + /// A stray Command-held flags event after arming does not quit. + func testFlagsChangedCommandHeldWhileArmedIsIgnoredAndStaysArmed() { + var decision = Self.armedDecision() + let intent = decision.handle(.flagsChanged(commandDown: true)) + XCTAssertEqual(intent, .ignore) + XCTAssertEqual(decision.state, .armed) + } + + /// Terminal quitting state is not escapable. + func testEventsWhileQuittingAreIgnoredAndStaysQuitting() { + var decision = Self.armedDecision() + _ = decision.handle(.keyUp) + XCTAssertEqual(decision.state, .quitting) + for event in [QuitHoldEvent.keyDown(toggleOn: true), .keyUp, .flagsChanged(commandDown: false), .timerElapsed] { + XCTAssertEqual(decision.handle(event), .ignore) + XCTAssertEqual(decision.state, .quitting) + } + } + + // MARK: - External cancellation (owner window closed / app resigned active) + + /// External cancel while holding dismisses and returns to idle. + func testExternalCancelWhileHoldingDismissesAndReturnsToIdle() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + let intent = decision.handle(.externalCancel) + XCTAssertEqual(intent, .dismiss) + XCTAssertEqual(decision.state, .idle) + } + + /// External cancel while armed resets to idle — a later ⌘Q tap must NOT + /// quit from stale `.armed` state (regression: owner window closes mid-hold). + func testExternalCancelWhileArmedDismissesAndReturnsToIdle() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + _ = decision.handle(.timerElapsed) + let intent = decision.handle(.externalCancel) + XCTAssertEqual(intent, .dismiss) + XCTAssertEqual(decision.state, .idle) + } + + /// After an external cancel from `.armed`, a subsequent quick ⌘Q tap does + /// not quit — it begins a fresh hold and cancels on release, never quits. + func testTapAfterExternalCancelFromArmedDoesNotQuit() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + _ = decision.handle(.timerElapsed) + _ = decision.handle(.externalCancel) + let down = decision.handle(.keyDown(toggleOn: true)) + XCTAssertEqual(down, .beginHold(threshold: QuitHoldDecision.threshold)) + XCTAssertEqual(decision.state, .holding) + let up = decision.handle(.keyUp) + XCTAssertEqual(up, .cancel) + XCTAssertEqual(decision.state, .idle) + } + + /// External cancel is a no-op when idle. + func testExternalCancelWhileIdleIsIgnored() { + var decision = QuitHoldDecision() + XCTAssertEqual(decision.handle(.externalCancel), .ignore) + XCTAssertEqual(decision.state, .idle) + } + + /// External cancel does not escape the terminal quitting state. + func testExternalCancelWhileQuittingIsIgnored() { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + _ = decision.handle(.timerElapsed) + _ = decision.handle(.keyUp) + XCTAssertEqual(decision.handle(.externalCancel), .ignore) + XCTAssertEqual(decision.state, .quitting) + } + + // MARK: - Helpers + + /// A decision driven to the armed state (toggle-on keyDown + timer elapsed). + private static func armedDecision() -> QuitHoldDecision { + var decision = QuitHoldDecision() + _ = decision.handle(.keyDown(toggleOn: true)) + let armIntent = decision.handle(.timerElapsed) + XCTAssertEqual(armIntent, .arm) + XCTAssertEqual(decision.state, .armed) + return decision + } +} diff --git a/Tests/RepoPromptTests/App/QuitHoldEventFilterTests.swift b/Tests/RepoPromptTests/App/QuitHoldEventFilterTests.swift new file mode 100644 index 000000000..e9dd4f3af --- /dev/null +++ b/Tests/RepoPromptTests/App/QuitHoldEventFilterTests.swift @@ -0,0 +1,58 @@ +import AppKit +@testable import RepoPrompt +import XCTest + +/// Pins the keyboard-event swallowing policy for the hold-⌘Q-to-quit gate at +/// the lowest faithful layer (a pure helper over `NSEvent.ModifierFlags`). +/// Table-driven to keep the normalization / toggle-off / non-quit-key cases +/// tight. Covers S-003 (toggle off → pass-through) and S-011 (⌘W / ⇧⌘W / +/// extra-modifier ⌘Q untouched) without mocking NSEvent/NSApp. +final class QuitHoldEventFilterTests: XCTestCase { + /// `kVK_ANSI_Q` (Carbon `0x0C`); hard-coded to avoid importing Carbon. + private let qKeyCode: UInt16 = 0x0C + /// `kVK_ANSI_W` (Carbon `0x0D`). + private let wKeyCode: UInt16 = 0x0D + + /// ⌘Q (command-only) is still recognized with Caps Lock / Fn / numeric pad + /// active — those flags are normalized out. + func testCmdQWithIgnorableModifiersIsSwallowedWhenToggleOn() { + let swallowCases: [NSEvent.ModifierFlags] = [ + .command, + [.command, .capsLock], + [.command, .function], + [.command, .numericPad] + ] + for flags in swallowCases { + XCTAssertTrue( + QuitHoldEventFilter.shouldSwallowKeyDown(modifierFlags: flags, keyCode: qKeyCode, toggleOn: true), + "expected swallow for normalized ⌘Q with flags \(flags)" + ) + } + } + + /// S-003: with the toggle off, ⌘Q is not swallowed (default menu fires). + func testCmdQWithToggleOffIsNotSwallowed() { + XCTAssertFalse( + QuitHoldEventFilter.shouldSwallowKeyDown(modifierFlags: .command, keyCode: qKeyCode, toggleOn: false) + ) + } + + /// Extra modifiers (⌘⇧/⌘⌥/⌘⌃), wrong key (⌘W / ⇧⌘W), and Q-without-command + /// are all passed through untouched (exact-modifier match; S-011). + func testNonQuitGesturesAreNotSwallowed() { + let passThrough: [(NSEvent.ModifierFlags, UInt16, String)] = [ + ([.command, .shift], qKeyCode, "⌘⇧Q"), + ([.command, .option], qKeyCode, "⌘⌥Q"), + ([.command, .control], qKeyCode, "⌘⌃Q"), + (.command, wKeyCode, "⌘W"), + ([.command, .shift], wKeyCode, "⇧⌘W"), + ([], qKeyCode, "Q alone") + ] + for (flags, key, label) in passThrough { + XCTAssertFalse( + QuitHoldEventFilter.shouldSwallowKeyDown(modifierFlags: flags, keyCode: key, toggleOn: true), + "expected pass-through for \(label)" + ) + } + } +} diff --git a/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift b/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift index 19e64cc5c..2baf4bb02 100644 --- a/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift +++ b/Tests/RepoPromptTests/SettingsJSONOnlyPersistenceTests.swift @@ -335,6 +335,64 @@ final class SettingsJSONOnlyPersistenceTests: XCTestCase { XCTAssertTrue(reloaded.showDatesInMessageTimestamps()) } + func testWarnBeforeCmdQDefaultsOnWithoutPersisting() throws { + let temp = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let fileURL = temp.appendingPathComponent("Settings/globalSettings.json") + let fileStore = GlobalSettingsFileStore(fileURL: fileURL) + try fileStore.save(GlobalSettingsDocument( + scalarPreferences: GlobalScalarPreferences(ui: .init(showTooltips: false)) + )) + let suiteName = "SettingsJSONOnlyPersistenceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = GlobalSettingsStore(defaults: defaults, fileStore: fileStore) + let before = try String(contentsOf: fileURL, encoding: .utf8) + + // Fresh install with no stored preference reads the default (on). + XCTAssertTrue(store.warnBeforeCmdQ()) + XCTAssertNil(try fileStore.load().scalarPreferences?.ui?.warnBeforeCmdQ) + // Reading the default must not mutate the persisted document. + XCTAssertEqual(try String(contentsOf: fileURL, encoding: .utf8), before) + } + + func testWarnBeforeCmdQRoundTripsFalseAndTrue() throws { + let temp = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: temp) } + let fileURL = temp.appendingPathComponent("Settings/globalSettings.json") + let fileStore = GlobalSettingsFileStore(fileURL: fileURL) + let suiteName = "SettingsJSONOnlyPersistenceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = GlobalSettingsStore(defaults: defaults, fileStore: fileStore) + + store.setWarnBeforeCmdQ(false) + XCTAssertEqual(try fileStore.load().scalarPreferences?.ui?.warnBeforeCmdQ, false) + XCTAssertFalse(store.warnBeforeCmdQ()) + + store.setWarnBeforeCmdQ(true) + XCTAssertEqual(try fileStore.load().scalarPreferences?.ui?.warnBeforeCmdQ, true) + XCTAssertTrue(store.warnBeforeCmdQ()) + + // Setting survives a store reload (persists across launches). + let reloaded = GlobalSettingsStore(defaults: defaults, fileStore: fileStore) + XCTAssertTrue(reloaded.warnBeforeCmdQ()) + } + + func testWarnBeforeCmdQCodableRoundTripsAndLegacyDecodesToNil() throws { + // New optional value round-trips through synthesized Codable. + let encoded = try JSONEncoder().encode(GlobalScalarPreferences.UISettings(warnBeforeCmdQ: false)) + let decoded = try JSONDecoder().decode(GlobalScalarPreferences.UISettings.self, from: encoded) + XCTAssertEqual(decoded.warnBeforeCmdQ, false) + + // Legacy JSON without the key decodes without error and yields nil, + // which the accessor maps to the default (on) — backward compatible. + let legacyJSON = #"{"showTooltips":true}"# + let legacy = try JSONDecoder().decode(GlobalScalarPreferences.UISettings.self, from: Data(legacyJSON.utf8)) + XCTAssertEqual(legacy.showTooltips, true) + XCTAssertNil(legacy.warnBeforeCmdQ) + } + private func makeTempDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("SettingsJSONOnlyPersistenceTests-\(UUID().uuidString)", isDirectory: true)