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:
1 parent
dca0695c61
commit
48fb8b9bab
4 files changed
+155
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user