Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import { patchWebmDurationOnDisk } from "../recording/webm-duration";
import { reindexRecordingOnDisk } from "../recording/webm-seek-index";
import { registerNativeBridgeHandlers } from "./nativeBridge";
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";
import { isAwaitingScreenPromptAnswer, shouldPromptForScreenAccess } from "./screenAccessPrompt";

const PROJECT_FILE_EXTENSION = "openscreen";
export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
Expand Down Expand Up @@ -455,6 +456,8 @@ type AttachNativeMacWebcamRecordingInput = {

let selectedSource: SelectedSource | null = null;
let selectedDesktopSource: DesktopCapturerSource | null = null;
/** When macOS was asked for Screen Recording this launch, if it has been. */
let screenAccessPromptedAt: number | null = null;
let lastEnumeratedSources = new Map<string, DesktopCapturerSource>();
let currentProjectPath: string | null = null;
let currentRecordingSession: RecordingSession | null = null;
Expand Down Expand Up @@ -1674,7 +1677,14 @@ export function registerIpcHandlers(

// Screen recording has no askForMediaAccess equivalent, so trigger the
// TCC prompt without opening OpenScreen's source selector above it.
if (status === "not-determined") {
// macOS reports a never-asked machine as "denied", so the decision has to
// come from shouldPromptForScreenAccess rather than the status alone.
//
// Report "not-determined" while that prompt is up: it is the status the
// renderer's retry loop polls on, and macOS keeps answering "denied"
// until the user actually accepts.
if (shouldPromptForScreenAccess(status, screenAccessPromptedAt)) {
screenAccessPromptedAt = Date.now();
const mainWin = getMainWindow();
if (mainWin && !mainWin.isDestroyed()) {
if (!mainWin.isVisible()) {
Expand All @@ -1691,6 +1701,14 @@ export function registerIpcHandlers(
return { success: true, granted: false, status: "not-determined" };
}

// Keep reporting "not-determined" while that prompt may still be up.
// macOS answers "denied" the whole time it is on screen, so returning the
// real status here would open System Settings over the prompt and stop
// the renderer's retry loop on its first poll.
if (isAwaitingScreenPromptAnswer(screenAccessPromptedAt, Date.now())) {
return { success: true, granted: false, status: "not-determined" };
}

return { success: true, granted: false, status };
} catch (error) {
console.error("Failed to request screen access:", error);
Expand Down
50 changes: 50 additions & 0 deletions electron/ipc/screenAccessPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
isAwaitingScreenPromptAnswer,
SCREEN_PROMPT_GRACE_MS,
shouldPromptForScreenAccess,
} from "./screenAccessPrompt";

describe("shouldPromptForScreenAccess", () => {
it("never prompts once the permission is granted", () => {
expect(shouldPromptForScreenAccess("granted", null)).toBe(false);
expect(shouldPromptForScreenAccess("granted", 1_000)).toBe(false);
});

it("prompts on the first ask of a launch even though macOS reports denied", () => {
// The regression this guards: macOS collapses "never asked" into "denied",
// so a first run used to skip the prompt entirely.
expect(shouldPromptForScreenAccess("denied", null)).toBe(true);
});

it("stops prompting after this launch has already asked", () => {
// Lets the caller report the real status so the Settings dialog takes over
// instead of re-prompting on every click.
expect(shouldPromptForScreenAccess("denied", 1_000)).toBe(false);
expect(shouldPromptForScreenAccess("restricted", 1_000)).toBe(false);
});

it("still prompts on not-determined, whatever this launch has already asked", () => {
expect(shouldPromptForScreenAccess("not-determined", null)).toBe(true);
expect(shouldPromptForScreenAccess("not-determined", 1_000)).toBe(true);
});
});

describe("isAwaitingScreenPromptAnswer", () => {
it("is not awaiting anything before the prompt has been raised", () => {
expect(isAwaitingScreenPromptAnswer(null, 10_000)).toBe(false);
});

it("holds the real status back while the prompt may still be on screen", () => {
// Without this the Settings dialog opens over the native prompt and the
// renderer's retry loop aborts on its first poll, because macOS keeps
// answering "denied" until the user actually accepts.
expect(isAwaitingScreenPromptAnswer(1_000, 1_000)).toBe(true);
expect(isAwaitingScreenPromptAnswer(1_000, 1_000 + SCREEN_PROMPT_GRACE_MS - 1)).toBe(true);
});

it("releases the real status once the grace window lapses", () => {
expect(isAwaitingScreenPromptAnswer(1_000, 1_000 + SCREEN_PROMPT_GRACE_MS)).toBe(false);
expect(isAwaitingScreenPromptAnswer(1_000, 60_000)).toBe(false);
});
});
53 changes: 53 additions & 0 deletions electron/ipc/screenAccessPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* How long after raising the native prompt to keep reporting "not-determined".
*
* Matches the renderer's retry budget in `openSourceSelectorFlow` (8 attempts,
* 750ms apart), which is the window the user has to answer the prompt before
* the Settings dialog takes over.
*/
export const SCREEN_PROMPT_GRACE_MS = 6_000;

/**
* Decides whether to raise macOS' own Screen Recording prompt.
*
* `systemPreferences.getMediaAccessStatus("screen")` cannot answer
* "not-determined" on macOS. Chromium resolves that permission through
* `CGPreflightScreenCaptureAccess()`, a bool, so a machine that has never been
* asked is reported exactly like an explicit refusal — both arrive as "denied".
* Gating the prompt on `status === "not-determined"` therefore never fires: a
* fresh install falls straight through to the "open System Settings" dialog and
* macOS is never given the chance to ask, so the only way to grant is a manual
* toggle. The renderer's permission-retry loop arms on the same status and is
* dead for the same reason.
*
* Drive the first prompt off whether this launch has already asked instead.
* Asking once per launch keeps a genuine refusal from re-prompting on every
* click, and lets a later call report the real status so the Settings dialog
* still reaches a user who said no.
*/
export function shouldPromptForScreenAccess(status: string, promptedAt: number | null): boolean {
if (status === "granted") {
return false;
}

return status === "not-determined" || promptedAt === null;
}

/**
* Whether the native prompt raised at `promptedAt` may still be waiting for an
* answer, and the real status should be withheld until it is.
*
* macOS gives us nothing to observe here. `desktopCapturer.getSources()` settles
* in a few milliseconds whether or not the prompt is still on screen (measured
* at 4ms on macOS 26.2), and the status stays "denied" for the whole time the
* prompt is up — it only ever flips once the user accepts. So an in-flight flag
* around that call covers nothing, and reporting "denied" straight away would
* open System Settings over the prompt and abort the renderer's retry loop on
* its first poll.
*
* Treating the grace window as "still asking" keeps the loop polling long enough
* to notice an accept, and lets the Settings dialog through once it lapses.
*/
export function isAwaitingScreenPromptAnswer(promptedAt: number | null, now: number): boolean {
return promptedAt !== null && now - promptedAt < SCREEN_PROMPT_GRACE_MS;
}
Loading