diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7d63b934b..37fb4e79c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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"); @@ -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(); let currentProjectPath: string | null = null; let currentRecordingSession: RecordingSession | null = null; @@ -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()) { @@ -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); diff --git a/electron/ipc/screenAccessPrompt.test.ts b/electron/ipc/screenAccessPrompt.test.ts new file mode 100644 index 000000000..c23375272 --- /dev/null +++ b/electron/ipc/screenAccessPrompt.test.ts @@ -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); + }); +}); diff --git a/electron/ipc/screenAccessPrompt.ts b/electron/ipc/screenAccessPrompt.ts new file mode 100644 index 000000000..76fa4f93a --- /dev/null +++ b/electron/ipc/screenAccessPrompt.ts @@ -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; +}