Phase 1 REVIEW.md CR-03 (SHELL-06): `HotkeyBinder.bind` never checked
whether `store.installGlobalMonitor` returned nil — yet
`NSEvent.addGlobalMonitorForEvents` silently returns nil when Input
Monitoring was revoked between launches (common after a codesign
identity change). The result was the exact silent-no-op SHELL-06
exists to prevent: hotkey appears bound, global monitor is dead, no
banner surfaces. Related WR-06: `BannerContent.hotkeyBindFailed`
existed but was never enqueued from production code.
packages/Shell/Sources/Shell/HotkeyBinder.swift
- Add `HotkeyBinder.BannerSink` protocol (mirrors
`InputMonitoringProbe.BannerSink` pattern).
- `bind(_:inputMonitoringGranted:bindFailedSink:onPress:)` now checks
the returned token: if nil, sets `isDegraded = true` AND invokes
`sink.enqueueHotkeyBindFailed()`. Local monitor install is still
unconditional so the hotkey works while Jarvis is frontmost.
packages/Shell/Tests/ShellTests/HotkeyBindingTests.swift
- Add `DeniedGlobalMonitorStore` fake (returns nil for global install).
- Add `RecordingBindFailedSink` that counts enqueue calls.
- New tests:
- `test_nilGlobalTokenMarksDegradedAndNotifiesSink` — SHELL-06
regression guard for the silent-revoke path.
- `test_deniedInputMonitoringDoesNotEnqueueBindFailed` — asserts the
priority-2 inputMonitoringDenied banner is not mixed with the
priority-3 hotkeyBindFailed banner.
- `test_successfulBindDoesNotEnqueueBindFailed` — no spurious banner
on the happy path.
App/AppDelegate.swift:233-260
- Add `HotkeyBindFailedSink` adapter (mirrors `AdHocBannerSink`
pattern) so Shell can enqueue `BannerContent.hotkeyBindFailed` onto
the `HUDBannerCoordinator` without importing App types.
- `bindHotkeyFromWizard()` now passes the sink into `hotkeyBinder.bind`
— fixes WR-06 (hotkeyBindFailed preset no longer orphaned).
179 lines
7.1 KiB
Swift
179 lines
7.1 KiB
Swift
import XCTest
|
|
import AppKit
|
|
@testable import Shell
|
|
|
|
@MainActor
|
|
final class HotkeyBindingTests: XCTestCase {
|
|
/// `@unchecked Sendable`: counters are `var` (required by the Sendable
|
|
/// protocol), but every interaction in these @MainActor-bound tests happens
|
|
/// on the main thread, so there's no actual data race. Test-fixture-only
|
|
/// escape hatch, not production code.
|
|
private final class RecordingMonitorStore: HotkeyMonitorStore, @unchecked Sendable {
|
|
var globalInstalled = 0
|
|
var localInstalled = 0
|
|
var removed = 0
|
|
|
|
func installGlobalMonitor(
|
|
_ shortcut: KeyboardShortcut,
|
|
_ onPress: @escaping @Sendable () -> Void
|
|
) -> Any? {
|
|
globalInstalled += 1
|
|
return "global-\(globalInstalled)" as NSString
|
|
}
|
|
|
|
func installLocalMonitor(
|
|
_ shortcut: KeyboardShortcut,
|
|
_ onPress: @escaping @Sendable () -> Void
|
|
) -> Any? {
|
|
localInstalled += 1
|
|
return "local-\(localInstalled)" as NSString
|
|
}
|
|
|
|
func remove(_ token: Any) {
|
|
removed += 1
|
|
}
|
|
}
|
|
|
|
/// CR-03 (SHELL-06): store that mimics `NSEvent.addGlobalMonitorForEvents`
|
|
/// returning `nil` — the exact OS behaviour when Input Monitoring was
|
|
/// revoked between launches. Local-monitor install still succeeds.
|
|
private final class DeniedGlobalMonitorStore: HotkeyMonitorStore, @unchecked Sendable {
|
|
var localInstalled = 0
|
|
var removed = 0
|
|
func installGlobalMonitor(
|
|
_ shortcut: KeyboardShortcut,
|
|
_ onPress: @escaping @Sendable () -> Void
|
|
) -> Any? { nil }
|
|
func installLocalMonitor(
|
|
_ shortcut: KeyboardShortcut,
|
|
_ onPress: @escaping @Sendable () -> Void
|
|
) -> Any? {
|
|
localInstalled += 1
|
|
return "local-\(localInstalled)" as NSString
|
|
}
|
|
func remove(_ token: Any) { removed += 1 }
|
|
}
|
|
|
|
/// CR-03 (SHELL-06): counter-recording BannerSink so we can assert the
|
|
/// hotkey-bind-failed banner was enqueued when the OS silently refused.
|
|
private final class RecordingBindFailedSink: HotkeyBinder.BannerSink, @unchecked Sendable {
|
|
var enqueueCount = 0
|
|
func enqueueHotkeyBindFailed() { enqueueCount += 1 }
|
|
}
|
|
|
|
func test_defaultHotkeyIsUnset() {
|
|
let binder = HotkeyBinder(store: RecordingMonitorStore())
|
|
XCTAssertNil(binder.currentShortcut, "SHELL-02: default hotkey ships unset")
|
|
XCTAssertFalse(binder.isDegraded)
|
|
}
|
|
|
|
func test_bindInstallsGlobalAndLocalWhenGranted() {
|
|
let store = RecordingMonitorStore()
|
|
let binder = HotkeyBinder(store: store)
|
|
let shortcut = KeyboardShortcut(keyCode: 38, modifiers: [.command, .shift]) // J
|
|
binder.bind(shortcut, inputMonitoringGranted: true) { }
|
|
XCTAssertEqual(store.globalInstalled, 1)
|
|
XCTAssertEqual(store.localInstalled, 1)
|
|
XCTAssertFalse(binder.isDegraded)
|
|
XCTAssertEqual(binder.currentShortcut, shortcut)
|
|
}
|
|
|
|
func test_bindInstallsLocalOnlyWhenDenied() {
|
|
let store = RecordingMonitorStore()
|
|
let binder = HotkeyBinder(store: store)
|
|
binder.bind(
|
|
KeyboardShortcut(keyCode: 38, modifiers: [.command, .shift]),
|
|
inputMonitoringGranted: false
|
|
) { }
|
|
XCTAssertEqual(store.globalInstalled, 0, "Must NOT install global monitor on denial")
|
|
XCTAssertEqual(store.localInstalled, 1, "Must install local monitor fallback")
|
|
XCTAssertTrue(binder.isDegraded)
|
|
}
|
|
|
|
func test_unbindRemovesAllMonitors() {
|
|
let store = RecordingMonitorStore()
|
|
let binder = HotkeyBinder(store: store)
|
|
binder.bind(
|
|
KeyboardShortcut(keyCode: 38, modifiers: [.command]),
|
|
inputMonitoringGranted: true
|
|
) { }
|
|
binder.unbind()
|
|
XCTAssertEqual(store.removed, 2, "unbind must remove both global and local tokens")
|
|
XCTAssertNil(binder.currentShortcut)
|
|
}
|
|
|
|
func test_keyboardShortcutCodableRoundTrip() throws {
|
|
let original = KeyboardShortcut(keyCode: 49 /* space */, modifiers: [.option])
|
|
let data = try JSONEncoder().encode(original)
|
|
let decoded = try JSONDecoder().decode(KeyboardShortcut.self, from: data)
|
|
XCTAssertEqual(decoded, original)
|
|
}
|
|
|
|
/// CR-03 (SHELL-06): when `NSEvent.addGlobalMonitorForEvents` silently
|
|
/// returns nil, the binder must NOT pretend the bind succeeded — it must
|
|
/// set `isDegraded = true` and (when a sink is wired) enqueue the
|
|
/// hotkey-bind-failed banner. Local monitor is still installed so the
|
|
/// hotkey works while Jarvis is frontmost.
|
|
func test_nilGlobalTokenMarksDegradedAndNotifiesSink() {
|
|
let store = DeniedGlobalMonitorStore()
|
|
let sink = RecordingBindFailedSink()
|
|
let binder = HotkeyBinder(store: store)
|
|
let shortcut = KeyboardShortcut(keyCode: 38, modifiers: [.command])
|
|
binder.bind(
|
|
shortcut,
|
|
inputMonitoringGranted: true,
|
|
bindFailedSink: sink
|
|
) { }
|
|
|
|
XCTAssertTrue(
|
|
binder.isDegraded,
|
|
"SHELL-06: nil global token must mark binder degraded even when inputMonitoringGranted=true"
|
|
)
|
|
XCTAssertEqual(
|
|
sink.enqueueCount, 1,
|
|
"SHELL-06: nil global token must enqueue BannerContent.hotkeyBindFailed exactly once"
|
|
)
|
|
XCTAssertEqual(
|
|
store.localInstalled, 1,
|
|
"Local monitor must still be installed so the hotkey works while Jarvis is frontmost"
|
|
)
|
|
XCTAssertEqual(binder.currentShortcut, shortcut)
|
|
}
|
|
|
|
/// CR-03 (SHELL-06): when Input Monitoring was never granted, the binder
|
|
/// is degraded but the bind-failed sink is NOT called — the OS refused
|
|
/// is a *different* banner (inputMonitoringDenied, priority 2) vs. the
|
|
/// silent-revoke path (hotkeyBindFailed, priority 3).
|
|
func test_deniedInputMonitoringDoesNotEnqueueBindFailed() {
|
|
let store = RecordingMonitorStore()
|
|
let sink = RecordingBindFailedSink()
|
|
let binder = HotkeyBinder(store: store)
|
|
binder.bind(
|
|
KeyboardShortcut(keyCode: 38, modifiers: [.command]),
|
|
inputMonitoringGranted: false,
|
|
bindFailedSink: sink
|
|
) { }
|
|
XCTAssertTrue(binder.isDegraded)
|
|
XCTAssertEqual(
|
|
sink.enqueueCount, 0,
|
|
"Denied-InputMonitoring path must use the inputMonitoringDenied banner, "
|
|
+ "not the hotkeyBindFailed banner"
|
|
)
|
|
}
|
|
|
|
/// CR-03 (SHELL-06): when the global monitor installs successfully, the
|
|
/// sink must NOT be called — no spurious banner on the happy path.
|
|
func test_successfulBindDoesNotEnqueueBindFailed() {
|
|
let store = RecordingMonitorStore()
|
|
let sink = RecordingBindFailedSink()
|
|
let binder = HotkeyBinder(store: store)
|
|
binder.bind(
|
|
KeyboardShortcut(keyCode: 38, modifiers: [.command]),
|
|
inputMonitoringGranted: true,
|
|
bindFailedSink: sink
|
|
) { }
|
|
XCTAssertFalse(binder.isDegraded)
|
|
XCTAssertEqual(sink.enqueueCount, 0)
|
|
}
|
|
}
|