feat(app): non-dismissible Input Monitoring banner + foreground re-probe — closes #88

SHELL-06 v1 requirement: surface a HUD banner when global key monitoring
silently no-ops on TCC denial, and clear it when the user grants access.
Settings already exposed the state; the HUD path was missing.

Three changes:
  - `BannerContent.inputMonitoringDenied` is now `nonDismissible: true`.
    The user must not be able to click past a denial that breaks global
    hotkeys — the silent-no-op failure mode is exactly what this banner
    exists to prevent.
  - `HUDBannerCoordinator.dismiss(id:)` clears a banner by id, bypassing
    the `nonDismissible` flag, without poisoning `dismissedThisLaunch`.
    The condition can recur (user revokes again), so we need to be able
    to re-enqueue after a programmatic clear.
  - `AppDelegate.reprobeInputMonitoring` runs on
    `NSApplication.didBecomeActiveNotification`. Was-denied → now-granted
    dismisses the banner and rebinds the global hotkey under fresh TCC
    (the original bind silently fell back to local-only). Was-granted →
    now-denied re-enqueues the banner.

`IOHIDCheckAccess` reads the cached TCC decision without prompting, so
this is safe to call on every foreground hop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 4.7 committed 2026-05-12 16:41:38 -05:00
1 parent dca0695c61
commit 48fb8b9bab
4 files changed
+155 -1

No files matched your search

+54
View File
@@ -929,6 +929,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// two panels don't stack on top of the wizard window.
self?.openStartupSurfacesIfDiagnostic()
}
// 18. Issue #88 / SHELL-06 — re-probe Input Monitoring on app
// foreground. `NSEvent.addGlobalMonitorForEvents` silently
// no-ops on TCC denial; if the user grants access in System
// Settings (via our deep-link), we need to clear the
// non-dismissible banner without forcing them to relaunch.
// The query-only probe (`IOHIDCheckAccess`) reads the cached
// TCC decision without prompting.
NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.reprobeInputMonitoring()
}
}
}
/// On-disk replay log path. Lives next to `config.json` under
@@ -2928,6 +2945,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
// MARK: - Menu-bar actions
/// Lazily constructed on first toggle so the panel doesn't allocate
@@ -3012,6 +3030,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
systemLogger?.info("openStartupSurfacesIfDiagnostic: opened Status + DevOverlay (mode=diagnostic)")
}
/// Issue #88 / SHELL-06 — query-only Input Monitoring TCC probe wired
/// to the `NSApplication.didBecomeActiveNotification` observer set up
/// in `applicationWillFinishLaunching`. Two transitions are handled:
///
/// - Was-denied → now-granted (most common: user clicked the deep
/// link, toggled access on, returned to Jarvis). Programmatically
/// dismisses the non-dismissible `.inputMonitoringDenied` banner
/// via `HUDBannerCoordinator.dismiss(id:)`. We also re-bind the
/// existing global hotkey if one was previously configured, since
/// the original bind happened under denied access and silently
/// fell back to local-only.
///
/// - Was-granted → now-denied (rarer: user toggled access off in
/// System Settings while Jarvis was background). Enqueues
/// `.inputMonitoringDenied`. The coordinator dedupes by id so a
/// re-probe while the banner is still showing is a no-op.
///
/// `IOHIDCheckAccess` reads the cached TCC decision without prompting,
/// so this is safe to call on every foreground hop.
@MainActor
private func reprobeInputMonitoring() {
let granted = hidProbe.isListenEventAccessGranted()
if granted {
bannerCoordinator?.dismiss(id: BannerContent.inputMonitoringDenied.id)
// Rebind hotkey under fresh TCC: the previous bind installed
// local monitors only (global silently nil-tokened on denial).
// Re-running `bindHotkeyFromWizard` reinstalls global monitors.
if let state = wizardState, state.hotkey != nil, !state.inputMonitoringGranted {
state.inputMonitoringGranted = true
bindHotkeyFromWizard()
}
} else {
bannerCoordinator?.enqueue(.inputMonitoringDenied)
}
}
/// Copies a debug state snapshot to the system clipboard. Writes ONLY
/// boolean presence indicators (`apiKeyStored`, `inputMonitoringGranted`,
/// `hotkeyBound`). The API key VALUE is never fetched into a local
+9 -1
View File
@@ -54,6 +54,13 @@ public extension BannerContent {
)
/// Input Monitoring TCC denied — global hotkey degrades to local-only.
///
/// SHELL-06 / #88: non-dismissible. The user can't click this away on
/// a denial because letting them silently restore the "global hotkey
/// silently no-ops" failure mode is the defect this banner exists to
/// prevent. The banner is cleared programmatically by AppDelegate's
/// `reprobeInputMonitoring()` on app foreground after the user grants
/// access in System Settings.
static let inputMonitoringDenied = BannerContent(
id: "input-monitoring-denied",
priority: 2,
@@ -62,7 +69,8 @@ public extension BannerContent {
action: .init(
label: "Open System Settings",
url: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
)
),
nonDismissible: true
)
/// Hotkey registration failed — another app claimed the shortcut.
+37
View File
@@ -89,6 +89,43 @@ public final class HUDBannerCoordinator {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: item)
}
/// Programmatically dismiss a banner by id, bypassing the
/// `nonDismissible` flag. Use ONLY when the underlying condition was
/// fixed (e.g., Input Monitoring re-granted after the user opened
/// System Settings) — never as a way to let the user click past a
/// non-dismissible warning. (SHELL-06 / #88 re-probe path.)
///
/// If the banner is currently showing, it's drained the same way
/// `dismissCurrent()` drains (3 0 0 ms gap before the next queued banner
/// renders). If it's only in the queue, it's silently removed. No-op
/// when no banner with `id` is known.
public func dismiss(id: String) {
// Drop a queued copy (e.g. enqueued during the 300ms drain) so it
// doesn't surface after we clear the current banner.
queue.removeAll { $0.id == id }
guard let c = current, c.id == id else { return }
// NOTE: do NOT add to `dismissedThisLaunch`. Programmatic dismiss
// means the condition was resolved (e.g. TCC re-granted); if it
// recurs (user revokes again), we need to be able to re-enqueue.
// The user-driven `dismissCurrent()` path remembers dismissal
// because the user explicitly clicked away from a banner they
// accepted; the programmatic path is the opposite intent.
current = nil
panel.orderOut(nil)
drainWorkItem?.cancel()
drainWorkItem = nil
guard !queue.isEmpty else { return }
let next = queue.removeFirst()
let item = DispatchWorkItem { [weak self] in
guard let self else { return }
if self.current != nil { return }
if self.dismissedThisLaunch.contains(next.id) { return }
self.showBanner(next)
}
drainWorkItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: item)
}
/// Clear everything — used when the entire app is about to quit or when
/// a test needs to reset the coordinator.
public func clear() {
@@ -143,4 +143,59 @@ final class HUDBannerCoordinatorTests: XCTestCase {
}
wait(for: [exp], timeout: 2.0)
}
// MARK: - #88 / SHELL-06 — programmatic dismiss(id:)
/// #88: `dismiss(id:)` clears a non-dismissible banner (used by
/// AppDelegate's foreground re-probe when TCC was re-granted).
func test_dismissByIdClearsNonDismissibleBanner() {
let panel = HUDBannerPanel()
let coordinator = HUDBannerCoordinator(panel: panel)
coordinator.enqueue(.inputMonitoringDenied)
XCTAssertEqual(coordinator.currentBanner?.id, "input-monitoring-denied")
// User-triggered dismiss must NOT clear a non-dismissible banner.
coordinator.dismissCurrent()
XCTAssertEqual(
coordinator.currentBanner?.id,
"input-monitoring-denied",
"non-dismissible banner must ignore user dismissCurrent"
)
// Programmatic dismiss-by-id MUST clear it.
coordinator.dismiss(id: "input-monitoring-denied")
XCTAssertNil(
coordinator.currentBanner,
"#88: dismiss(id:) must clear a non-dismissible banner when condition resolved"
)
}
/// #88: `dismiss(id:)` must NOT mark the id as dismissed-this-launch
/// — the condition can recur (user revokes again) and we need to be
/// able to re-enqueue.
func test_dismissByIdAllowsReEnqueueIfConditionRecurs() {
let panel = HUDBannerPanel()
let coordinator = HUDBannerCoordinator(panel: panel)
coordinator.enqueue(.inputMonitoringDenied)
coordinator.dismiss(id: "input-monitoring-denied")
XCTAssertNil(coordinator.currentBanner)
// Condition recurs (re-probe finds denial again).
coordinator.enqueue(.inputMonitoringDenied)
XCTAssertEqual(
coordinator.currentBanner?.id,
"input-monitoring-denied",
"#88: programmatic dismiss must NOT poison the id — re-enqueue must work"
)
}
/// #88: `dismiss(id:)` on an unknown id is a no-op.
func test_dismissByIdUnknownIsNoop() {
let panel = HUDBannerPanel()
let coordinator = HUDBannerCoordinator(panel: panel)
coordinator.enqueue(.keychainEmpty)
coordinator.dismiss(id: "nonexistent")
XCTAssertEqual(
coordinator.currentBanner?.id,
"keychain-empty",
"#88: dismiss(id:) on unknown id must not affect current banner"
)
}
}