Skip to content

Commit 74a20fa

Browse files
authored
Add proactive accessibility permission checking (farouqaldori#49)
* feat: add proactive accessibility permission checking - Add AccessibilityPermissionManager singleton to monitor permission state - Show startup alert explaining why permission is needed - Display amber warning icon in closed notch when permission missing - Update AccessibilityRow with amber styling and reactive state - Keep notch visible while accessibility warning is shown - Periodic monitoring detects when user grants permission Fixes farouqaldori#46 * fix: address review comments from PR farouqaldori#49 - Start periodic monitoring immediately when accessibility permission is missing, so UI reliably updates when permission is granted through any path (not just via our alert's "Open Settings" button) - Include accessibilityWarningWidth in expansion calculation for the warning-only case, fixing potential layout compression * fix: improve accessibility permission detection with adaptive polling Use DispatchSourceTimer with adaptive polling (0.5s for 30s, then 2s) instead of Timer for more reliable detection. Add app activation handler in NotchView to restart fast polling when user returns from System Settings. Move from custom alert to macOS system prompt for better UX. * fix: show explanatory alert instead of system dialog on launch Replace promptForPermission() with showPermissionAlert() to match the intended behavior described in the PR. Users now see a custom alert explaining why accessibility permission is needed, with an "Open Settings" button to guide them to grant permission.
1 parent a1afe79 commit 74a20fa

5 files changed

Lines changed: 352 additions & 33 deletions

File tree

ClaudeIsland/App/AppDelegate.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5858
}
5959
NSApplication.shared.setActivationPolicy(.accessory)
6060

61+
// Check accessibility permission on launch
62+
self.checkAccessibilityPermission()
63+
6164
self.windowManager = WindowManager()
6265
_ = self.windowManager?.setupNotchWindow()
6366

@@ -87,6 +90,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
8790
// Stop interrupt watchers
8891
InterruptWatcherManager.shared.stopAll()
8992

93+
// Stop accessibility permission monitoring
94+
AccessibilityPermissionManager.shared.stopPeriodicMonitoring()
95+
9096
self.updateCheckTimer?.invalidate()
9197
self.screenObserver = nil
9298
}
@@ -119,4 +125,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
119125

120126
return true
121127
}
128+
129+
private func checkAccessibilityPermission() {
130+
let manager = AccessibilityPermissionManager.shared
131+
132+
if !manager.isAccessibilityEnabled {
133+
logger.warning("Accessibility permission not granted on launch")
134+
135+
// Start periodic monitoring so UI updates when permission is granted
136+
manager.startPeriodicMonitoring()
137+
138+
// Show explanatory alert after a brief delay to explain why permission is needed
139+
// The alert offers "Open Settings" to guide users to grant permission
140+
Task { @MainActor in
141+
try? await Task.sleep(for: .seconds(1.0))
142+
manager.showPermissionAlert()
143+
}
144+
}
145+
}
122146
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
//
2+
// AccessibilityPermissionManager.swift
3+
// ClaudeIsland
4+
//
5+
// Monitors macOS Accessibility permission status and provides UI integration
6+
//
7+
8+
import AppKit
9+
import ApplicationServices
10+
import os
11+
12+
private let logger = Logger(subsystem: "com.engels74.ClaudeIsland", category: "AccessibilityPermission")
13+
14+
// MARK: - AccessibilityPermissionManager
15+
16+
/// Manages Accessibility permission state for the app.
17+
/// Required for global mouse event monitoring and CGEvent posting.
18+
@Observable
19+
@MainActor
20+
final class AccessibilityPermissionManager {
21+
// MARK: Lifecycle
22+
23+
private init() {
24+
self.checkPermission()
25+
}
26+
27+
// MARK: Internal
28+
29+
static let shared = AccessibilityPermissionManager()
30+
31+
/// Current accessibility permission state
32+
private(set) var isAccessibilityEnabled = false
33+
34+
/// Check the current permission state
35+
func checkPermission() {
36+
let previousState = self.isAccessibilityEnabled
37+
let newState = AXIsProcessTrusted()
38+
self.isAccessibilityEnabled = newState
39+
40+
// Always log at info level so it appears in Console
41+
logger.info("Accessibility check: AXIsProcessTrusted() = \(newState)")
42+
43+
// Log state changes prominently
44+
if previousState != newState {
45+
logger.warning("Accessibility permission CHANGED: \(previousState) -> \(newState)")
46+
}
47+
}
48+
49+
/// Start periodic monitoring until permission is granted
50+
/// Uses adaptive polling: 0.5s for first 30s, then 2s thereafter
51+
func startPeriodicMonitoring() {
52+
// Don't start if already monitoring
53+
guard self.dispatchTimer == nil else { return }
54+
55+
// If already enabled, no need to monitor
56+
if self.isAccessibilityEnabled { return }
57+
58+
logger.info("Starting periodic accessibility permission monitoring (fast mode)")
59+
60+
// Record start time for adaptive polling
61+
self.monitoringStartTime = Date()
62+
self.currentPollingInterval = self.fastPollingInterval
63+
64+
// Use DispatchSourceTimer for reliable firing regardless of run loop state
65+
let timer = DispatchSource.makeTimerSource(queue: .main)
66+
timer.schedule(deadline: .now(), repeating: .milliseconds(Int(self.fastPollingInterval * 1000)))
67+
timer.setEventHandler { [weak self] in
68+
Task { @MainActor [weak self] in
69+
guard let self else { return }
70+
self.checkPermission()
71+
72+
if self.isAccessibilityEnabled {
73+
logger.info("Accessibility permission granted, stopping monitoring")
74+
self.stopPeriodicMonitoring()
75+
} else {
76+
self.adjustPollingIntervalIfNeeded()
77+
}
78+
}
79+
}
80+
timer.resume()
81+
self.dispatchTimer = timer
82+
}
83+
84+
/// Stop periodic monitoring
85+
func stopPeriodicMonitoring() {
86+
self.dispatchTimer?.cancel()
87+
self.dispatchTimer = nil
88+
self.monitoringStartTime = nil
89+
}
90+
91+
/// Prompt for accessibility permission using system dialog
92+
/// This triggers the macOS "App would like to control this computer" dialog
93+
func promptForPermission() {
94+
logger.info("Prompting for accessibility permission")
95+
96+
// AXIsProcessTrustedWithOptions with prompt=true triggers the system dialog
97+
// Use CFString literal directly to avoid concurrency issues with kAXTrustedCheckOptionPrompt
98+
let options = ["AXTrustedCheckOptionPrompt" as CFString: true] as CFDictionary
99+
_ = AXIsProcessTrustedWithOptions(options)
100+
101+
// Start monitoring after prompting so we detect when user grants permission
102+
self.startPeriodicMonitoring()
103+
}
104+
105+
/// Open System Preferences directly to Accessibility settings
106+
func openAccessibilitySettings() {
107+
logger.info("Opening Accessibility settings")
108+
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
109+
NSWorkspace.shared.open(url)
110+
}
111+
// Start monitoring after opening settings
112+
self.startPeriodicMonitoring()
113+
}
114+
115+
/// Show an alert explaining why accessibility permission is needed
116+
/// Returns true if user clicked "Open Settings", false if they clicked "Later"
117+
@discardableResult
118+
func showPermissionAlert() -> Bool {
119+
// CRITICAL: Before showing modal alert, hide the notch window
120+
// The notch window sits at a high window level and visually blocks the alert
121+
let notchWindow = NSApp.windows.first { $0 is NotchPanel }
122+
let wasVisible = notchWindow?.isVisible ?? false
123+
notchWindow?.orderOut(nil)
124+
125+
let alert = NSAlert()
126+
alert.messageText = "Accessibility Permission Required"
127+
alert.informativeText = """
128+
Claude Island needs Accessibility permission to:
129+
130+
• Monitor mouse position to show/hide the notch
131+
• Pass clicks through to apps behind the notch
132+
133+
Without this permission, the app will have limited functionality.
134+
135+
If you previously granted permission but it stopped working after an update, you may need to remove and re-add the app in System Settings.
136+
"""
137+
alert.alertStyle = .warning
138+
alert.addButton(withTitle: "Open Settings")
139+
alert.addButton(withTitle: "Later")
140+
141+
let response = alert.runModal()
142+
143+
// Restore notch window visibility after alert dismissal
144+
if wasVisible {
145+
notchWindow?.orderFront(nil)
146+
}
147+
148+
if response == .alertFirstButtonReturn {
149+
self.openAccessibilitySettings()
150+
return true
151+
}
152+
153+
return false
154+
}
155+
156+
/// Handle app becoming active - check permission and restart fast polling if needed
157+
func handleAppActivation() {
158+
let previousState = self.isAccessibilityEnabled
159+
self.checkPermission()
160+
161+
// If still not enabled and we were monitoring, restart fast polling
162+
if !self.isAccessibilityEnabled && self.dispatchTimer != nil {
163+
logger.info("App activated while monitoring - restarting fast polling")
164+
self.stopPeriodicMonitoring()
165+
self.startPeriodicMonitoring()
166+
}
167+
168+
if previousState != self.isAccessibilityEnabled {
169+
logger.warning("Permission detected on activation: \(previousState) -> \(self.isAccessibilityEnabled)")
170+
}
171+
}
172+
173+
// MARK: Private
174+
175+
@ObservationIgnored private var dispatchTimer: DispatchSourceTimer?
176+
177+
/// Time when monitoring started (for adaptive polling)
178+
@ObservationIgnored private var monitoringStartTime: Date?
179+
180+
/// Duration of fast polling after monitoring starts (30 seconds)
181+
private let fastPollingDuration: TimeInterval = 30.0
182+
183+
/// Fast polling interval during initial monitoring
184+
private let fastPollingInterval: TimeInterval = 0.5
185+
186+
/// Slow polling interval after initial period
187+
private let slowPollingInterval: TimeInterval = 2.0
188+
189+
/// Current polling interval (tracks which mode we're in)
190+
@ObservationIgnored private var currentPollingInterval: TimeInterval = 0.5
191+
192+
/// Adjust polling interval from fast to slow after the initial period
193+
private func adjustPollingIntervalIfNeeded() {
194+
guard let startTime = self.monitoringStartTime,
195+
self.currentPollingInterval == self.fastPollingInterval
196+
else { return }
197+
198+
let elapsed = Date().timeIntervalSince(startTime)
199+
if elapsed >= self.fastPollingDuration {
200+
logger.info("Switching to slow polling mode after \(Int(elapsed))s")
201+
self.currentPollingInterval = self.slowPollingInterval
202+
self.dispatchTimer?.schedule(
203+
deadline: .now() + self.slowPollingInterval,
204+
repeating: .seconds(Int(self.slowPollingInterval))
205+
)
206+
}
207+
}
208+
}

ClaudeIsland/UI/Views/NotchHeaderView.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,61 @@ struct ReadyForInputIndicatorIcon: View {
206206
]
207207
}
208208

209+
// MARK: - AccessibilityWarningIcon
210+
211+
/// Pixel art warning icon for missing accessibility permission
212+
struct AccessibilityWarningIcon: View {
213+
// MARK: Lifecycle
214+
215+
init(size: CGFloat = 14, color: Color = TerminalColors.amber) {
216+
self.size = size
217+
self.color = color
218+
}
219+
220+
// MARK: Internal
221+
222+
let size: CGFloat
223+
let color: Color
224+
225+
var body: some View {
226+
Canvas { context, _ in
227+
let scale = self.size / 30.0
228+
let pixelSize: CGFloat = 4 * scale
229+
230+
for (x, y) in self.pixels {
231+
let rect = CGRect(
232+
x: x * scale - pixelSize / 2,
233+
y: y * scale - pixelSize / 2,
234+
width: pixelSize,
235+
height: pixelSize
236+
)
237+
context.fill(Path(rect), with: .color(self.color))
238+
}
239+
}
240+
.frame(width: self.size, height: self.size)
241+
}
242+
243+
// MARK: Private
244+
245+
/// Triangle warning shape pixel positions (at 30x30 scale)
246+
private let pixels: [(CGFloat, CGFloat)] = [
247+
// Top point
248+
(15, 3),
249+
// Second row
250+
(13, 7), (17, 7),
251+
// Third row
252+
(11, 11), (15, 11), (19, 11),
253+
// Fourth row
254+
(9, 15), (15, 15), (21, 15),
255+
// Fifth row
256+
(7, 19), (15, 19), (23, 19),
257+
// Bottom row (base of triangle)
258+
(5, 23), (9, 23), (13, 23), (17, 23), (21, 23), (25, 23),
259+
// Exclamation mark (inside triangle)
260+
(15, 27),
261+
]
262+
}
263+
209264
// MARK: - SessionStateDots
210265

211266
/// Displays colored dots representing session states in minimized notch

0 commit comments

Comments
 (0)