Skip to content

fix(macos): raise the Screen Recording prompt on first run - #302

Open
heyitsR1 wants to merge 2 commits into
getopenscreen:mainfrom
heyitsR1:fix/macos-screen-recording-tcc-prompt
Open

fix(macos): raise the Screen Recording prompt on first run#302
heyitsR1 wants to merge 2 commits into
getopenscreen:mainfrom
heyitsR1:fix/macos-screen-recording-tcc-prompt

Conversation

@heyitsR1

@heyitsR1 heyitsR1 commented Aug 8, 2026

Copy link
Copy Markdown

Summary

On macOS, OpenScreen never raises the system Screen Recording prompt. A fresh install goes straight to the "Screen Recording permission is required" dialog, so the only way to grant is toggling the app manually in System Settings.

requestScreenAccess() gates the prompt on status === "not-determined":

const status = systemPreferences.getMediaAccessStatus("screen");
if (status === "granted") { ... }

// Screen recording has no askForMediaAccess equivalent, so trigger the
// TCC prompt without opening OpenScreen's source selector above it.
if (status === "not-determined") {
	desktopCapturer.getSources(...)   // the only call that raises the TCC prompt
}

That branch is unreachable on macOS. Electron resolves the status through Chromium, which reads the permission with CGPreflightScreenCaptureAccess() — a bool (ui/base/cocoa/permissions_utils.mm):

bool IsScreenCaptureAllowed() {
    return CGPreflightScreenCaptureAccess();
}

There is no third state, so "never asked" and "explicitly refused" both arrive as denied.

Repro on macOS 26.2 / Electron 41.2.1, from one process with no kTCCServiceScreenCapture row for its bundle id — never asked for either permission:

SCREEN_STATUS=denied            <- never asked, reported as refused
CAMERA_STATUS=not-determined    <- never asked, reported correctly

Same call, same instant. That asymmetry is the whole bug, and it is why the camera path just below in handlers.ts works while the screen path never fires.

Two consequences:

  1. The prompt is never raised, so open-source-selector always shows the Settings dialog on first run.
  2. The renderer's retry loop is dead for the same reason — shouldRetryAfterPermissionPrompt() in openSourceSelectorFlow.ts only arms when access?.status === "not-determined", so the 8×750ms poll that reopens the selector after a grant never runs.

electron/main.ts already describes the intended behaviour — "Screen Recording is requested lazily from the source-picker action so its prompt isn't hidden behind the selector window" — and the microphone request beside it correctly keys off !== "granted".

What changes

Decide from whether this launch has already asked rather than from the status.

New pure helpers in electron/ipc/screenAccessPrompt.ts (with the reasoning documented) plus colocated unit tests; requestScreenAccess() calls them and records when it asked.

Situation Before After
Granted returns granted unchanged
Not granted, first ask this launch Settings dialog, no prompt raises the OS prompt, returns not-determined so the existing retry loop arms
Not granted, prompt may still be unanswered Settings dialog not-determined held for the renderer's retry budget, so the dialog can't open over the prompt
Not granted, grace window lapsed Settings dialog Settings dialog (real status returned — no re-prompt loop)
not-determined (non-macOS / future Electron) raises prompt unchanged
Non-darwin early return above unchanged

The grace window exists because macOS gives nothing to wait on. desktopCapturer.getSources() settles in 4ms whether or not the prompt is still on screen, and the status stays denied the entire time the prompt is up — it only flips once the user accepts:

status_before=denied
getSources REJECTED after 4ms: undefined
status_at_settle=denied
status_at_1s=denied
status_at_7s=denied

So an in-flight flag around that call would cover about four milliseconds, and reporting the real status straight away would abort openSourceSelectorFlow on its first poll. SCREEN_PROMPT_GRACE_MS is set to the renderer's own budget (8 × 750ms).

The prompt mechanism itself (desktopCapturer.getSources, window focus) is untouched — this only changes when it is reached.

Why it matters beyond the dialog

Because the prompt never runs, users grant through the System Settings toggle instead. On my machine that path produced a TCC row pinned to a bare cdhash rather than the app's designated requirement:

kTCCServiceScreenCapture | com.siddharthvaddem.openscreen | auth_value=2
csreq -> cdhash H"0d786a41badad5969423b5c6f6e0add1264ff1d8"

against an installed 1.5.0 whose cdhash is 8a171c66…. System Settings drew the toggle as on (the row says allowed) while every runtime check failed the requirement — a loop that survives app and machine restarts and looks unfixable to the user. The same app's Microphone, Camera and Audio rows, granted through real OS prompts in the same session, all carry the normal requirement and work:

identifier "com.siddharthvaddem.openscreen" and anchor apple generic
  and certificate leaf[subject.OU] = N26FZ4GW28

I can't fully explain how that particular hash was written and I'm not claiming this PR fixes TCC. But routing first-run grants through the OS prompt is the path that reliably records the signature-based requirement, and it is what every other permission in this app already does.

Related issue

No open issue on this repo — found while debugging a fresh Homebrew install. Happy to file one if you'd like it tracked in the milestone flow.

Type of change

  • Bug fix
  • Feature
  • Enhancement
  • Documentation
  • Refactor / maintenance
  • Performance
  • Security

Release impact

  • Patch
  • Minor
  • Major / breaking change
  • No release note needed

Desktop impact

  • Windows
  • macOS
  • Linux
  • Installer / packaging
  • Not platform-specific

Screenshots / video

No UI change — the difference is which dialog macOS shows on first run (its own prompt instead of OpenScreen's fallback).

Testing

Node 22.22.1 / npm 10.9.4, macOS 26.2 (arm64):

  • npx vitest --run electron/ipc/screenAccessPrompt.test.ts — 7 passed
  • npm run test141 files, 1684 passed, 1 skipped
  • npm run lint — exit 0; warning count identical to main (13 before and after), none in the touched files
  • npx tsc --noEmit — clean
  • npx tsc -p tsconfig.test.json --noEmit — clean
  • npm run docs:check — OK (22 files)

The helpers are pure and have no process.platform branch, so the tests need no platform pinning. now is injected rather than read from the clock, so the grace-window cases are deterministic without fake timers.

Verified on real macOS, per AGENTS.md's requirement for permission changes: the SCREEN_STATUS=denied / CAMERA_STATUS=not-determined repro above was measured on this hardware from a virgin TCC state, which is the premise the fix rests on.

Partially verified end to end: calling desktopCapturer.getSources() from a virgin-TCC bundle did engage macOS — it wrote a kTCCServiceScreenCapture row for the probe bundle (auth_value=0), which I reset afterwards. So the prompt path reaches TCC. What I did not do is accept the prompt and observe the status flip, since I have no desktop automation here. The prompt-raising code itself is unchanged by this PR — what changes is reachability, which the unit tests and the measurements above cover. Still worth a maintainer smoke test on a Mac that has never granted OpenScreen before merging.

One caveat I'd flag: the grant may still only take effect on the next launch, since macOS can keep the process's cached answer at denied even after the user accepts. The grace window gives the retry loop its chance either way, and the OS prompt now runs so TCC records a proper signature-based entry — but a "restart OpenScreen to finish enabling" hint could be a sensible follow-up.

`requestScreenAccess` only raised the TCC prompt when
`getMediaAccessStatus("screen")` returned "not-determined", which macOS
never reports. Chromium resolves that permission through
`CGPreflightScreenCaptureAccess()`, a bool, so a machine that has never
been asked is indistinguishable from an explicit refusal and both arrive
as "denied".

A first run therefore fell straight through to the "open System Settings"
dialog without macOS ever being asked, leaving a manual toggle as the only
way to grant. The renderer's retry loop in openSourceSelectorFlow arms on
the same status and never ran either.

Decide from whether this launch has already asked instead. The first ask
raises the prompt and reports "not-determined" so the retry loop arms;
later asks report the real status so the Settings dialog still reaches a
user who genuinely refused, without re-prompting on every click.

Verified on macOS 26.2: with no TCC screen-capture row for the bundle,
getMediaAccessStatus("screen") returns "denied" while
getMediaAccessStatus("camera") returns "not-determined".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heyitsR1
heyitsR1 requested a review from EtienneLescot as a code owner August 8, 2026 07:41
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds screen-access prompt decision helpers, tracks prompt timestamps per launch, updates macOS IPC handling, and adds tests for permission states and the prompt grace period.

Changes

Screen access prompt handling

Layer / File(s) Summary
Prompt decision contract and tests
electron/ipc/screenAccessPrompt.ts, electron/ipc/screenAccessPrompt.test.ts
Adds a six-second grace period, prompt eligibility logic, pending-response detection, and tests for permission states, launch history, and grace-window boundaries.
IPC prompt integration
electron/ipc/handlers.ts
Tracks when macOS prompts during the launch, uses the decision helpers, and returns "not-determined" while the native prompt may still be open.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant IPCHandler
  participant ScreenAccessPrompt
  participant macOS
  IPCHandler->>ScreenAccessPrompt: evaluate permission status and prompt timestamp
  ScreenAccessPrompt-->>IPCHandler: return prompt or pending-response decision
  IPCHandler->>macOS: request screen access when prompting is allowed
  macOS-->>IPCHandler: return permission status
  IPCHandler-->>IPCHandler: report not-determined during the grace period
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the macOS first-run Screen Recording prompt fix.
Description check ✅ Passed The description covers the required sections and provides clear context, testing evidence, platform impact, and the remaining end-to-end verification caveat.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/ipc/handlers.ts`:
- Around line 459-460: Track the native Screen Recording request’s in-flight
state separately from hasPromptedForScreenAccess in the relevant IPC handler.
Set the in-flight flag before awaiting desktopCapturer.getSources(), clear it
after settlement, and return "not-determined" for concurrent requests until the
first call resolves. Add a handler-level regression test using a deferred
getSources() promise that issues a second request before resolution and verifies
this behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5b6d6121-63f3-47d4-9df4-58adbba99965

📥 Commits

Reviewing files that changed from the base of the PR and between df4e00a and fe56fa2.

📒 Files selected for processing (3)
  • electron/ipc/handlers.ts
  • electron/ipc/screenAccessPrompt.test.ts
  • electron/ipc/screenAccessPrompt.ts

Comment thread electron/ipc/handlers.ts Outdated
Comment on lines +459 to +460
/** macOS raises its Screen Recording prompt once per launch. */
let hasPromptedForScreenAccess = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Track the active native prompt separately.

Line 1687 marks the launch as prompted before desktopCapturer.getSources() settles. A retry can then return "denied" while the first request is still pending. open-source-selector can open System Settings over the native Screen Recording prompt.

Add an in-flight flag. Return "not-determined" until getSources() settles. Add a handler-level regression test with a deferred getSources() Promise and a second request before it resolves.

Proposed fix
 /** macOS raises its Screen Recording prompt once per launch. */
 let hasPromptedForScreenAccess = false;
+let screenAccessPromptInFlight = false;

+			if (screenAccessPromptInFlight) {
+				return { success: true, granted: false, status: "not-determined" };
+			}
 			if (shouldPromptForScreenAccess(status, hasPromptedForScreenAccess)) {
 				hasPromptedForScreenAccess = true;
+				screenAccessPromptInFlight = true;
 				const mainWin = getMainWindow();
 				// ...
-				desktopCapturer
+				void desktopCapturer
 					.getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 } })
 					.catch(() => {
 						// Permission probing failure is reported by the explicit status check below.
-					});
+					})
+					.finally(() => {
+						screenAccessPromptInFlight = false;
+					});
 				return { success: true, granted: false, status: "not-determined" };
 			}

As per coding guidelines, “Add a test for every new behavior in the same package as the code under test.”

Also applies to: 1686-1687

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/handlers.ts` around lines 459 - 460, Track the native Screen
Recording request’s in-flight state separately from hasPromptedForScreenAccess
in the relevant IPC handler. Set the in-flight flag before awaiting
desktopCapturer.getSources(), clear it after settlement, and return
"not-determined" for concurrent requests until the first call resolves. Add a
handler-level regression test using a deferred getSources() promise that issues
a second request before resolution and verifies this behavior.

Source: Coding guidelines

Raising the prompt and immediately reporting the real status let
open-source-selector show the Settings dialog over the native prompt, and
stopped the renderer's retry loop on its first poll.

An in-flight flag around getSources() does not cover this. Measured on
macOS 26.2, that call settles in 4ms whether or not the prompt is still on
screen (it rejects outright when access is denied), and the status stays
"denied" for as long as the prompt is up — it only flips once the user
accepts. So there is nothing observable to wait on.

Time-box it instead: keep reporting "not-determined" for the renderer's
retry budget after asking, then let the real status through so the Settings
dialog still reaches a user who refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@heyitsR1

heyitsR1 commented Aug 8, 2026

Copy link
Copy Markdown
Author

Good catch on the symptom — System Settings can indeed open over the native prompt. I took a different fix than the proposed one, because I measured the assumption it rests on and it doesn't hold.

An in-flight flag around getSources() assumes that call stays pending while the prompt is on screen. It doesn't. On macOS 26.2 / Electron 41.2.1, from a bundle with no kTCCServiceScreenCapture row:

status_before=denied
getSources REJECTED after 4ms: undefined
status_at_settle=denied
status_at_1s=denied
status_at_7s=denied

It settles in 4ms — it rejects outright rather than waiting — so the flag would be true for about four milliseconds and the renderer's first retry, 750ms later, would still see denied.

The measurement also shows the problem is wider than the race: macOS keeps answering denied for the entire time the prompt is up, and only flips once the user accepts. So even a perfect in-flight flag would leave openSourceSelectorFlow aborting on its first poll, because access.status !== "not-determined" is true immediately.

Since macOS exposes nothing to wait on, 028de73 time-boxes it instead: after asking, keep reporting not-determined for the renderer's retry budget (SCREEN_PROMPT_GRACE_MS, 6s = 8 × 750ms), then let the real status through so the Settings dialog still reaches someone who genuinely refused.

Tests are in screenAccessPrompt.test.tsnow is injected rather than read from the clock, so the grace-window cases are deterministic and need no fake timers. I skipped the handler-level deferred-getSources() test since the mechanism it would exercise turned out not to be the one doing the work.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
electron/ipc/handlers.ts (1)

1686-1710: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add an IPC-level regression test for the screen access grace path.

screenAccessPrompt.test.ts covers the helper only. Add a request-screen-access handler test with mocked clock, media status, and desktopCapturer.getSources. Verify the first prompt request, "not-determined" during the grace window, and "denied" after the window expires.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/handlers.ts` around lines 1686 - 1710, The request-screen-access
IPC handler lacks regression coverage for its screen-access grace period. Add an
IPC-level test around the request-screen-access handler, mocking the clock,
media status, and desktopCapturer.getSources, and verify the initial prompt
request, a not-determined response during the grace window, and a denied
response after the grace window expires.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@electron/ipc/handlers.ts`:
- Around line 1686-1710: The request-screen-access IPC handler lacks regression
coverage for its screen-access grace period. Add an IPC-level test around the
request-screen-access handler, mocking the clock, media status, and
desktopCapturer.getSources, and verify the initial prompt request, a
not-determined response during the grace window, and a denied response after the
grace window expires.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 87795d40-43ae-48fc-88ed-c3f90c4bbfbe

📥 Commits

Reviewing files that changed from the base of the PR and between fe56fa2 and 028de73.

📒 Files selected for processing (3)
  • electron/ipc/handlers.ts
  • electron/ipc/screenAccessPrompt.test.ts
  • electron/ipc/screenAccessPrompt.ts

@heyitsR1

heyitsR1 commented Aug 8, 2026

Copy link
Copy Markdown
Author

On the IPC-level test for the grace path — I'd rather not add it, and I tried it before deciding.

handlers.ts isn't importable under Vitest today. Mocking the electron module isn't enough; the transitive imports run Electron-only APIs at module scope, and you hit them one at a time:

TypeError: process.getSystemVersion is not a function
# stub that, then:
TypeError: Cannot read properties of undefined (reading 'appendSwitch')   # app.commandLine

Getting to a first assertion means faking Electron runtime internals across a 4,088-line module with 64 ipcMain.handle registrations and 28 project-level imports. That harness would be considerably larger than the change it guards, and brittle — any new module-scope call anywhere in that import graph breaks it. It's also why no test in the repo imports handlers.ts at present.

The repo's existing answer to this is the pattern I followed: lift the logic into a small pure module next to the code and test that (recordingStream.ts, audioPeaks.ts, webm-duration.ts, mediaLinksRegistry.ts are all shaped this way). All three states named in the review are covered in screenAccessPrompt.test.ts — first prompt, not-determined inside the window, real status once it lapses — with now injected so they're deterministic without fake timers. What an IPC-level test would add beyond that is coverage of the wiring, which is four lines of this diff.

Happy to add it if you'd prefer, or to open a separate PR that makes handlers.ts testable — that seems worth doing on its own merits, but as its own change rather than smuggled in behind a permissions fix.

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