Skip to content

Add accessibility onboarding flow - #9

Merged
supagroova merged 4 commits into
mainfrom
feat/assessibility-flow
Mar 19, 2026
Merged

Add accessibility onboarding flow#9
supagroova merged 4 commits into
mainfrom
feat/assessibility-flow

Conversation

@supagroova

@supagroova supagroova commented Mar 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replace the old accessibility permission check (which force-quit the app) with a guided SwiftUI onboarding window
  • New AccessibilityOnboardingView walks users through enabling accessibility permissions in System Settings, with a direct deep link and screenshot
  • Polls for permission grant and automatically transitions to normal operation once trusted
  • Menu bar icon click re-shows the onboarding window during the onboarding phase instead of opening settings

Test plan

  • Launch app without accessibility permissions — verify onboarding window appears
  • Click "Open System Settings" — verify it opens to the correct Privacy pane
  • Grant permission — verify the app automatically detects it and starts normally
  • Click menu bar icon during onboarding — verify it re-shows the onboarding window
  • Launch app with permissions already granted — verify it starts normally without onboarding
  • Run AccessibilityOnboardingTests — verify all tests pass

🤖 Generated with Claude Code

Replace the old accessibility check that force-quit the app with a
guided onboarding window. When permissions are missing, a SwiftUI
window walks users through enabling accessibility in System Settings.
Polls for permission grant and automatically proceeds to normal
operation once trusted.
@greptile-apps

greptile-apps Bot commented Mar 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the old hard exit(1) accessibility-permission check with a polished SwiftUI onboarding window that guides users through enabling the permission, polls for the grant, and gracefully resumes normal operation — a significant UX improvement.

Key changes and findings:

  • AccessibilityOnboardingView + AccessibilityOnboardingBridge: new Swift/ObjC bridge is well-structured; the settings deep-link URL has been updated to the Ventura-compatible format per prior review; NSApp.activate deprecation on macOS 14 is properly guarded.
  • EMRAppDelegate.m: handlePermissionRevoked correctly tears down the event tap and workspace observers before re-entering onboarding, but showAccessibilityOnboarding does not guard against being called while onboardingBridge is already set. Rapid successive dispatch_async calls from myCGEventCallback can cause the old window to be orphaned on screen while a second window is shown.
  • Two .foregroundColor(.secondary) calls in AccessibilityOnboardingView should be updated to .foregroundStyle(.secondary) to avoid deprecation warnings on macOS 14+.
  • Test coverage is thorough: both AccessibilityOnboardingTests.swift and the new EMRAppDelegateTest.m cases cover the key lifecycle scenarios, including double-revocation and null event tap.

Confidence Score: 3/5

  • Safe to merge after fixing the orphaned-window bug in showAccessibilityOnboarding; the rest of the changes are solid.
  • The core onboarding flow is well-designed and well-tested. One P1 logic bug exists: showAccessibilityOnboarding can produce multiple stacked windows when called in rapid succession, which is a real (if edge-case) UX defect. The two deprecated API calls are minor. Once the guard is added the PR is ready.
  • zooom3/EMRAppDelegate.mshowAccessibilityOnboarding needs a guard against re-entrant calls while onboarding is already active.

Important Files Changed

Filename Overview
zooom3/AccessibilityOnboardingView.swift New SwiftUI onboarding view and ObjC bridge. The view structure and polling logic are sound. Two deprecated .foregroundColor(.secondary) calls should be replaced with .foregroundStyle(.secondary). The two-step root-view mutation in init is addressed in a prior thread.
zooom3/EMRAppDelegate.m Core delegate integrates the onboarding flow. Logic is mostly correct, but showAccessibilityOnboarding lacks a guard against being called while already onboarding, which can leave orphaned windows on screen when handlePermissionRevoked fires multiple times in quick succession.
zooom3Tests/AccessibilityOnboardingTests.swift Good unit test coverage for the Swift bridge: view creation, callback wiring, polling lifecycle, and settings URL validation are all exercised.
zooom3Tests/EMRAppDelegateTest.m Solid Objective-C tests cover permission-revocation edge cases including double-revocation, null event tap, workspace observer removal, and popover-during-onboarding behaviour.
zooom3/EMRAppDelegate.h Minimal header change: forward declares AccessibilityOnboardingBridge and adds the ivar. No issues.

Sequence Diagram

sequenceDiagram
    participant App as applicationDidFinishLaunching
    participant Del as EMRAppDelegate
    participant Bridge as AccessibilityOnboardingBridge
    participant Timer as Poll Timer (1s)
    participant Sys as System Settings

    App->>Del: applicationDidFinishLaunching
    Del->>Del: AXIsProcessTrusted()?
    alt Permission granted
        Del->>Del: setupEventTapAndObservers()
    else Permission missing
        Del->>Bridge: alloc/init (creates NSWindow)
        Del->>Bridge: setOnPermissionGranted: block
        Del->>Bridge: showWindow()
        Del->>Bridge: startPolling()
        Bridge->>Timer: scheduledTimer(1s, repeats:true)
        loop Every 1 second
            Timer->>Bridge: AXIsProcessTrusted()?
            Bridge-->>Timer: false → keep polling
        end
        Note over Bridge,Sys: User clicks "Open System Settings"
        Bridge->>Sys: NSWorkspace.open(settingsURL)
        Note over Sys: User enables Zooom3 in Accessibility
        Timer->>Bridge: AXIsProcessTrusted() → true
        Bridge->>Bridge: stopPolling()
        Bridge->>Del: onPermissionGranted() [dispatch_async main]
        Del->>Bridge: closeWindow() → stopPolling + orderOut
        Del->>Del: onboardingBridge = nil
        Del->>Del: setupEventTapAndObservers()
    end

    Note over Del: Permission later revoked
    Del->>Del: myCGEventCallback: kCGEventTapDisabledByTimeout/UserInput
    Del->>Del: dispatch_async → handlePermissionRevoked()
    Del->>Del: removeObserver (workspace), teardown event tap
    Del->>Bridge: showAccessibilityOnboarding() → new bridge
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: zooom3/EMRAppDelegate.m
Line: 562-574

Comment:
**Orphaned onboarding window when `showAccessibilityOnboarding` is called while already onboarding**

`showAccessibilityOnboarding` unconditionally replaces `onboardingBridge` without first dismissing the existing window. When `handlePermissionRevoked` is called a second time while onboarding is already active (e.g., multiple `kCGEventTapDisabledByTimeout` events queuing up on the main thread before the first `dispatch_async` is processed), ARC releases the old bridge and its `deinit` only calls `stopPolling()` — there is no `window.orderOut(nil)`. The old window remains visible while the new bridge immediately calls `showWindow()`, stacking a second onboarding window on top of the first.

Add an early return guard at the top of `showAccessibilityOnboarding`:

```objc
- (void)showAccessibilityOnboarding {
    if (onboardingBridge != nil) {
        // Already onboarding — just bring the existing window to front
        if (@available(macOS 13.0, *)) {
            [onboardingBridge showWindow];
        }
        return;
    }
    if (@available(macOS 13.0, *)) {
        onboardingBridge = [[AccessibilityOnboardingBridge alloc] init];
        __weak typeof(self) weakSelf = self;
        [onboardingBridge setOnPermissionGranted:^{
            dispatch_async(dispatch_get_main_queue(), ^{
                [weakSelf accessibilityPermissionGranted];
            });
        }];
        [onboardingBridge showWindow];
        [onboardingBridge startPolling];
    }
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: zooom3/AccessibilityOnboardingView.swift
Line: 21-24

Comment:
**`foregroundColor(_:)` is deprecated in favour of `foregroundStyle(_:)`**

Both uses of `.foregroundColor(.secondary)` (lines 22 and 49) trigger a deprecation warning on macOS 14+. The replacement is `.foregroundStyle(.secondary)`, which also supports multi-layer styles available since macOS 13 — matching the deployment target.

```suggestion
                .foregroundStyle(.secondary)
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: zooom3/AccessibilityOnboardingView.swift
Line: 47-50

Comment:
**Second `foregroundColor(.secondary)` also deprecated**

Same deprecation as above — the `"Waiting for permission..."` label also uses `.foregroundColor(.secondary)`:

```suggestion
                    .foregroundStyle(.secondary)
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "Fix duplicate worksp..."

Comment thread zooom3/Images.xcassets/AccessibilitySettings.imageset/Contents.json
Comment thread zooom3/AccessibilityOnboardingView.swift Outdated
Comment thread zooom3/AccessibilityOnboardingView.swift
Comment thread zooom3/AccessibilityOnboardingView.swift
Comment thread zooom3/EMRAppDelegate.m
When permissions were revoked while the app was running, the event tap
callback would enter a tight loop re-enabling the tap that the system
kept disabling. Now checks AXIsProcessTrusted() before re-enabling and
gracefully tears down the tap, showing the onboarding flow to re-grant
permission. Includes tests for the revocation handling path.
Comment thread zooom3/EMRAppDelegate.m
Comment thread zooom3/EMRAppDelegate.m
…ant cycle

handlePermissionRevoked now removes workspace session observers before they
get re-registered by setupEventTapAndObservers on permission re-grant.
Comment thread zooom3/EMRAppDelegate.m
Comment on lines +562 to +574
- (void)showAccessibilityOnboarding {
if (@available(macOS 13.0, *)) {
onboardingBridge = [[AccessibilityOnboardingBridge alloc] init];
__weak typeof(self) weakSelf = self;
[onboardingBridge setOnPermissionGranted:^{
dispatch_async(dispatch_get_main_queue(), ^{
[weakSelf accessibilityPermissionGranted];
});
}];
[onboardingBridge showWindow];
[onboardingBridge startPolling];
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Orphaned onboarding window when showAccessibilityOnboarding is called while already onboarding

showAccessibilityOnboarding unconditionally replaces onboardingBridge without first dismissing the existing window. When handlePermissionRevoked is called a second time while onboarding is already active (e.g., multiple kCGEventTapDisabledByTimeout events queuing up on the main thread before the first dispatch_async is processed), ARC releases the old bridge and its deinit only calls stopPolling() — there is no window.orderOut(nil). The old window remains visible while the new bridge immediately calls showWindow(), stacking a second onboarding window on top of the first.

Add an early return guard at the top of showAccessibilityOnboarding:

- (void)showAccessibilityOnboarding {
    if (onboardingBridge != nil) {
        // Already onboarding — just bring the existing window to front
        if (@available(macOS 13.0, *)) {
            [onboardingBridge showWindow];
        }
        return;
    }
    if (@available(macOS 13.0, *)) {
        onboardingBridge = [[AccessibilityOnboardingBridge alloc] init];
        __weak typeof(self) weakSelf = self;
        [onboardingBridge setOnPermissionGranted:^{
            dispatch_async(dispatch_get_main_queue(), ^{
                [weakSelf accessibilityPermissionGranted];
            });
        }];
        [onboardingBridge showWindow];
        [onboardingBridge startPolling];
    }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: zooom3/EMRAppDelegate.m
Line: 562-574

Comment:
**Orphaned onboarding window when `showAccessibilityOnboarding` is called while already onboarding**

`showAccessibilityOnboarding` unconditionally replaces `onboardingBridge` without first dismissing the existing window. When `handlePermissionRevoked` is called a second time while onboarding is already active (e.g., multiple `kCGEventTapDisabledByTimeout` events queuing up on the main thread before the first `dispatch_async` is processed), ARC releases the old bridge and its `deinit` only calls `stopPolling()` — there is no `window.orderOut(nil)`. The old window remains visible while the new bridge immediately calls `showWindow()`, stacking a second onboarding window on top of the first.

Add an early return guard at the top of `showAccessibilityOnboarding`:

```objc
- (void)showAccessibilityOnboarding {
    if (onboardingBridge != nil) {
        // Already onboarding — just bring the existing window to front
        if (@available(macOS 13.0, *)) {
            [onboardingBridge showWindow];
        }
        return;
    }
    if (@available(macOS 13.0, *)) {
        onboardingBridge = [[AccessibilityOnboardingBridge alloc] init];
        __weak typeof(self) weakSelf = self;
        [onboardingBridge setOnPermissionGranted:^{
            dispatch_async(dispatch_get_main_queue(), ^{
                [weakSelf accessibilityPermissionGranted];
            });
        }];
        [onboardingBridge showWindow];
        [onboardingBridge startPolling];
    }
}
```

How can I resolve this? If you propose a fix, please make it concise.

@supagroova
supagroova merged commit 81be1bc into main Mar 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant