Skip to content

fix: stop repeated screen recording permission prompts on macOS Sequoia/Tahoe#14

Merged
SamuelZ12 merged 2 commits intomainfrom
fix/issue-12-permission-reprompt
Feb 12, 2026
Merged

fix: stop repeated screen recording permission prompts on macOS Sequoia/Tahoe#14
SamuelZ12 merged 2 commits intomainfrom
fix/issue-12-permission-reprompt

Conversation

@SamuelZ12
Copy link
Copy Markdown
Owner

@SamuelZ12 SamuelZ12 commented Feb 12, 2026

Closes #12

Problem

Screen Recording permission keeps re-prompting even after granting access on macOS Sequoia and Tahoe. The permission APIs on macOS 15+ can return false negatives and transient errors, causing the app to repeatedly show the system permission dialog.

Changes

  • Modern macOS detectionisModernMacOS flag for macOS 15+ where permission APIs are unreliable
  • Suppress automatic re-prompts — after initial grant or prompt, automatic checks poll silently instead of showing the dialog again
  • User-initiated vs automatic — onboarding button passes userInitiated: true so explicit checks always work, but background checks don't spam
  • Treat -3801 as transient on modern macOSSCStreamErrorUserDeclined can fire falsely on Sequoia/Tahoe
  • Persist prompt state — tracks hasRequestedPermission and lastPromptDate in UserDefaults so state survives restarts
  • Stop polling on grant — properly calls stopPolling() in paths that were missing it

Note

Medium Risk
Adjusts screen recording permission prompting and persistence logic; behavior changes could unintentionally suppress needed prompts or delay detection of granted permissions, especially on macOS 15+ where APIs are flaky.

Overview
Reduces repeated Screen Recording permission dialogs on macOS 15+ by distinguishing user-initiated vs automatic checks and suppressing automatic re-prompts after a prior prompt/verification, falling back to silent polling instead.

Updates ScreenCapturePermissionManager to persist prompt/verification state in UserDefaults (verified/requested flags and last prompt time), treat -3801 as transient on modern macOS, and more consistently stop polling when permission is detected. The onboarding Recheck Permission button now calls requestPermissionAndStartMonitoring(userInitiated: true) to always allow an explicit re-prompt.

Written by Cursor Bugbot for commit 8196500. This will update automatically on new commits. Configure here.

…ia/Tahoe (#12)

- Detect modern macOS (15+) where permission APIs are flaky
- Suppress automatic re-prompts after initial grant/request
- Treat SCStream error -3801 as transient on modern macOS
- Persist prompt state in UserDefaults across app restarts
- Properly stop polling when permission is granted
- Allow user-initiated checks to always show dialog
@claude
Copy link
Copy Markdown

claude bot commented Feb 12, 2026

PR Review: Fix Screen Recording Permission Re-prompting on macOS Sequoia/Tahoe

Summary

This PR addresses issue #12 by implementing sophisticated logic to handle unreliable permission APIs on macOS 15+. The approach is well-reasoned and tackles a real macOS Sequoia/Tahoe bug with permission verification.


✅ Strengths

1. Problem Understanding

  • Correctly identifies that macOS 15+ has flaky permission APIs that can return false negatives
  • Smart distinction between user-initiated vs automatic permission checks
  • Good use of persistent state across app restarts

2. Code Quality

  • Clean separation of concerns with VerificationResult enum
  • Comprehensive logging for debugging permission issues
  • Consistent use of defaults instance for UserDefaults access
  • Proper @MainActor usage for thread-safe UI updates

3. Logic Improvements

  • Multiple missing stopPolling() calls fixed (ScreenCapturePermissionManager.swift:135, 226) - prevents unnecessary background work
  • Cooldown now considers persisted dates (ScreenCapturePermissionManager.swift:184-185) - handles app restarts correctly
  • User-initiated checks bypass all suppression (ScreenCapturePermissionManager.swift:187-188) - ensures users can always retry manually

⚠️ Issues & Concerns

1. Critical: Potential Permission Suppression Bug

Location: ScreenCapturePermissionManager.swift:139-141

let shouldSuppressAutomaticDialog = isModernMacOS
    && !userInitiated
    && (hadPermissionBefore || hasRequestedBefore)

Problem: If a user:

  1. Launches the app for the first time on macOS 15+
  2. Dismisses the permission dialog (doesn't grant)
  3. Restarts the app

The app will suppress future automatic prompts because hasRequestedBefore is now true, even though permission was never granted. The user would need to manually click "Recheck Permission" in the onboarding UI.

Recommendation: Consider only suppressing if hadPermissionBefore is true (confirmed grant), not just hasRequestedBefore:

let shouldSuppressAutomaticDialog = isModernMacOS
    && !userInitiated
    && hadPermissionBefore  // Only suppress if we know permission was granted before

Or add a third state to track "permission was explicitly denied" separately from "permission was requested".


2. Moderate: Error Code Interpretation on macOS 15+

Location: ScreenCapturePermissionManager.swift:78

let isDefinitiveDenial = error.code == -3801 && !isModernMacOS

Concern: On macOS 15+, error code -3801 (SCStreamErrorUserDeclined) is treated as a transient error. However, if the user actually declines permission on macOS 15+, the app won't recognize it as a denial and will keep retrying/polling indefinitely.

Current behavior on macOS 15+ when user denies:

  • Returns .transientError instead of .denied
  • Falls into polling mode instead of showing proper "permission denied" state
  • User experience: app keeps waiting indefinitely for permission that will never come

Recommendation: Consider a hybrid approach:

  • Track consecutive -3801 errors
  • If -3801 occurs 3+ times in a row, treat it as likely denial even on macOS 15+
  • Or add a timeout: after N retries over M minutes without success, ask user to manually check System Settings

3. Minor: Race Condition in Init

Location: ScreenCapturePermissionManager.swift:42-45

hasPermission = CGPreflightScreenCaptureAccess()
if hasPermission {
    defaults.set(true, forKey: verifiedPermissionKey)
}

Issue: On macOS 15+, CGPreflightScreenCaptureAccess() can return false negatives. Setting verifiedPermissionKey here could be premature.

Impact: Low - the requestPermissionAndStartMonitoring() call will re-verify and correct the state.

Recommendation: Either:

  • Don't set verifiedPermissionKey in init() on macOS 15+
  • Add a comment explaining this is an optimistic cache that gets verified later

4. Performance: Polling Efficiency

Location: ScreenCapturePermissionManager.swift:270

let result = await verifyPermissionViaScreenCaptureKit(maxAttempts: 2, delaySeconds: 0.5)

Concern: During polling (every 2 seconds), we make up to 2 ScreenCaptureKit attempts with 0.5s delay between them. This means:

  • Each poll can take up to 1 second (2 attempts × 0.5s delay)
  • With a 2-second polling interval, we're using 50% of CPU time on permission checks
  • On macOS 15+ where checks are flaky, this could be wasteful

Recommendation:

  • Consider reducing to 1 attempt during polling (save the retries for initial verification)
  • Or increase polling interval to 3-5 seconds to reduce overhead

5. Security: UserDefaults Keys Not Namespaced

Location: ScreenCapturePermissionManager.swift:15-17

private let verifiedPermissionKey = "hasVerifiedScreenRecordingPermission"
private let hasRequestedPermissionKey = "hasRequestedScreenRecordingPermission"
private let lastPromptDateKey = "lastScreenRecordingPermissionPromptDate"

Concern: While unlikely to collide, these keys could theoretically conflict with other apps or future macOS features.

Best Practice: Namespace with bundle identifier:

private let verifiedPermissionKey = "app.samuelz12.screenscribe.hasVerifiedScreenRecordingPermission"

Impact: Very low - this is being overly cautious


🧪 Testing Concerns

Missing Test Coverage

The project has no unit tests (no test files found). Given the complexity of this permission logic, consider adding tests for:

  1. State machine behavior:

    • Different permission states across app restarts
    • Cooldown timer logic
    • shouldSuppressAutomaticDialog conditions
  2. Edge cases:

    • User dismisses permission dialog (doesn't grant or deny)
    • User revokes permission after granting
    • Multiple rapid permission checks
    • macOS version boundary (14.x → 15.0)
  3. Mock testing:

    • Mock CGPreflightScreenCaptureAccess return values
    • Mock ScreenCaptureKit errors
    • Mock UserDefaults state

Recommendation: While not blocking for this PR, consider adding XCTest target for future changes.


🔒 Security Assessment

No security vulnerabilities identified.

  • Permission APIs are read-only or trigger system dialogs (no privilege escalation)
  • UserDefaults persistence is appropriate for this use case (no sensitive data)
  • No injection points or untrusted input processing

📝 Documentation

Good:

  • Inline comments explain macOS version-specific behavior
  • Logger statements provide clear debugging trail

Could Improve:

  • Add doc comments to public methods (e.g., requestPermissionAndStartMonitoring(userInitiated:))
  • Document the state machine: what combinations of UserDefaults keys mean

🎯 Recommendations Summary

Must Fix (before merge):

  1. Review suppression logic - ensure dismissed (not denied) permissions don't permanently suppress prompts

Should Fix (before merge):

  1. Handle actual denials on macOS 15+ - don't poll forever when user explicitly denies
  2. Reduce polling overhead - decrease attempts or increase interval

Nice to Have (future work):

  1. Init verification on macOS 15+ - add clarifying comment
  2. Namespace UserDefaults keys - low priority hygiene
  3. Add unit tests - valuable for future changes

Final Verdict

Conditional Approval ✅ with minor fixes

This PR makes meaningful progress on a tricky macOS bug. The architecture is sound, but the edge case handling needs refinement to avoid permanently suppressing prompts for users who initially dismissed (not denied) the dialog.

The code quality is high and follows the project's Swift/SwiftUI patterns well. Once the suppression logic is reviewed and the polling behavior is optimized, this should be good to merge.

Great work tackling this challenging macOS API quirk! 🎉

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

This PR is being reviewed by Cursor Bugbot

Details

You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes an issue where Screen Recording permission repeatedly prompts users on macOS Sequoia (15+) even after granting access. The root cause is that macOS 15+ has unreliable permission APIs that can return false negatives and transient errors. The solution implements intelligent suppression of automatic permission dialogs while preserving user-initiated permission checks.

Changes:

  • Adds macOS version detection to identify macOS 15+ where permission APIs are unreliable
  • Implements state persistence (verified permission, request history, last prompt date) across app restarts to prevent repeated automatic prompts
  • Distinguishes between user-initiated and automatic permission checks, suppressing automatic re-prompts after initial interaction while allowing manual rechecks
  • Treats error -3801 (SCStreamErrorUserDeclined) as transient on modern macOS rather than definitive denial
  • Adds missing stopPolling() calls in permission grant code paths

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
ScreenScribe/Sources/Services/ScreenCapturePermissionManager.swift Adds persistence layer for permission state, implements modern macOS detection, adds userInitiated parameter to distinguish automatic vs manual checks, updates error handling and polling logic
ScreenScribe/Sources/Onboarding/PermissionStepView.swift Marks the "Recheck Permission" button as user-initiated to bypass automatic suppression logic

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 190 to 191
let timeSinceLastPopup = now.timeIntervalSince(lastTime)
shouldShowPopup = timeSinceLastPopup >= popupCooldownInterval
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The cooldown logic compares the current time with persisted dates using timeIntervalSince. If the system clock is adjusted backward or the persisted date is somehow in the future, timeIntervalSince could return a negative value, causing timeSinceLastPopup >= popupCooldownInterval to be false even when it should be true. This could result in the permission dialog being suppressed indefinitely until the user manually triggers a recheck. Consider adding a check to handle negative time intervals or dates in the future by treating them as if the cooldown has expired.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to 39
private var isModernMacOS: Bool {
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 15
}

private init() {
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

The isModernMacOS property uses majorVersion >= 15 to detect macOS Sequoia/Tahoe and newer. However, this is a computed property that's called multiple times throughout the code. Consider making this a stored property initialized once in init() for better performance, or add a comment explaining why it needs to be computed (in case the OS version could change at runtime, which is unlikely).

Suggested change
private var isModernMacOS: Bool {
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 15
}
private init() {
private let isModernMacOS: Bool
private init() {
self.isModernMacOS = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 15

Copilot uses AI. Check for mistakes.
// On macOS Sequoia/Tahoe, these can also occur transiently during cold boot
let isDefinitiveDenial = error.code == -3801
// On macOS Sequoia/Tahoe+, -3801 can still be transient, so only trust it on older macOS
let isDefinitiveDenial = error.code == -3801 && !isModernMacOS
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

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

On modern macOS (15+), error code -3801 is never treated as a definitive denial due to the condition !isModernMacOS. This means if a user explicitly revokes a previously granted permission in System Settings, verifyPermissionViaScreenCaptureKit returns .transientError instead of .denied, and the verifiedPermissionKey is never cleared. With hadPermissionBefore remaining true, the app will suppress automatic permission dialogs and poll silently indefinitely. The user would need to manually click a "Recheck Permission" button to trigger a new prompt. Consider adding logic to detect when permission has been revoked (e.g., checking for consistent -3801 errors over multiple polling iterations) and clearing the verified flag in that case, or documenting this as expected behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8749329ad4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// On macOS Sequoia/Tahoe, these can also occur transiently during cold boot
let isDefinitiveDenial = error.code == -3801
// On macOS Sequoia/Tahoe+, -3801 can still be transient, so only trust it on older macOS
let isDefinitiveDenial = error.code == -3801 && !isModernMacOS
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 Badge Handle real -3801 denials on modern macOS

In verifyPermissionViaScreenCaptureKit, -3801 is no longer treated as a definitive denial on macOS 15+ (isDefinitiveDenial at line 78), but the downstream flow still routes prior-granted users through the transient path that only starts polling. When a user actually revokes Screen Recording permission after previously granting it, requestPermissionAndStartMonitoring (called automatically from App.proceedWithNormalStartup()) now keeps polling and never re-requests access, so the app can remain stuck in limited mode without an automatic recovery prompt.

Useful? React with 👍 / 👎.

@SamuelZ12 SamuelZ12 merged commit 0a96035 into main Feb 12, 2026
3 checks passed
@SamuelZ12 SamuelZ12 deleted the fix/issue-12-permission-reprompt branch February 12, 2026 03:20
@claude
Copy link
Copy Markdown

claude bot commented Feb 12, 2026

Pull Request Review

This PR addresses a critical user experience issue on macOS 15+ (Sequoia/Tahoe) where screen recording permission dialogs repeatedly appear even after being granted. The implementation is well-thought-out and demonstrates deep understanding of the platform-specific permission system quirks.

Code Quality & Best Practices ✅

Strengths:

  • Clean separation of concerns: User-initiated vs automatic permission checks is an elegant solution to the API reliability issues
  • Comprehensive logging: Excellent debug logging throughout (ScreenCaptureKit attempts, verification results, decision logic)
  • Progressive retry strategy: Linear backoff with more aggressive retries for known-good users (8 attempts @ 1.5s vs 5 @ 1.0s) is smart
  • State persistence: Proper use of UserDefaults to maintain permission state across app restarts
  • MainActor consistency: Correct usage of @MainActor for thread-safe UI updates

Minor Suggestions:

  1. Magic numbers: Consider extracting retry constants to named properties:

    private let maxAttemptsForNewUsers = 5
    private let maxAttemptsForReturningUsers = 8
    private let baseDelayForNewUsers = 1.0
    private let baseDelayForReturningUsers = 1.5
  2. UserDefaults key management: Keys are well-defined as constants, but consider creating a nested struct Keys for better organization:

    private enum UserDefaultsKeys {
        static let verified = "hasVerifiedScreenRecordingPermission"
        static let requested = "hasRequestedScreenRecordingPermission"
        static let lastPromptDate = "lastScreenRecordingPermissionPromptDate"
    }

Potential Bugs & Issues ⚠️

1. Date Persistence Type Safety (ScreenCapturePermissionManager.swift:192)

let persistedPopupTime = defaults.object(forKey: lastPromptDateKey) as? Date
  • UserDefaults.object(forKey:) returns Any?, and the cast could theoretically fail if data gets corrupted
  • Recommendation: While unlikely, consider defensive coding or data validation on read

2. Race Condition in Timer Polling (ScreenCapturePermissionManager.swift:264-282)

  • The pollPermission() method is async and runs on a 2-second timer
  • If a previous poll is still running when the timer fires again, you could have overlapping ScreenCaptureKit verification attempts
  • Recommendation: Add a flag to skip polling if already in progress:
    private var isPolling = false
    
    private func pollPermission() async {
        guard !isPolling else { return }
        isPolling = true
        defer { isPolling = false }
        // ... existing logic
    }

3. Memory Leak Potential (ScreenCapturePermissionManager.swift:250)

  • Timer uses [weak self] correctly ✅
  • However, the Timer might continue running if stopPolling() isn't called in all code paths
  • Verification needed: Ensure stopPolling() is called in all permission-granted scenarios (appears to be done correctly)

Performance Considerations 🚀

Good:

  • Retry delays use linear backoff, preventing CPU thrashing
  • Early returns when permission is detected (lines 124-127, 131-136)
  • Timer is properly invalidated when no longer needed

Potential Optimization:

  • ScreenCaptureKit verification during polling (line 278): Currently uses 2 attempts with 0.5s delay, which means each poll could take up to 1 second. On a 2-second polling interval, this is 50% duty cycle.
    • Consider reducing to 1 attempt during polling, or increasing polling interval to 3-4 seconds
    • Current approach is defensive, which is appropriate given macOS flakiness, but worth monitoring

Security Concerns 🔒

Low Risk, but worth noting:

  1. UserDefaults storage: Permission state is stored unencrypted in UserDefaults

    • This is appropriate for boolean flags (not sensitive data)
    • However, a malicious process with UserDefaults access could set hasVerifiedScreenRecordingPermission = true to suppress prompts
    • Reality check: This is not a practical security risk since:
      • The actual permission is controlled by macOS TCC (Transparency, Consent, and Control)
      • Manipulating UserDefaults doesn't grant actual screen capture access
      • This is standard practice for macOS apps
  2. No API key or credential exposure: ✅ Clean

Test Coverage 📊

Critical Gap: No automated tests found for this critical permission flow

Recommendation: Consider adding unit tests for:

  • VerificationResult logic (granted/denied/transient detection)
  • shouldSuppressAutomaticDialog decision logic (lines 139-141)
  • UserDefaults state persistence/retrieval
  • Cooldown interval calculations (lines 197-207)

Note: Permission dialogs are hard to test automatically, but the decision logic can be tested with mocks.

Architecture & Design Patterns ⭐

Excellent:

  • Singleton pattern appropriate for system-level permission manager
  • Clear state machine: .granted.denied.transientError
  • Combine publishing for reactive UI updates
  • Separation of read-only (checkPermission) vs mutation (requestPermissionAndStartMonitoring)

Platform-Specific Wisdom:

  • The isModernMacOS flag (line 34-37) is a pragmatic solution to a real platform bug
  • Understanding that -3801 can be transient on Sequoia shows deep macOS knowledge
  • Dual verification strategy (CGPreflight + ScreenCaptureKit) accounts for API inconsistencies

Specific Code Review Comments

ScreenCapturePermissionManager.swift:78

let isDefinitiveDenial = error.code == -3801 && !isModernMacOS

✅ Excellent handling of macOS version-specific behavior. Consider adding a comment explaining why -3801 is unreliable on macOS 15+.

ScreenCapturePermissionManager.swift:167-174

The userInitiated branch that clears verifiedPermissionKey when hadPermissionBefore is true feels slightly risky. If a user legitimately has permission but ScreenCaptureKit is temporarily flaky, this clears their verified state.

  • Counter-argument: This is intentional to force re-verification, which is correct for a user explicitly asking to recheck
  • Verdict: Acceptable, but worth monitoring in production

ScreenCapturePermissionManager.swift:193

let latestPopupTime = [lastPopupTime, persistedPopupTime].compactMap { $0 }.max()

🎯 Elegant way to handle both in-memory and persisted timestamps!

Summary

Overall Assessment: Strong Implementation

This PR demonstrates:

  • Deep understanding of macOS permission API quirks
  • Thoughtful UX design (user-initiated vs automatic)
  • Robust error handling and retry logic
  • Good code organization and documentation

Risk Level: Medium (as noted in PR description)

  • Changes permission flow logic, which is critical for app functionality
  • Appropriate given the severity of the bug being fixed

Recommendation: Approve with minor suggestions

The identified issues are minor and don't block merging. Consider addressing:

  1. Race condition in polling (add isPolling flag)
  2. Extract magic numbers to constants
  3. Add unit tests for decision logic

Testing Checklist (for manual verification):

  • New install on macOS 15+ grants permission correctly
  • App restart doesn't re-prompt after initial grant
  • "Recheck Permission" button still works when clicked
  • Permission revocation is detected and prompts again
  • Cold boot scenario (restart Mac) doesn't spam dialogs
  • Behavior on macOS 14 and earlier unchanged

Great work fixing this frustrating UX issue! 🎉

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.

Screen Recording permission keeps asking even after granting access on macOS

2 participants