From 6b70dfac10d71b6e4e74dbd89e560785c4fb3875 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 23 Sep 2026 18:24:40 +0200 Subject: [PATCH 1/3] feat(macos): gather every permission in a first-run permissions window A fresh macOS install could not reach Screen Recording: the app only raised the prompt for a status Chromium never reports (`not-determined`), so the first Record click went straight to an English 'Open System Settings' dialog for an app that was not even in the list yet. The microphone was also requested on every launch, before anything used it. A permissions window now opens at launch while Screen Recording is missing, and from the app and tray menus. It lists Screen & system audio (required), Accessibility (recommended, for the cursor), and the microphone and camera (optional), each with its live status and the one action that can move it: raise macOS' prompt the first time, open the right System Settings pane after that. - Screen Recording is read from a helper spawned per read (`--screen-access-status`): the app's own read is cached for the life of the process and never sees a grant made while it runs. - 'Never asked' and 'refused' are the same bool on macOS, so the app keeps its own note of the prompts it raised (permissions.json). - When the grant exists but this process cannot use it yet, the window offers the relaunch. - The Record button and the cursor check open the window instead of the two hard-coded English dialogs; the renderer's 6 s retry loop and the launch-time microphone request are gone. Strings in all 15 locales. --- electron/electron-env.d.ts | 20 +- electron/ipc/handlers.ts | 104 ++------ electron/main.ts | 45 ++-- .../screen/macScreenAccess.test.ts | 196 ++++++++++++++ .../native-bridge/screen/macScreenAccess.ts | 187 +++++++++++++ electron/native/README.md | 2 + .../ScreenCaptureRecorder.swift | 25 ++ electron/permissions/index.ts | 154 +++++++++++ electron/permissions/macPermissions.test.ts | 184 +++++++++++++ electron/permissions/macPermissions.ts | 201 ++++++++++++++ electron/preload.ts | 8 +- electron/windows.ts | 41 +++ src/App.tsx | 3 + src/components/launch/LaunchWindow.test.tsx | 5 - src/components/launch/LaunchWindow.tsx | 8 +- .../launch/openSourceSelectorFlow.test.ts | 73 ----- .../launch/openSourceSelectorFlow.ts | 62 ----- .../permissions/PermissionsWindow.test.tsx | 137 ++++++++++ .../permissions/PermissionsWindow.tsx | 251 ++++++++++++++++++ src/i18n/locales/ar/common.json | 3 +- src/i18n/locales/ar/launch.json | 49 ++++ src/i18n/locales/cs/common.json | 3 +- src/i18n/locales/cs/launch.json | 49 ++++ src/i18n/locales/de/common.json | 3 +- src/i18n/locales/de/launch.json | 49 ++++ src/i18n/locales/en/common.json | 3 +- src/i18n/locales/en/launch.json | 49 ++++ src/i18n/locales/es/common.json | 3 +- src/i18n/locales/es/launch.json | 49 ++++ src/i18n/locales/fr/common.json | 3 +- src/i18n/locales/fr/launch.json | 49 ++++ src/i18n/locales/it/common.json | 3 +- src/i18n/locales/it/launch.json | 49 ++++ src/i18n/locales/ja-JP/common.json | 3 +- src/i18n/locales/ja-JP/launch.json | 49 ++++ src/i18n/locales/ko-KR/common.json | 3 +- src/i18n/locales/ko-KR/launch.json | 49 ++++ src/i18n/locales/pt-BR/common.json | 3 +- src/i18n/locales/pt-BR/launch.json | 49 ++++ src/i18n/locales/ru/common.json | 3 +- src/i18n/locales/ru/launch.json | 49 ++++ src/i18n/locales/tr/common.json | 3 +- src/i18n/locales/tr/launch.json | 49 ++++ src/i18n/locales/vi/common.json | 3 +- src/i18n/locales/vi/launch.json | 49 ++++ src/i18n/locales/zh-CN/common.json | 3 +- src/i18n/locales/zh-CN/launch.json | 49 ++++ src/i18n/locales/zh-TW/common.json | 3 +- src/i18n/locales/zh-TW/launch.json | 49 ++++ 49 files changed, 2204 insertions(+), 282 deletions(-) create mode 100644 electron/native-bridge/screen/macScreenAccess.test.ts create mode 100644 electron/native-bridge/screen/macScreenAccess.ts create mode 100644 electron/permissions/index.ts create mode 100644 electron/permissions/macPermissions.test.ts create mode 100644 electron/permissions/macPermissions.ts delete mode 100644 src/components/launch/openSourceSelectorFlow.test.ts delete mode 100644 src/components/launch/openSourceSelectorFlow.ts create mode 100644 src/components/permissions/PermissionsWindow.test.tsx create mode 100644 src/components/permissions/PermissionsWindow.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 826e28e8d..3bf20d40e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -40,12 +40,6 @@ interface Window { openSourceSelector: () => Promise<{ opened: boolean; reason?: string; - access?: { - success: boolean; - granted: boolean; - status: string; - error?: string; - }; }>; openNotes: () => Promise<{ opened: boolean; @@ -77,12 +71,14 @@ interface Window { status: string; error?: string; }>; - requestScreenAccess: () => Promise<{ - success: boolean; - granted: boolean; - status: string; - error?: string; - }>; + /** macOS privacy permissions; see electron/permissions/macPermissions.ts. */ + permissions: { + get: () => Promise; + request: (kind: import("./permissions/macPermissions").PermissionKind) => Promise; + openSettings: (kind: import("./permissions/macPermissions").PermissionKind) => Promise; + relaunch: () => Promise; + close: () => Promise; + }; requestNativeMacCursorAccess: () => Promise<{ success: boolean; granted: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 17574ffa9..a7d1b25cb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -83,6 +83,7 @@ import { import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; +import { getMacPermissions, showPermissionsWindow } from "../permissions"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; import { describeSalvagedTake, @@ -1864,43 +1865,6 @@ export function registerIpcHandlers( const sameSelectedSource = (left: SelectedSource | null, right: SelectedSource | null) => left?.id === right?.id && left?.name === right?.name && left?.display_id === right?.display_id; - async function requestScreenAccess() { - if (process.platform !== "darwin") { - return { success: true, granted: true, status: "granted" }; - } - - try { - const status = systemPreferences.getMediaAccessStatus("screen"); - if (status === "granted") { - return { success: true, granted: true, status }; - } - - // Screen recording has no askForMediaAccess equivalent, so trigger the - // TCC prompt without opening OpenScreen's source selector above it. - if (status === "not-determined") { - const mainWin = getMainWindow(); - if (mainWin && !mainWin.isDestroyed()) { - if (!mainWin.isVisible()) { - mainWin.show(); - } - mainWin.focus(); - } - app.focus({ steal: true }); - desktopCapturer - .getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 } }) - .catch(() => { - // Permission probing failure is reported by the explicit status check below. - }); - return { success: true, granted: false, status: "not-determined" }; - } - - return { success: true, granted: false, status }; - } catch (error) { - console.error("Failed to request screen access:", error); - return { success: false, granted: false, status: "unknown", error: String(error) }; - } - } - ipcMain.handle("get-sources", async (_, opts) => { // desktopCapturer.getSources can never settle where the GL stack cannot be // reached -- a container, a CI runner, a host whose ANGLE fails to @@ -2129,10 +2093,6 @@ export function registerIpcHandlers( } }); - ipcMain.handle("request-screen-access", async () => { - return requestScreenAccess(); - }); - ipcMain.handle("request-native-mac-cursor-access", async () => { const access = await requestMacCursorAccessibilityAccess(); @@ -2154,26 +2114,12 @@ export function registerIpcHandlers( return access; } - const mainWin = getMainWindow(); - const detail = - "Allow OpenScreen under System Settings → Privacy & Security → Accessibility, then press record again to start the countdown."; - const messageOptions = { - type: "warning", - buttons: ["Open Accessibility Settings", "Cancel"], - defaultId: 0, - cancelId: 1, - message: "Accessibility access is required for the editable cursor", - detail, - } satisfies Electron.MessageBoxOptions; - const result = - mainWin && !mainWin.isDestroyed() - ? await dialog.showMessageBox(mainWin, messageOptions) - : await dialog.showMessageBox(messageOptions); - if (result.response === 0) { - await shell.openExternal( - "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", - ); - } + // The helper that answered has just raised macOS' own Accessibility prompt on + // its way up, so the window must offer System Settings, not a second prompt. It + // explains what the grant is for and tracks it live, where a message box could + // only say "go to System Settings" in English. + getMacPermissions().noteRequested("accessibility"); + showPermissionsWindow(); } return access; @@ -2193,34 +2139,16 @@ export function registerIpcHandlers( return { opened: false, reason: "portal-owns-selection" }; } - const access = await requestScreenAccess(); - if (!access.granted) { - if (process.platform === "darwin" && access.status !== "not-determined") { - const mainWin = getMainWindow(); - const messageOptions = { - type: "warning", - buttons: ["Open System Settings", "Cancel"], - defaultId: 0, - cancelId: 1, - message: "Screen Recording permission is required", - detail: - "Allow OpenScreen in macOS System Settings, then come back and choose a screen or window.", - } satisfies Electron.MessageBoxOptions; - const result = - mainWin && !mainWin.isDestroyed() - ? await dialog.showMessageBox(mainWin, messageOptions) - : await dialog.showMessageBox(messageOptions); - if (result.response === 0) { - await shell.openExternal( - "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", - ); - } + // Chromium's picker can only list sources once THIS process can capture, which on + // macOS means granted, and granted before launch: the app's own read is cached for + // the life of the process. Anything short of that belongs in the permissions + // window, which says what is missing and offers the relaunch when that is all. + if (process.platform === "darwin") { + const permissions = await getMacPermissions().read(); + if (permissions.screen !== "granted" || permissions.screenRequiresRelaunch) { + showPermissionsWindow(); + return { opened: false, reason: "screen-access-required" }; } - return { - opened: false, - reason: "screen-access-required", - access, - }; } const sourceSelectorWin = getSourceSelectorWindow(); diff --git a/electron/main.ts b/electron/main.ts index c01cfdfba..64ace0cb5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -12,7 +12,6 @@ import { net, session, shell, - systemPreferences, Tray, } from "electron"; import { ShortcutBinding } from "../src/lib/shortcuts"; @@ -60,6 +59,11 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { installMainProcessErrorGuards } from "./main-process-errors"; +import { + registerPermissionsIpc, + showPermissionsWindow, + showPermissionsWindowIfNeeded, +} from "./permissions"; import { registerSttIpc, shutdownStt } from "./stt"; import { checkLatestRelease } from "./update-checker"; import { loadUpdateMode, saveUpdateMode } from "./update-settings"; @@ -231,6 +235,10 @@ function setupApplicationMenu() { role: "about", label: mainT("common", "actions.about") || "About OpenScreen", }, + { + label: mainT("common", "actions.permissions") || "Permissions…", + click: showPermissionsWindow, + }, { type: "separator" as const }, { label: mainT("common", "actions.saveDiagnostics") || "Save Diagnostics", @@ -923,6 +931,14 @@ function updateTrayMenu(recording: boolean = false) { label: mainT("common", "actions.saveDiagnostics") || "Save Diagnostics", click: runSaveDiagnostics, }, + ...(isMac + ? [ + { + label: mainT("common", "actions.permissions") || "Permissions…", + click: showPermissionsWindow, + }, + ] + : []), { type: "separator" as const }, { label: mainT("common", "actions.quit") || "Quit", @@ -1182,25 +1198,11 @@ appReady?.then(async () => { }); } - // Request mic permission now. Screen Recording is requested lazily from the - // source-picker action so its prompt isn't hidden behind the selector window. - // - // NOT awaited, on purpose. `askForMediaAccess` resolves only once the user - // answers the modal TCC prompt, and `createWindow()` is 70 lines below this in - // the same async block — so on a Mac where the microphone is still - // `not-determined` (every first run, and every fresh dev machine) the app - // showed a permission dialog with NO window behind it and created the HUD only - // after it was dismissed. Nothing between here and `createWindow()` needs the - // answer: the recorder re-checks the status when the user actually arms the mic. - if (process.platform === "darwin") { - const micStatus = systemPreferences.getMediaAccessStatus("microphone"); - if (micStatus !== "granted") { - systemPreferences - .askForMediaAccess("microphone") - .then((granted) => console.info(`[permissions] microphone granted=${granted}`)) - .catch((error) => console.warn("[permissions] microphone request failed:", error)); - } - } + // No permission is requested at launch. Screen Recording, Accessibility, the microphone + // and the camera are all gathered in the permissions window (electron/permissions), + // opened below when recording cannot work yet; the microphone and camera are also + // requested at the moment a take first uses them. + registerPermissionsIpc(); ipcMain.on("hud-overlay-close", () => { app.quit(); @@ -1332,4 +1334,7 @@ appReady?.then(async () => { } createWindow(); + void showPermissionsWindowIfNeeded().catch((error) => + console.warn("[permissions] could not read the permissions at launch:", error), + ); }); diff --git a/electron/native-bridge/screen/macScreenAccess.test.ts b/electron/native-bridge/screen/macScreenAccess.test.ts new file mode 100644 index 000000000..d8afe447f --- /dev/null +++ b/electron/native-bridge/screen/macScreenAccess.test.ts @@ -0,0 +1,196 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The cast on `actual` is written out in each factory rather than shared in a helper: + * `vi.mock` calls are HOISTED above every top-level statement, so a module-scope helper + * is still in its temporal dead zone when the factory runs. + */ +type WithDefault = { default?: Record }; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + const spawn = vi.fn(); + return { ...actual, spawn, default: { ...((actual as WithDefault).default ?? {}), spawn } }; +}); + +const mocks = vi.hoisted(() => ({ accessSync: vi.fn() })); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + // No helper binary exists in a test checkout; by default pretend the first candidate + // path is executable so path resolution is not what is under test. + return { + ...actual, + accessSync: mocks.accessSync, + default: { ...((actual as WithDefault).default ?? {}), accessSync: mocks.accessSync }, + }; +}); + +import { spawn } from "node:child_process"; +import { isMacScreenProbeUnavailable, readMacScreenCaptureAccess } from "./macScreenAccess"; + +/** Minimal stand-in for the helper: stdio pipes plus kill bookkeeping. */ +class FakeHelper extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + killed = false; + + kill() { + this.killed = true; + return true; + } + + /** Feeds one NDJSON line, the way the real helper emits them. */ + emitEvent(event: Record) { + this.stdout.write(`${JSON.stringify(event)}\n`); + } +} + +const spawnMock = vi.mocked(spawn); +let helper: FakeHelper; +let originalPlatform: PropertyDescriptor | undefined; + +beforeEach(() => { + originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + helper = new FakeHelper(); + spawnMock.mockReset(); + spawnMock.mockReturnValue(helper as unknown as ReturnType); + mocks.accessSync.mockReset(); +}); + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + vi.restoreAllMocks(); +}); + +/** Lets the spawn listeners attach before the fake helper speaks. */ +async function settle(pending: Promise, act: () => void): Promise { + await Promise.resolve(); + act(); + return pending; +} + +describe("readMacScreenCaptureAccess", () => { + it("grants when the helper reports the permission", async () => { + const access = await settle(readMacScreenCaptureAccess(), () => + helper.emitEvent({ event: "screen-access", granted: true }), + ); + + expect(access).toMatchObject({ success: true, granted: true, status: "granted" }); + }); + + it("denies when the helper reports the permission is absent", async () => { + const access = await settle(readMacScreenCaptureAccess(), () => + helper.emitEvent({ event: "screen-access", granted: false }), + ); + + expect(access).toMatchObject({ success: true, granted: false, status: "denied" }); + }); + + it("spawns the probe flag and never a recording request", async () => { + await settle(readMacScreenCaptureAccess(), () => + helper.emitEvent({ event: "screen-access", granted: true }), + ); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock.mock.calls[0]?.[1]).toEqual(["--screen-access-status"]); + }); + + it("reads a fresh answer per call, which is the point of the child process", async () => { + // The main process cannot do this: CGPreflightScreenCaptureAccess caches its + // result for the life of the caller, so a grant made while the app runs is + // invisible to it. Every call here is a new process, so the second read sees + // the grant the first one missed. + const first = await settle(readMacScreenCaptureAccess(), () => + helper.emitEvent({ event: "screen-access", granted: false }), + ); + helper = new FakeHelper(); + spawnMock.mockReturnValue(helper as unknown as ReturnType); + const second = await settle(readMacScreenCaptureAccess(), () => + helper.emitEvent({ event: "screen-access", granted: true }), + ); + + expect(first.granted).toBe(false); + expect(second.granted).toBe(true); + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + it("reports missing-helper rather than a refusal when no binary is installed", async () => { + mocks.accessSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + + const access = await readMacScreenCaptureAccess(); + + expect(access).toMatchObject({ granted: false, status: "missing-helper" }); + expect(isMacScreenProbeUnavailable(access.status)).toBe(true); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("reads the answer even when the helper dies in the same tick", async () => { + // The helper prints one line and exits immediately, so the process-death event can + // land before stdout has been drained. Racing it away would report a good answer + // as a dead helper on a machine that is merely fast. + const access = await settle(readMacScreenCaptureAccess(), () => { + helper.emitEvent({ event: "screen-access", granted: true }); + helper.emit("close", 0, null); + }); + + expect(access).toMatchObject({ granted: true, status: "granted" }); + }); + + it("reports exited rather than a refusal when an older helper rejects the flag", async () => { + // A build predating the probe mode answers `invalidArguments` and exits 1. + // Calling that a denial would tell a user with a working grant to go and + // re-grant it. + const access = await settle(readMacScreenCaptureAccess(), () => helper.emit("close", 1, null)); + + expect(access).toMatchObject({ granted: false, status: "exited" }); + expect(isMacScreenProbeUnavailable(access.status)).toBe(true); + }); + + it("reports error when the helper cannot be launched at all", async () => { + const access = await settle(readMacScreenCaptureAccess(), () => + helper.emit("error", new Error("EACCES")), + ); + + expect(access).toMatchObject({ granted: false, status: "error", error: "EACCES" }); + expect(isMacScreenProbeUnavailable(access.status)).toBe(true); + }); + + it("reports timeout and kills the helper when it never answers", async () => { + // A hung helper must neither stall the caller nor be read as a refusal, and must + // not outlive the read that spawned it. + vi.useFakeTimers(); + try { + const pending = readMacScreenCaptureAccess(); + await vi.advanceTimersByTimeAsync(3_000); + const access = await pending; + + expect(access).toMatchObject({ + success: false, + granted: false, + status: "timeout", + error: "Timed out reading the macOS screen recording permission", + }); + expect(isMacScreenProbeUnavailable(access.status)).toBe(true); + expect(helper.killed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("answers granted off-darwin without spawning anything", async () => { + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + + const access = await readMacScreenCaptureAccess(); + + expect(access).toMatchObject({ granted: true, status: "granted" }); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/native-bridge/screen/macScreenAccess.ts b/electron/native-bridge/screen/macScreenAccess.ts new file mode 100644 index 000000000..f167048a3 --- /dev/null +++ b/electron/native-bridge/screen/macScreenAccess.ts @@ -0,0 +1,187 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants } from "node:fs"; +import path from "node:path"; + +/** + * Reading macOS' Screen Recording grant from a short-lived child process. + * + * `CGPreflightScreenCaptureAccess()` caches its answer for the life of the process + * that calls it. Once it has answered false it answers false forever, whatever the + * user does in System Settings afterwards -- Apple's own guidance is to relaunch. + * Electron's `systemPreferences.getMediaAccessStatus("screen")` is that same + * function (Chromium's `IsScreenCaptureAllowed()` in `ui/base/cocoa/permissions_utils.mm`), + * so the app's main process holds one stale bool for its entire run. + * + * That is the whole reason this module exists. The helper is spawned fresh for every + * read, so every read is the current answer, and a grant the user makes while the app + * is running becomes observable without a restart. + * + * The prompt is NOT raised here. It stays in the main process, where Chromium raises + * it through the app bundle, so TCC records the grant against the app's designated + * requirement rather than against a bare child binary. + */ + +const HELPER_NAME = "openscreen-screencapturekit-helper"; + +/** Kept in step with `screenAccessStatusFlag` in ScreenCaptureRecorder.swift. */ +const SCREEN_ACCESS_STATUS_FLAG = "--screen-access-status"; + +/** + * The helper prints one line and exits, so this bounds a hung spawn rather than a + * slow answer. Shorter than the cursor helper's budget because nothing here waits + * on a window server handshake. + */ +const PROBE_TIMEOUT_MS = 3_000; + +/** + * Why `denied` is the only status that means "the user said no". + * + * The other four mean the helper never got to answer -- absent from the build, killed + * by the loader, crashed, or hung. Treating those as a refusal is what would put the + * "grant Screen Recording" dialog in front of a user whose permission is fine, which is + * the same failure the cursor helper's `missing-helper` split exists to prevent (#515). + */ +export type MacScreenAccessStatus = + | "granted" + | "denied" + | "missing-helper" + | "error" + | "exited" + | "timeout"; + +export interface MacScreenAccessResult { + success: boolean; + granted: boolean; + status: MacScreenAccessStatus; + error?: string; +} + +/** True when the probe never got far enough to answer the permission question. */ +export function isMacScreenProbeUnavailable(status: MacScreenAccessStatus) { + return ( + status === "missing-helper" || status === "error" || status === "exited" || status === "timeout" + ); +} + +function helperCandidates() { + const envPath = process.env.OPENSCREEN_SCK_CAPTURE_EXE?.trim(); + const appRoot = process.env.APP_ROOT ? path.resolve(process.env.APP_ROOT) : process.cwd(); + const archTag = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + const resourceRoot = + typeof process.resourcesPath === "string" + ? process.resourcesPath + : path.join(appRoot, "resources"); + + return [ + envPath, + path.join(appRoot, "electron", "native", "screencapturekit", "build", HELPER_NAME), + path.join(appRoot, "electron", "native", "bin", archTag, HELPER_NAME), + path.join(resourceRoot, "electron", "native", "bin", archTag, HELPER_NAME), + ].filter((candidate): candidate is string => Boolean(candidate)); +} + +export function findMacScreenAccessHelperPath() { + for (const candidate of helperCandidates()) { + try { + accessSync(candidate, fsConstants.X_OK); + return candidate; + } catch { + // Try the next helper location. + } + } + + return null; +} + +/** + * Reads the current Screen Recording grant, uncached. + * + * Never prompts and never blocks on the user: the helper calls the preflight function + * only, so this is safe to poll while macOS' own prompt is on screen. + */ +export async function readMacScreenCaptureAccess(): Promise { + if (process.platform !== "darwin") { + return { success: true, granted: true, status: "granted" }; + } + + const helperPath = findMacScreenAccessHelperPath(); + if (!helperPath) { + return { success: true, granted: false, status: "missing-helper" }; + } + + return new Promise((resolve) => { + const child = spawn(helperPath, [SCREEN_ACCESS_STATUS_FLAG], { + stdio: ["ignore", "pipe", "pipe"], + }); + let settled = false; + let lineBuffer = ""; + + const finish = (result: MacScreenAccessResult) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (!child.killed) { + child.kill("SIGTERM"); + } + resolve(result); + }; + + const timer = setTimeout(() => { + finish({ + success: false, + granted: false, + status: "timeout", + error: "Timed out reading the macOS screen recording permission", + }); + }, PROBE_TIMEOUT_MS); + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + lineBuffer += chunk; + const lines = lineBuffer.split(/\r?\n/); + lineBuffer = lines.pop() ?? ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + try { + const event = JSON.parse(trimmed) as { event?: string; granted?: boolean }; + if (event.event === "screen-access") { + finish({ + success: true, + granted: event.granted === true, + status: event.granted === true ? "granted" : "denied", + }); + return; + } + } catch { + // Ignore non-JSON helper output. + } + } + }); + + child.once("error", (error) => { + finish({ success: false, granted: false, status: "error", error: error.message }); + }); + + // `close`, not `exit`. This helper prints one line and dies, and `exit` can fire + // before stdout has been drained to the listener above -- which would report a + // perfectly good answer as a dead helper. `close` waits for the stdio streams. + // + // Reaching it at all means the helper ran and said nothing: an older build without + // the flag, which answers `invalidArguments` and exits 1. Reported as `exited` + // rather than a refusal, so the caller falls back to the app's own status instead + // of accusing the user of denying a permission they may well hold. + child.once("close", (code, signal) => { + finish({ + success: false, + granted: false, + status: "exited", + error: `macOS screen access probe exited (code=${code}, signal=${signal})`, + }); + }); + }); +} diff --git a/electron/native/README.md b/electron/native/README.md index 3a5363530..bd84dd2f5 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -9,6 +9,8 @@ macOS native recording will use a ScreenCaptureKit helper with the same process 3. The helper owns ScreenCaptureKit/AVFoundation capture, timing, encoding, and muxing. 4. Electron persists the resulting media/session manifest and reports helper errors explicitly. +The helper has one non-recording mode: `openscreen-screencapturekit-helper --screen-access-status` prints `{"event":"screen-access","granted":}` and exits. `CGPreflightScreenCaptureAccess()` caches its answer for the life of the calling process, so Electron's own read can never observe a Screen Recording grant made after launch — a helper spawned per read has no cache to be stale (`electron/native-bridge/screen/macScreenAccess.ts`). + Helper locations: 1. `OPENSCREEN_SCK_CAPTURE_EXE`, for local development and diagnostics. diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 785c4a3a1..8b08e1554 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -931,10 +931,35 @@ struct OpenScreenScreenCaptureKitHelper { _ = CGMainDisplayID() } + /// The flag that turns this helper into a one-shot answer to "may we record the + /// screen", printed as the usual single JSON line and nothing else. + private static let screenAccessStatusFlag = "--screen-access-status" + static func main() async { do { initializeCoreGraphicsWindowServerConnection() + // Answered from a process that exists for one read and then dies, because a FRESH + // PROCESS is the only place the answer can be trusted. + // `CGPreflightScreenCaptureAccess()` caches its result for the life of the calling + // process: once it has answered false it answers false forever, whatever the user + // does in System Settings afterwards. The app is long-lived, and Chromium's + // `getMediaAccessStatus("screen")` goes through that same function, so from the + // first miss until the next relaunch the app cannot observe its own permission + // being granted. That staleness -- not the missing prompt alone -- is what left + // the permission unreachable without a restart. + // + // Deliberately BEFORE the macOS 13 guard and the request decode below: the question + // is asked on every macOS the app supports, and answering it needs neither + // ScreenCaptureKit nor a recording request. + if CommandLine.arguments.count == 2, CommandLine.arguments[1] == screenAccessStatusFlag { + emit([ + "event": "screen-access", + "granted": CGPreflightScreenCaptureAccess(), + ]) + exit(0) + } + guard CommandLine.arguments.count == 2 else { throw HelperError.invalidArguments } diff --git a/electron/permissions/index.ts b/electron/permissions/index.ts new file mode 100644 index 000000000..0002df9e8 --- /dev/null +++ b/electron/permissions/index.ts @@ -0,0 +1,154 @@ +import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + app, + type BrowserWindow, + desktopCapturer, + ipcMain, + shell, + systemPreferences, +} from "electron"; +import { readMacScreenCaptureAccess } from "../native-bridge/screen/macScreenAccess"; +import { createPermissionsWindow } from "../windows"; +import { + createMacPermissions, + type MacPermissions, + type NotedKind, + type PermissionKind, + type PermissionsStore, +} from "./macPermissions"; + +export type { PermissionKind, PermissionsSnapshot } from "./macPermissions"; + +const STORE_FILE = "permissions.json"; + +/** + * The app's own note of which prompts it has raised on this Mac: the one thing macOS + * will not say. Only ever used to choose between raising a prompt and opening System + * Settings -- never as the answer to whether a permission is held, which is always + * read live. + * + * A stale note (the user reset TCC with `tccutil`) costs the prompt, and the user gets + * the System Settings pane instead, which still works. A lost one costs a request macOS + * silently ignores. Both leave the user with something to act on. + */ +function createFileStore(userData: string): PermissionsStore { + const file = path.join(userData, STORE_FILE); + let requested: Partial> = {}; + try { + const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); + if (parsed && typeof parsed === "object" && "requested" in parsed) { + requested = (parsed as { requested: typeof requested }).requested ?? {}; + } + } catch { + // Missing or unreadable: nothing has been asked yet. + } + + return { + hasRequested: (kind) => typeof requested[kind] === "string", + markRequested: (kind) => { + requested = { ...requested, [kind]: new Date().toISOString() }; + const temporary = `${file}.${process.pid}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify({ requested }, null, 2)}\n`, "utf8"); + renameSync(temporary, file); + } catch (error) { + // Best effort: the in-memory note still gets the rest of this launch right. + console.warn("[permissions] failed to persist the request note:", error); + } finally { + rmSync(temporary, { force: true }); + } + }, + }; +} + +function macosMajor(): number { + if (process.platform !== "darwin") { + return 0; + } + const major = Number.parseInt(process.getSystemVersion().split(".")[0] ?? "", 10); + return Number.isFinite(major) ? major : 0; +} + +let permissions: MacPermissions | null = null; + +export function getMacPermissions(): MacPermissions { + permissions ??= createMacPermissions({ + platform: process.platform, + macosMajor: macosMajor(), + probeScreen: async () => { + const probe = await readMacScreenCaptureAccess(); + return probe.status === "granted" || probe.status === "denied" + ? { answered: true, granted: probe.granted } + : { answered: false }; + }, + appScreenGranted: () => systemPreferences.getMediaAccessStatus("screen") === "granted", + accessibilityTrusted: (prompt) => systemPreferences.isTrustedAccessibilityClient(prompt), + mediaStatus: (kind) => systemPreferences.getMediaAccessStatus(kind), + askForMedia: (kind) => systemPreferences.askForMediaAccess(kind), + // Raised from THIS process, through Chromium's own call to + // CGRequestScreenCaptureAccess, so TCC files the grant under the app bundle. + // The call rejects within milliseconds while the permission is missing, so nothing + // is learned from awaiting it; the answer is read back from a fresh process. + raiseScreenPrompt: () => { + desktopCapturer + .getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 } }) + .catch(() => undefined); + }, + openExternal: (url) => shell.openExternal(url), + store: createFileStore(app.getPath("userData")), + }); + return permissions; +} + +let permissionsWindow: BrowserWindow | null = null; + +export function showPermissionsWindow(): void { + if (process.platform !== "darwin") { + return; + } + if (permissionsWindow && !permissionsWindow.isDestroyed()) { + permissionsWindow.show(); + permissionsWindow.focus(); + return; + } + permissionsWindow = createPermissionsWindow(); + permissionsWindow.on("closed", () => { + permissionsWindow = null; + }); +} + +/** Opens the permissions window when recording cannot work yet. For app launch. */ +export async function showPermissionsWindowIfNeeded(): Promise { + // Not in the headless e2e runs either: there is no one to answer, and the probe would + // hold the window open behind every spec. + if (process.platform !== "darwin" || process.env["HEADLESS"] === "true") { + return; + } + const snapshot = await getMacPermissions().read(); + if (snapshot.screen !== "granted" || snapshot.screenRequiresRelaunch) { + showPermissionsWindow(); + } +} + +const KINDS: readonly PermissionKind[] = ["screen", "accessibility", "microphone", "camera"]; +const isKind = (value: unknown): value is PermissionKind => KINDS.includes(value as PermissionKind); + +export function registerPermissionsIpc(): void { + ipcMain.handle("permissions:get", () => getMacPermissions().read()); + ipcMain.handle("permissions:request", (_event, kind: unknown) => + isKind(kind) ? getMacPermissions().request(kind) : undefined, + ); + ipcMain.handle("permissions:open-settings", (_event, kind: unknown) => + isKind(kind) ? getMacPermissions().openSettings(kind) : undefined, + ); + ipcMain.handle("permissions:relaunch", () => { + app.relaunch(); + app.quit(); + }); + ipcMain.handle("permissions:close", () => { + if (permissionsWindow && !permissionsWindow.isDestroyed()) { + permissionsWindow.close(); + } + }); +} diff --git a/electron/permissions/macPermissions.test.ts b/electron/permissions/macPermissions.test.ts new file mode 100644 index 000000000..426bb3b53 --- /dev/null +++ b/electron/permissions/macPermissions.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createMacPermissions, + type MacPermissionsDeps, + type NotedKind, + permissionSettingsUrl, + type ScreenProbe, +} from "./macPermissions"; + +function setup(overrides: Partial = {}) { + const requested = new Set(); + const deps: MacPermissionsDeps = { + platform: "darwin", + macosMajor: 26, + probeScreen: vi.fn(async (): Promise => ({ answered: true, granted: false })), + appScreenGranted: vi.fn(() => false), + accessibilityTrusted: vi.fn(() => false), + mediaStatus: vi.fn(() => "not-determined"), + askForMedia: vi.fn(async () => true), + raiseScreenPrompt: vi.fn(), + openExternal: vi.fn(async () => undefined), + store: { + hasRequested: (kind) => requested.has(kind), + markRequested: (kind) => { + requested.add(kind); + }, + }, + ...overrides, + }; + return { deps, requested, permissions: createMacPermissions(deps) }; +} + +describe("read", () => { + it("reports a first run as not requested, not as refused", async () => { + const { permissions } = setup(); + + expect(await permissions.read()).toMatchObject({ + supported: true, + macosMajor: 26, + screen: "not-requested", + screenRequiresRelaunch: false, + accessibility: "not-requested", + microphone: "not-requested", + camera: "not-requested", + }); + }); + + it("reports a refusal once the app has asked", async () => { + const { permissions, requested } = setup(); + requested.add("screen"); + requested.add("accessibility"); + + expect(await permissions.read()).toMatchObject({ screen: "denied", accessibility: "denied" }); + }); + + it("trusts the fresh-process read over the app's cached one", async () => { + // The case the helper exists for: granted in System Settings while the app runs. + const { permissions } = setup({ + probeScreen: async () => ({ answered: true, granted: true }), + appScreenGranted: () => false, + }); + + expect(await permissions.read()).toMatchObject({ + screen: "granted", + screenRequiresRelaunch: true, + }); + }); + + it("needs no relaunch when both reads agree on a grant", async () => { + const { permissions } = setup({ + probeScreen: async () => ({ answered: true, granted: true }), + appScreenGranted: () => true, + }); + + expect(await permissions.read()).toMatchObject({ + screen: "granted", + screenRequiresRelaunch: false, + }); + }); + + it("falls back to the app's own read when the helper cannot answer", async () => { + const granted = setup({ + probeScreen: async () => ({ answered: false }), + appScreenGranted: () => true, + }); + const refused = setup({ probeScreen: async () => ({ answered: false }) }); + + expect((await granted.permissions.read()).screen).toBe("granted"); + expect((await refused.permissions.read()).screen).toBe("not-requested"); + }); + + it("maps the camera and microphone statuses, restricted included", async () => { + const { permissions } = setup({ + mediaStatus: (kind) => (kind === "microphone" ? "restricted" : "denied"), + }); + + expect(await permissions.read()).toMatchObject({ microphone: "restricted", camera: "denied" }); + }); + + it("reads everything as granted off macOS without touching the helper", async () => { + const { permissions, deps } = setup({ platform: "win32" }); + + expect(await permissions.read()).toMatchObject({ + supported: false, + screen: "granted", + accessibility: "granted", + }); + expect(deps.probeScreen).not.toHaveBeenCalled(); + }); +}); + +describe("request", () => { + it("raises the Screen Recording prompt once, noting it first", async () => { + const { permissions, deps, requested } = setup(); + + await permissions.request("screen"); + + expect(requested.has("screen")).toBe(true); + expect(deps.raiseScreenPrompt).toHaveBeenCalledTimes(1); + expect(deps.openExternal).not.toHaveBeenCalled(); + }); + + it("opens System Settings instead once macOS will not prompt again", async () => { + const { permissions, deps } = setup(); + + await permissions.request("screen"); + await permissions.request("screen"); + + expect(deps.raiseScreenPrompt).toHaveBeenCalledTimes(1); + expect(deps.openExternal).toHaveBeenCalledWith(permissionSettingsUrl("screen")); + }); + + it("prompts for Accessibility the first time and opens its pane after", async () => { + const { permissions, deps } = setup(); + + await permissions.request("accessibility"); + expect(deps.accessibilityTrusted).toHaveBeenCalledWith(true); + + await permissions.request("accessibility"); + expect(deps.openExternal).toHaveBeenCalledWith(permissionSettingsUrl("accessibility")); + }); + + it("asks macOS for the microphone while it is undetermined", async () => { + const { permissions, deps } = setup(); + + await permissions.request("microphone"); + + expect(deps.askForMedia).toHaveBeenCalledWith("microphone"); + }); + + it("sends a refused camera to its pane", async () => { + const { permissions, deps } = setup({ mediaStatus: () => "denied" }); + + await permissions.request("camera"); + + expect(deps.askForMedia).not.toHaveBeenCalled(); + expect(deps.openExternal).toHaveBeenCalledWith(permissionSettingsUrl("camera")); + }); + + it("does nothing for a granted or policy-restricted permission", async () => { + const { permissions, deps } = setup({ + probeScreen: async () => ({ answered: true, granted: true }), + mediaStatus: () => "restricted", + }); + + await permissions.request("screen"); + await permissions.request("microphone"); + + expect(deps.raiseScreenPrompt).not.toHaveBeenCalled(); + expect(deps.askForMedia).not.toHaveBeenCalled(); + expect(deps.openExternal).not.toHaveBeenCalled(); + }); +}); + +describe("permissionSettingsUrl", () => { + it("uses the pane anchors that open on every supported macOS", () => { + expect(permissionSettingsUrl("screen")).toBe( + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", + ); + expect(permissionSettingsUrl("accessibility")).toBe( + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + ); + }); +}); diff --git a/electron/permissions/macPermissions.ts b/electron/permissions/macPermissions.ts new file mode 100644 index 000000000..b86fd4cf6 --- /dev/null +++ b/electron/permissions/macPermissions.ts @@ -0,0 +1,201 @@ +/** + * The macOS privacy permissions OpenScreen uses, read and requested in one place. + * + * Four permissions, and only one of them is required: + * + * - `screen`: Screen & System Audio Recording. Required. It also covers system audio, + * because that is captured through ScreenCaptureKit rather than a Core Audio tap. + * - `accessibility`: recommended. The cursor helper needs it to tell pointer and text + * cursors apart; without it recording still works, with plainer cursor telemetry. + * - `microphone` and `camera`: optional, and still requested at the moment of use when + * the user skipped them here. + * + * Screen Recording is the awkward one, for two reasons macOS gives no API around: + * + * 1. `CGPreflightScreenCaptureAccess()` caches its answer for the life of the calling + * process, and Electron's `getMediaAccessStatus("screen")` is that same call. A grant + * made while the app runs is invisible to the app's own read, so the live status is + * read from a helper spawned fresh for every read (`macScreenAccess.ts`). + * 2. That read is a bool: "never asked" and "refused" are the same `false`. macOS shows + * its prompt once per decision and ignores every later request, so the app keeps its + * own note of having asked -- the only way to know whether a request can still raise + * a prompt or has to send the user to System Settings instead. + * + * Accessibility has the same bool-only read, and the same note is kept for it. + */ + +export type PermissionKind = "screen" | "accessibility" | "microphone" | "camera"; + +/** + * - `not-requested`: nothing has been asked yet, so a request can raise macOS' prompt. + * - `denied`: asked and not granted. Only System Settings can change it now. + * - `restricted`: forbidden by policy (MDM, Screen Time). Nothing the user can do here. + */ +export type PermissionStatus = "granted" | "not-requested" | "denied" | "restricted"; + +export interface PermissionsSnapshot { + /** False off macOS, where none of this applies and every permission reads granted. */ + supported: boolean; + /** macOS major version (13, 14, 15, 26...), 0 when unknown or off macOS. */ + macosMajor: number; + screen: PermissionStatus; + /** + * Screen Recording is granted, but this process still reads its cached refusal, so the + * parts of the app that go through Chromium (the source picker) cannot use it until the + * app is relaunched. + */ + screenRequiresRelaunch: boolean; + accessibility: PermissionStatus; + microphone: PermissionStatus; + camera: PermissionStatus; +} + +/** What a fresh-process read of the Screen Recording grant answered. */ +export type ScreenProbe = { answered: true; granted: boolean } | { answered: false }; + +export type NotedKind = "screen" | "accessibility"; + +export interface PermissionsStore { + hasRequested(kind: NotedKind): boolean; + markRequested(kind: NotedKind): void; +} + +export interface MacPermissionsDeps { + platform: NodeJS.Platform; + macosMajor: number; + probeScreen(): Promise; + /** The app's own, per-process cached Screen Recording read. */ + appScreenGranted(): boolean; + /** `isTrustedAccessibilityClient`: `prompt` raises macOS' prompt as a side effect. */ + accessibilityTrusted(prompt: boolean): boolean; + /** `getMediaAccessStatus` for the microphone or camera. */ + mediaStatus(kind: "microphone" | "camera"): string; + askForMedia(kind: "microphone" | "camera"): Promise; + /** Raises macOS' Screen Recording prompt from the app's own process. */ + raiseScreenPrompt(): void; + openExternal(url: string): Promise; + store: PermissionsStore; +} + +/** + * The legacy `com.apple.preference.security` form opens the right pane on every version + * from 13 to 26. The `com.apple.settings.PrivacySecurity.extension` form that macOS 26 + * uses itself is unconfirmed on 13. + */ +const SETTINGS_ANCHOR: Record = { + screen: "Privacy_ScreenCapture", + accessibility: "Privacy_Accessibility", + microphone: "Privacy_Microphone", + camera: "Privacy_Camera", +}; + +export function permissionSettingsUrl(kind: PermissionKind): string { + return `x-apple.systempreferences:com.apple.preference.security?${SETTINGS_ANCHOR[kind]}`; +} + +function mediaPermissionStatus(status: string): PermissionStatus { + switch (status) { + case "granted": + return "granted"; + case "not-determined": + return "not-requested"; + case "restricted": + return "restricted"; + default: + return "denied"; + } +} + +const OFF_MACOS: PermissionsSnapshot = { + supported: false, + macosMajor: 0, + screen: "granted", + screenRequiresRelaunch: false, + accessibility: "granted", + microphone: "granted", + camera: "granted", +}; + +export function createMacPermissions(deps: MacPermissionsDeps) { + const notedStatus = (kind: NotedKind): PermissionStatus => + deps.store.hasRequested(kind) ? "denied" : "not-requested"; + + async function readScreen(): Promise<{ status: PermissionStatus; requiresRelaunch: boolean }> { + const appGranted = deps.appScreenGranted(); + const probe = await deps.probeScreen(); + // A helper that could not answer (absent from the build, crashed, hung) says nothing + // about the permission, so this falls back to the app's own read: stale at worst, + // which is all any build had before the helper. + const granted = probe.answered ? probe.granted : appGranted; + if (!granted) { + return { status: notedStatus("screen"), requiresRelaunch: false }; + } + return { status: "granted", requiresRelaunch: !appGranted }; + } + + async function read(): Promise { + if (deps.platform !== "darwin") { + return OFF_MACOS; + } + + const screen = await readScreen(); + return { + supported: true, + macosMajor: deps.macosMajor, + screen: screen.status, + screenRequiresRelaunch: screen.requiresRelaunch, + accessibility: deps.accessibilityTrusted(false) ? "granted" : notedStatus("accessibility"), + microphone: mediaPermissionStatus(deps.mediaStatus("microphone")), + camera: mediaPermissionStatus(deps.mediaStatus("camera")), + }; + } + + function openSettings(kind: PermissionKind): Promise { + return deps.openExternal(permissionSettingsUrl(kind)); + } + + /** + * Does whatever can move `kind` towards granted: raises macOS' prompt the first time, + * and opens the matching System Settings pane once a prompt can no longer appear. + */ + async function request(kind: PermissionKind): Promise { + if (deps.platform !== "darwin") { + return; + } + const status = (await read())[kind]; + + if (status === "granted" || status === "restricted") { + return; + } + if (status === "denied") { + await openSettings(kind); + return; + } + + switch (kind) { + case "screen": + // Noted before raising: if the app quits with the prompt still up, the next + // launch must offer System Settings, not a prompt macOS will not show again. + deps.store.markRequested("screen"); + deps.raiseScreenPrompt(); + return; + case "accessibility": + deps.store.markRequested("accessibility"); + deps.accessibilityTrusted(true); + return; + case "microphone": + case "camera": + await deps.askForMedia(kind); + return; + } + } + + /** For a prompt raised elsewhere -- the cursor helper raises Accessibility's itself. */ + function noteRequested(kind: NotedKind): void { + deps.store.markRequested(kind); + } + + return { read, request, openSettings, noteRequested }; +} + +export type MacPermissions = ReturnType; diff --git a/electron/preload.ts b/electron/preload.ts index 93494593e..ebefc7a1d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -157,8 +157,12 @@ contextBridge.exposeInMainWorld("electronAPI", { requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, - requestScreenAccess: () => { - return ipcRenderer.invoke("request-screen-access"); + permissions: { + get: () => ipcRenderer.invoke("permissions:get"), + request: (kind: string) => ipcRenderer.invoke("permissions:request", kind), + openSettings: (kind: string) => ipcRenderer.invoke("permissions:open-settings", kind), + relaunch: () => ipcRenderer.invoke("permissions:relaunch"), + close: () => ipcRenderer.invoke("permissions:close"), }, requestNativeMacCursorAccess: () => { return ipcRenderer.invoke("request-native-mac-cursor-access"); diff --git a/electron/windows.ts b/electron/windows.ts index 634392755..b8e0c2adf 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -654,6 +654,47 @@ export function createCountdownOverlayWindow(): BrowserWindow { return win; } +/** + * The macOS permissions window: first run, the app menu, and wherever a missing permission + * would otherwise stop a recording. An ordinary opaque window on purpose -- it has to sit + * beside System Settings and macOS' own prompts, not float above them like the HUD. + */ +export function createPermissionsWindow(): BrowserWindow { + const win = new BrowserWindow({ + width: 520, + height: 640, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + title: "OpenScreen", + backgroundColor: "#0b0c0f", + show: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + additionalArguments: [ASSET_BASE_URL_ARG], + nodeIntegration: false, + contextIsolation: true, + }, + }); + + win.once("ready-to-show", () => { + if (!HEADLESS) { + win.show(); + } + }); + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=permissions"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "permissions" }, + }); + } + + return win; +} + // Frameless Notes Window for taking notes during a recording. export function createNotesWindow(): BrowserWindow { const win = new BrowserWindow({ diff --git a/src/App.tsx b/src/App.tsx index aa7619d26..2d4dd051c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { NotesWindow } from "./components/launch/NotesWindow.tsx"; import { SourceSelector } from "./components/launch/SourceSelector"; +import { PermissionsWindow } from "./components/permissions/PermissionsWindow"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; import { EditorDialogsProvider } from "./contexts/EditorDialogsContext"; @@ -86,6 +87,8 @@ export default function App() { return ; case "countdown-overlay": return ; + case "permissions": + return ; case "cli-export": return ( diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index ebf319e65..6e011c558 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -220,11 +220,6 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo ...window.electronAPI, getSelectedSource, openSourceSelector: vi.fn(async () => ({ opened: true })), - requestScreenAccess: vi.fn(async () => ({ - success: true, - granted: true, - status: "granted", - })), // Follows the platform under test. Pinned to "darwin" before, which was // invisible while only `nativeBridgeClient` was consulted for it — and // silently wrong the moment anything read the platform through here. diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 60fe9be18..33ea3ef39 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -44,7 +44,6 @@ import { HUD_STACK_GAP, } from "./hudGeometry"; import styles from "./LaunchWindow.module.css"; -import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow"; // Locale list is computed once at module load; keeping the reference stable lets // the language menu sit behind a memo boundary. @@ -717,11 +716,10 @@ export function LaunchWindow() { }, [applySelectedSource, recording, startWhenDevicesReady]); const openSourceSelector = useCallback(async () => { + // A missing macOS permission is handled on the main side: it opens the permissions + // window and answers `screen-access-required`, so there is nothing to retry here. if (window.electronAPI) { - return await openSourceSelectorWithPermissionRetry({ - openSourceSelector: () => window.electronAPI.openSourceSelector(), - requestScreenAccess: () => window.electronAPI.requestScreenAccess(), - }); + return await window.electronAPI.openSourceSelector(); } return { opened: false, reason: "electron-api-unavailable" }; diff --git a/src/components/launch/openSourceSelectorFlow.test.ts b/src/components/launch/openSourceSelectorFlow.test.ts deleted file mode 100644 index ac80f1adc..000000000 --- a/src/components/launch/openSourceSelectorFlow.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow"; - -describe("openSourceSelectorWithPermissionRetry", () => { - it("returns immediately when the source selector opens on the first attempt", async () => { - const openSourceSelector = vi.fn().mockResolvedValue({ opened: true }); - const requestScreenAccess = vi.fn(); - - const result = await openSourceSelectorWithPermissionRetry({ - openSourceSelector, - requestScreenAccess, - wait: vi.fn(), - }); - - expect(result).toEqual({ opened: true }); - expect(openSourceSelector).toHaveBeenCalledTimes(1); - expect(requestScreenAccess).not.toHaveBeenCalled(); - }); - - it("retries opening after macOS screen permission becomes granted", async () => { - const openSourceSelector = vi - .fn() - .mockResolvedValueOnce({ - opened: false, - reason: "screen-access-required", - access: { success: true, granted: false, status: "not-determined" }, - }) - .mockResolvedValueOnce({ opened: true }); - const requestScreenAccess = vi - .fn() - .mockResolvedValueOnce({ success: true, granted: false, status: "not-determined" }) - .mockResolvedValueOnce({ success: true, granted: true, status: "granted" }); - const wait = vi.fn().mockResolvedValue(undefined); - - const result = await openSourceSelectorWithPermissionRetry({ - openSourceSelector, - requestScreenAccess, - wait, - maxAttempts: 4, - }); - - expect(result).toEqual({ opened: true }); - expect(wait).toHaveBeenCalledTimes(2); - expect(requestScreenAccess).toHaveBeenCalledTimes(2); - expect(openSourceSelector).toHaveBeenCalledTimes(2); - }); - - it("stops retrying once macOS permission is explicitly denied", async () => { - const openSourceSelector = vi.fn().mockResolvedValue({ - opened: false, - reason: "screen-access-required", - access: { success: true, granted: false, status: "not-determined" }, - }); - const requestScreenAccess = vi - .fn() - .mockResolvedValueOnce({ success: true, granted: false, status: "denied" }); - - const result = await openSourceSelectorWithPermissionRetry({ - openSourceSelector, - requestScreenAccess, - wait: vi.fn(), - maxAttempts: 4, - }); - - expect(result).toEqual({ - opened: false, - reason: "screen-access-required", - access: { success: true, granted: false, status: "denied" }, - }); - expect(requestScreenAccess).toHaveBeenCalledTimes(1); - expect(openSourceSelector).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/launch/openSourceSelectorFlow.ts b/src/components/launch/openSourceSelectorFlow.ts deleted file mode 100644 index 540f1fcef..000000000 --- a/src/components/launch/openSourceSelectorFlow.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type ScreenAccessResult = { - success: boolean; - granted: boolean; - status: string; - error?: string; -}; - -export type OpenSourceSelectorResult = { - opened: boolean; - reason?: string; - access?: ScreenAccessResult; -}; - -type OpenSourceSelectorFlowOptions = { - openSourceSelector: () => Promise; - requestScreenAccess: () => Promise; - wait?: (ms: number) => Promise; - retryDelayMs?: number; - maxAttempts?: number; -}; - -const defaultWait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); - -function shouldRetryAfterPermissionPrompt(result: OpenSourceSelectorResult): boolean { - return ( - result.opened === false && - result.reason === "screen-access-required" && - result.access?.status === "not-determined" - ); -} - -export async function openSourceSelectorWithPermissionRetry({ - openSourceSelector, - requestScreenAccess, - wait = defaultWait, - retryDelayMs = 750, - maxAttempts = 8, -}: OpenSourceSelectorFlowOptions): Promise { - const initialResult = await openSourceSelector(); - if (!shouldRetryAfterPermissionPrompt(initialResult)) { - return initialResult; - } - - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - await wait(retryDelayMs); - const access = await requestScreenAccess(); - - if (access.granted) { - return openSourceSelector(); - } - - if (access.status !== "not-determined") { - return { - opened: false, - reason: "screen-access-required", - access, - }; - } - } - - return initialResult; -} diff --git a/src/components/permissions/PermissionsWindow.test.tsx b/src/components/permissions/PermissionsWindow.test.tsx new file mode 100644 index 000000000..3b64f57f6 --- /dev/null +++ b/src/components/permissions/PermissionsWindow.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMISSIONS_POLL_MS, PermissionsWindow } from "./PermissionsWindow"; + +vi.mock("@/contexts/I18nContext", () => ({ + // Keys come back verbatim, so the assertions read as the contract, not as copy. + useScopedT: () => (key: string) => key, +})); + +type PermissionsApi = Window["electronAPI"]["permissions"]; +type Snapshot = Awaited>; + +const FIRST_RUN: Snapshot = { + supported: true, + macosMajor: 26, + screen: "not-requested", + screenRequiresRelaunch: false, + accessibility: "not-requested", + microphone: "not-requested", + camera: "not-requested", +}; + +let current: Snapshot; +let api: { [K in keyof PermissionsApi]: ReturnType }; + +beforeEach(() => { + current = { ...FIRST_RUN }; + api = { + get: vi.fn(async () => current), + request: vi.fn(async () => undefined), + openSettings: vi.fn(async () => undefined), + relaunch: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + window.electronAPI = { + ...window.electronAPI, + permissions: api as unknown as PermissionsApi, + }; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function renderWith(snapshot: Partial) { + current = { ...FIRST_RUN, ...snapshot }; + render(); + await screen.findByTestId("permission-screen"); +} + +describe("PermissionsWindow", () => { + it("offers Continue, not Allow, for Screen Recording on a first run", async () => { + await renderWith({}); + + const button = screen.getByTestId("permission-screen-action"); + expect(button).toHaveTextContent("permissions.actions.continue"); + expect(screen.getByTestId("permission-microphone-action")).toHaveTextContent( + "permissions.actions.allow", + ); + expect(screen.getByText("permissions.help.screenPrompt")).toBeInTheDocument(); + + fireEvent.click(button); + await waitFor(() => expect(api.request).toHaveBeenCalledWith("screen")); + expect(api.openSettings).not.toHaveBeenCalled(); + }); + + it("sends a refused permission to System Settings, with help for a missing entry", async () => { + await renderWith({ screen: "denied" }); + + const button = screen.getByTestId("permission-screen-action"); + expect(button).toHaveTextContent("permissions.actions.openSettings"); + expect(screen.getByText("permissions.help.screenNotListed")).toBeInTheDocument(); + + fireEvent.click(button); + await waitFor(() => expect(api.openSettings).toHaveBeenCalledWith("screen")); + expect(api.request).not.toHaveBeenCalled(); + }); + + it("keeps Get started disabled until Screen Recording is granted", async () => { + await renderWith({ accessibility: "granted", microphone: "granted", camera: "granted" }); + + expect(screen.getByTestId("permissions-start")).toBeDisabled(); + expect(screen.getByText("permissions.footer.screenRequired")).toBeInTheDocument(); + }); + + it("lets the user start once Screen Recording is granted, whatever the optional ones say", async () => { + await renderWith({ screen: "granted", camera: "denied" }); + + const start = screen.getByTestId("permissions-start"); + expect(start).toBeEnabled(); + fireEvent.click(start); + expect(api.close).toHaveBeenCalled(); + }); + + it("offers the relaunch when the grant is not usable by this process yet", async () => { + await renderWith({ screen: "granted", screenRequiresRelaunch: true }); + + expect(screen.queryByTestId("permissions-start")).not.toBeInTheDocument(); + expect(screen.getByText("permissions.help.screenRestart")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("permissions-relaunch")); + expect(api.relaunch).toHaveBeenCalled(); + }); + + it("warns about macOS' recurring confirmation only from macOS 15", async () => { + await renderWith({ screen: "granted", macosMajor: 15 }); + expect(screen.getByText("permissions.help.screenRecurring")).toBeInTheDocument(); + }); + + it("says nothing about it on macOS 14", async () => { + await renderWith({ screen: "granted", macosMajor: 14 }); + expect(screen.queryByText("permissions.help.screenRecurring")).not.toBeInTheDocument(); + }); + + it("shows a policy-restricted permission without a button", async () => { + await renderWith({ microphone: "restricted" }); + + const row = screen.getByTestId("permission-microphone"); + expect(within(row).getByText("permissions.status.restricted")).toBeInTheDocument(); + expect(screen.queryByTestId("permission-microphone-action")).not.toBeInTheDocument(); + }); + + it("follows a grant made in System Settings without any click", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + await renderWith({ screen: "denied" }); + expect(screen.getByTestId("permission-screen")).toHaveAttribute("data-status", "denied"); + + current = { ...current, screen: "granted" }; + await act(async () => { + await vi.advanceTimersByTimeAsync(PERMISSIONS_POLL_MS); + }); + + expect(screen.getByTestId("permission-screen")).toHaveAttribute("data-status", "granted"); + expect(screen.getByTestId("permissions-start")).toBeEnabled(); + }); +}); diff --git a/src/components/permissions/PermissionsWindow.tsx b/src/components/permissions/PermissionsWindow.tsx new file mode 100644 index 000000000..c70af98c3 --- /dev/null +++ b/src/components/permissions/PermissionsWindow.tsx @@ -0,0 +1,251 @@ +import { Accessibility, Check, Mic, MonitorPlay, Video } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; + +type PermissionsApi = Window["electronAPI"]["permissions"]; +type Snapshot = Awaited>; +type Kind = Parameters[0]; +type Status = Snapshot["screen"]; + +/** + * How often the statuses are re-read while the window is open. The user changes them in + * System Settings, which tells no one, so polling is the only way to follow along; each + * Screen Recording read spawns a short-lived helper, which is cheap at this rate. + */ +export const PERMISSIONS_POLL_MS = 500; + +const ROWS: ReadonlyArray<{ + kind: Kind; + level: "required" | "recommended" | "optional"; + Icon: typeof MonitorPlay; +}> = [ + { kind: "screen", level: "required", Icon: MonitorPlay }, + { kind: "accessibility", level: "recommended", Icon: Accessibility }, + { kind: "microphone", level: "optional", Icon: Mic }, + { kind: "camera", level: "optional", Icon: Video }, +]; + +/** macOS 15 re-confirms ScreenCaptureKit access periodically for every app that uses it. */ +const RECURRING_SCREEN_ALERT_FROM_MACOS = 15; + +export function PermissionsWindow() { + const t = useScopedT("launch"); + const [snapshot, setSnapshot] = useState(null); + const [busy, setBusy] = useState(null); + const mounted = useRef(true); + + const refresh = useCallback(async () => { + try { + const next = await window.electronAPI.permissions.get(); + if (mounted.current) { + setSnapshot(next); + } + } catch (error) { + console.warn("[permissions] read failed:", error); + } + }, []); + + useEffect(() => { + mounted.current = true; + let timer: number | undefined; + // Sequential rather than setInterval: a slow read must not stack up behind itself. + const tick = async () => { + await refresh(); + if (mounted.current) { + timer = window.setTimeout(tick, PERMISSIONS_POLL_MS); + } + }; + void tick(); + return () => { + mounted.current = false; + window.clearTimeout(timer); + }; + }, [refresh]); + + const act = useCallback( + async (kind: Kind, status: Status) => { + setBusy(kind); + try { + if (status === "denied") { + await window.electronAPI.permissions.openSettings(kind); + } else { + await window.electronAPI.permissions.request(kind); + } + await refresh(); + } finally { + if (mounted.current) { + setBusy(null); + } + } + }, + [refresh], + ); + + if (!snapshot) { + return
; + } + + const screenReady = snapshot.screen === "granted" && !snapshot.screenRequiresRelaunch; + const needsRelaunch = snapshot.screen === "granted" && snapshot.screenRequiresRelaunch; + + return ( +
+

{t("permissions.title")}

+

{t("permissions.subtitle")}

+ +
    + {ROWS.map(({ kind, level, Icon }) => { + const status = snapshot[kind]; + return ( +
  • +
    + +
    +
    + + {t(`permissions.rows.${kind}.name`)} + + + {t(`permissions.level.${level}`)} + +
    +

    + {t(`permissions.rows.${kind}.description`)} +

    +
    + void act(kind, status)} + t={t} + /> +
    + {kind === "screen" && ( + + )} +
  • + ); + })} +
+ +
+

+ {screenReady ? t("permissions.footer.ready") : t("permissions.footer.screenRequired")} +

+ {needsRelaunch ? ( + + ) : ( + + )} +
+
+ ); +} + +type T = ReturnType; + +function PermissionAction({ + kind, + status, + busy, + onAct, + t, +}: { + kind: Kind; + status: Status; + busy: boolean; + onAct: () => void; + t: T; +}) { + if (status === "granted") { + return ( + + + {t("permissions.status.granted")} + + ); + } + if (status === "restricted") { + return ( + + {t("permissions.status.restricted")} + + ); + } + + // Screen Recording's first step says "Continue", not "Allow": macOS' own prompt has no + // Allow button, only a way into System Settings, and the label must not promise one. + const label = + status === "denied" + ? t("permissions.actions.openSettings") + : kind === "screen" + ? t("permissions.actions.continue") + : t("permissions.actions.allow"); + + return ( + + ); +} + +function ScreenHelp({ + snapshot, + needsRelaunch, + t, +}: { + snapshot: Snapshot; + needsRelaunch: boolean; + t: T; +}) { + const lines: string[] = []; + if (needsRelaunch) { + lines.push(t("permissions.help.screenRestart")); + } else if (snapshot.screen === "denied") { + lines.push(t("permissions.help.screenSettings"), t("permissions.help.screenNotListed")); + } else if (snapshot.screen === "not-requested") { + lines.push(t("permissions.help.screenPrompt")); + } + if (snapshot.screen === "granted" && snapshot.macosMajor >= RECURRING_SCREEN_ALERT_FROM_MACOS) { + lines.push(t("permissions.help.screenRecurring")); + } + if (lines.length === 0) { + return null; + } + return ( +
+ {lines.map((line) => ( +

+ {line} +

+ ))} +
+ ); +} diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index 530deaf55..62c1b7b6c 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -45,7 +45,8 @@ "hide": "إخفاء OpenScreen", "hideOthers": "إخفاء الآخرين", "unhide": "إظهار الكل", - "saveDiagnostics": "حفظ التشخيصات" + "saveDiagnostics": "حفظ التشخيصات", + "permissions": "الأذونات…" }, "updates": { "available": "يتوفر OpenScreen {{latestVersion}}. الإصدار المثبت هو {{currentVersion}}.", diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index 0198af382..a19bd22a9 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -95,5 +95,54 @@ "about": "حول", "version": "الإصدار {{version}}", "checkingForUpdates": "جارٍ التحقق…" + }, + "permissions": { + "title": "يحتاج OpenScreen إلى بعض الأذونات", + "subtitle": "يطلب منك macOS منح كل إذن مرة واحدة فقط. يمكنك تغييرها في أي وقت من إعدادات النظام.", + "level": { + "required": "مطلوب", + "recommended": "مُوصى به", + "optional": "اختياري" + }, + "rows": { + "screen": { + "name": "الشاشة وصوت النظام", + "description": "لتسجيل شاشتك والصوت الصادر من جهاز Mac." + }, + "accessibility": { + "name": "تسهيلات الاستخدام", + "description": "لإظهار المؤشر الصحيح (سهم، نص) في تسجيلاتك." + }, + "microphone": { + "name": "الميكروفون", + "description": "لتسجيل صوتك." + }, + "camera": { + "name": "الكاميرا", + "description": "لإضافة كاميرا الويب إلى تسجيلاتك." + } + }, + "status": { + "granted": "مسموح", + "restricted": "تتم إدارته من قِبل مؤسستك" + }, + "actions": { + "continue": "متابعة", + "allow": "سماح", + "openSettings": "فتح الإعدادات", + "restart": "إعادة تشغيل OpenScreen", + "start": "ابدأ" + }, + "help": { + "screenPrompt": "سيطلب منك macOS بعد ذلك تفعيل OpenScreen في إعدادات النظام.", + "screenSettings": "في إعدادات النظام، فعِّل OpenScreen. إذا عرض macOS إنهاء التطبيق وإعادة فتحه، فوافق: ستعود هذه النافذة.", + "screenNotListed": "لا يظهر OpenScreen في القائمة؟ انقر على +، ثم اختر OpenScreen من التطبيقات.", + "screenRestart": "مسموح. يجب إعادة تشغيل OpenScreen ليتمكن من استخدامه.", + "screenRecurring": "سيطلب منك macOS من حين لآخر تأكيد هذا الوصول. هذا أمر طبيعي." + }, + "footer": { + "screenRequired": "تسجيل الشاشة مطلوب للتسجيل.", + "ready": "كل شيء جاهز." + } } } diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index c68a3e456..a3deb4c3b 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -45,7 +45,8 @@ "hide": "Skrýt OpenScreen", "hideOthers": "Skrýt ostatní", "unhide": "Zobrazit vše", - "saveDiagnostics": "Uložit diagnostiku" + "saveDiagnostics": "Uložit diagnostiku", + "permissions": "Oprávnění…" }, "updates": { "available": "OpenScreen {{latestVersion}} je k dispozici. Používáte verzi {{currentVersion}}.", diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json index 87dbef6c3..6a44f4a6a 100644 --- a/src/i18n/locales/cs/launch.json +++ b/src/i18n/locales/cs/launch.json @@ -95,5 +95,54 @@ "about": "O aplikaci", "version": "Verze {{version}}", "checkingForUpdates": "Kontrola…" + }, + "permissions": { + "title": "OpenScreen potřebuje několik oprávnění", + "subtitle": "macOS vás o každé požádá jen jednou. Kdykoli je můžete změnit v Nastavení systému.", + "level": { + "required": "Povinné", + "recommended": "Doporučené", + "optional": "Volitelné" + }, + "rows": { + "screen": { + "name": "Obrazovka a zvuk systému", + "description": "K nahrávání obrazovky a zvuku z vašeho Macu." + }, + "accessibility": { + "name": "Zpřístupnění", + "description": "Aby nahrávky zobrazovaly správný kurzor (šipka, text)." + }, + "microphone": { + "name": "Mikrofon", + "description": "K nahrávání vašeho hlasu." + }, + "camera": { + "name": "Kamera", + "description": "K přidání webkamery do nahrávek." + } + }, + "status": { + "granted": "Povoleno", + "restricted": "Spravováno vaší organizací" + }, + "actions": { + "continue": "Pokračovat", + "allow": "Povolit", + "openSettings": "Otevřít Nastavení", + "restart": "Restartovat OpenScreen", + "start": "Začít" + }, + "help": { + "screenPrompt": "macOS vás pak požádá, abyste OpenScreen zapnuli v Nastavení systému.", + "screenSettings": "V Nastavení systému zapněte OpenScreen. Pokud macOS nabídne aplikaci ukončit a znovu otevřít, přijměte: toto okno se vrátí.", + "screenNotListed": "OpenScreen v seznamu chybí? Klikněte na + a vyberte OpenScreen ve složce Aplikace.", + "screenRestart": "Povoleno. OpenScreen se musí restartovat, aby oprávnění mohl použít.", + "screenRecurring": "macOS vás občas požádá o potvrzení tohoto přístupu. To je v pořádku." + }, + "footer": { + "screenRequired": "K nahrávání je potřeba nahrávání obrazovky.", + "ready": "Vše je připraveno." + } } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 194818720..c1e4b5462 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -45,7 +45,8 @@ "hide": "OpenScreen ausblenden", "hideOthers": "Andere ausblenden", "unhide": "Alle einblenden", - "saveDiagnostics": "Diagnosedaten speichern" + "saveDiagnostics": "Diagnosedaten speichern", + "permissions": "Berechtigungen …" }, "updates": { "available": "OpenScreen {{latestVersion}} ist verfügbar. Du verwendest {{currentVersion}}.", diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index 0f2e8cc65..9aa284046 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -95,5 +95,54 @@ "about": "Über", "version": "Version {{version}}", "checkingForUpdates": "Wird geprüft…" + }, + "permissions": { + "title": "OpenScreen benötigt einige Berechtigungen", + "subtitle": "macOS fragt jede Berechtigung nur einmal ab. Du kannst sie jederzeit in den Systemeinstellungen ändern.", + "level": { + "required": "Erforderlich", + "recommended": "Empfohlen", + "optional": "Optional" + }, + "rows": { + "screen": { + "name": "Bildschirm & Systemaudio", + "description": "Um deinen Bildschirm und den Ton deines Mac aufzunehmen." + }, + "accessibility": { + "name": "Bedienungshilfen", + "description": "Um in Aufnahmen den richtigen Zeiger (Pfeil, Text) anzuzeigen." + }, + "microphone": { + "name": "Mikrofon", + "description": "Um deine Stimme aufzunehmen." + }, + "camera": { + "name": "Kamera", + "description": "Um deine Webcam zu Aufnahmen hinzuzufügen." + } + }, + "status": { + "granted": "Erlaubt", + "restricted": "Von deiner Organisation verwaltet" + }, + "actions": { + "continue": "Weiter", + "allow": "Erlauben", + "openSettings": "Einstellungen öffnen", + "restart": "OpenScreen neu starten", + "start": "Los geht's" + }, + "help": { + "screenPrompt": "macOS bittet dich anschließend, OpenScreen in den Systemeinstellungen zu aktivieren.", + "screenSettings": "Aktiviere OpenScreen in den Systemeinstellungen. Wenn macOS anbietet, die App zu beenden und neu zu öffnen, stimme zu: Dieses Fenster erscheint wieder.", + "screenNotListed": "OpenScreen fehlt in der Liste? Klicke auf + und wähle OpenScreen unter „Programme“.", + "screenRestart": "Erlaubt. OpenScreen muss neu starten, um die Berechtigung zu nutzen.", + "screenRecurring": "macOS bittet dich gelegentlich, diesen Zugriff zu bestätigen. Das ist normal." + }, + "footer": { + "screenRequired": "Zum Aufnehmen ist die Bildschirmaufnahme erforderlich.", + "ready": "Alles bereit." + } } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d1dd7d085..ab9a46b00 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -45,7 +45,8 @@ "hide": "Hide OpenScreen", "hideOthers": "Hide Others", "unhide": "Show All", - "saveDiagnostics": "Save Diagnostics" + "saveDiagnostics": "Save Diagnostics", + "permissions": "Permissions…" }, "updates": { "available": "OpenScreen {{latestVersion}} is available. You are using {{currentVersion}}.", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index ad2385c02..263b4e9b9 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -95,5 +95,54 @@ "about": "About", "version": "Version {{version}}", "checkingForUpdates": "Checking…" + }, + "permissions": { + "title": "OpenScreen needs a few permissions", + "subtitle": "macOS asks you to grant each one once. You can change them at any time in System Settings.", + "level": { + "required": "Required", + "recommended": "Recommended", + "optional": "Optional" + }, + "rows": { + "screen": { + "name": "Screen & system audio", + "description": "To record your screen and the sound your Mac plays." + }, + "accessibility": { + "name": "Accessibility", + "description": "To show the right cursor (pointer, text) in your recordings." + }, + "microphone": { + "name": "Microphone", + "description": "To record your voice." + }, + "camera": { + "name": "Camera", + "description": "To add your webcam to your recordings." + } + }, + "status": { + "granted": "Allowed", + "restricted": "Managed by your organization" + }, + "actions": { + "continue": "Continue", + "allow": "Allow", + "openSettings": "Open Settings", + "restart": "Restart OpenScreen", + "start": "Get started" + }, + "help": { + "screenPrompt": "macOS will then ask you to turn on OpenScreen in System Settings.", + "screenSettings": "In System Settings, turn on OpenScreen. If macOS offers to quit and reopen it, accept: this window will come back.", + "screenNotListed": "OpenScreen isn't in the list? Click +, then choose OpenScreen in Applications.", + "screenRestart": "Allowed. OpenScreen needs to restart before it can use it.", + "screenRecurring": "macOS will occasionally ask you to confirm this access. That's expected." + }, + "footer": { + "screenRequired": "Screen recording is needed to record.", + "ready": "You're all set." + } } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 92de74416..d067e7562 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -45,7 +45,8 @@ "hide": "Ocultar OpenScreen", "hideOthers": "Ocultar otros", "unhide": "Mostrar todo", - "saveDiagnostics": "Guardar diagnósticos" + "saveDiagnostics": "Guardar diagnósticos", + "permissions": "Permisos…" }, "updates": { "available": "OpenScreen {{latestVersion}} está disponible. Estás usando {{currentVersion}}.", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 7a2c92a55..4587d841d 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -95,5 +95,54 @@ "about": "Acerca de", "version": "Versión {{version}}", "checkingForUpdates": "Buscando…" + }, + "permissions": { + "title": "OpenScreen necesita algunos permisos", + "subtitle": "macOS te pide concederlos una sola vez. Puedes cambiarlos en cualquier momento en Ajustes del Sistema.", + "level": { + "required": "Obligatorio", + "recommended": "Recomendado", + "optional": "Opcional" + }, + "rows": { + "screen": { + "name": "Pantalla y audio del sistema", + "description": "Para grabar tu pantalla y el sonido de tu Mac." + }, + "accessibility": { + "name": "Accesibilidad", + "description": "Para mostrar el cursor correcto (flecha, texto) en tus grabaciones." + }, + "microphone": { + "name": "Micrófono", + "description": "Para grabar tu voz." + }, + "camera": { + "name": "Cámara", + "description": "Para añadir tu webcam a tus grabaciones." + } + }, + "status": { + "granted": "Permitido", + "restricted": "Gestionado por tu organización" + }, + "actions": { + "continue": "Continuar", + "allow": "Permitir", + "openSettings": "Abrir Ajustes", + "restart": "Reiniciar OpenScreen", + "start": "Empezar" + }, + "help": { + "screenPrompt": "Después, macOS te pedirá que actives OpenScreen en Ajustes del Sistema.", + "screenSettings": "En Ajustes del Sistema, activa OpenScreen. Si macOS ofrece cerrarlo y volver a abrirlo, acepta: esta ventana volverá.", + "screenNotListed": "¿OpenScreen no aparece en la lista? Haz clic en + y elige OpenScreen en Aplicaciones.", + "screenRestart": "Permitido. OpenScreen debe reiniciarse para poder usarlo.", + "screenRecurring": "De vez en cuando, macOS te pedirá que confirmes este acceso. Es normal." + }, + "footer": { + "screenRequired": "Se necesita la grabación de pantalla para grabar.", + "ready": "Todo listo." + } } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 6d5454603..518611716 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -45,7 +45,8 @@ "hide": "Masquer OpenScreen", "hideOthers": "Masquer les autres", "unhide": "Tout afficher", - "saveDiagnostics": "Enregistrer les diagnostics" + "saveDiagnostics": "Enregistrer les diagnostics", + "permissions": "Autorisations…" }, "updates": { "available": "OpenScreen {{latestVersion}} est disponible. Vous utilisez la version {{currentVersion}}.", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 858fd58f1..1d9f5fbb6 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -95,5 +95,54 @@ "about": "À propos", "version": "Version {{version}}", "checkingForUpdates": "Recherche…" + }, + "permissions": { + "title": "OpenScreen a besoin de quelques autorisations", + "subtitle": "macOS vous demande de les accorder une seule fois. Vous pouvez les modifier à tout moment dans Réglages Système.", + "level": { + "required": "Obligatoire", + "recommended": "Recommandée", + "optional": "Facultative" + }, + "rows": { + "screen": { + "name": "Écran et son du système", + "description": "Pour enregistrer votre écran et le son de votre Mac." + }, + "accessibility": { + "name": "Accessibilité", + "description": "Pour afficher le bon curseur (flèche, texte) dans vos enregistrements." + }, + "microphone": { + "name": "Micro", + "description": "Pour enregistrer votre voix." + }, + "camera": { + "name": "Caméra", + "description": "Pour ajouter votre webcam à vos enregistrements." + } + }, + "status": { + "granted": "Autorisé", + "restricted": "Géré par votre organisation" + }, + "actions": { + "continue": "Continuer", + "allow": "Autoriser", + "openSettings": "Ouvrir Réglages", + "restart": "Redémarrer OpenScreen", + "start": "Commencer" + }, + "help": { + "screenPrompt": "macOS vous demandera ensuite d'activer OpenScreen dans Réglages Système.", + "screenSettings": "Dans Réglages Système, activez OpenScreen. Si macOS propose de le quitter et le rouvrir, acceptez : cette fenêtre reviendra.", + "screenNotListed": "OpenScreen n'apparaît pas dans la liste ? Cliquez sur +, puis choisissez OpenScreen dans Applications.", + "screenRestart": "Autorisé. OpenScreen doit redémarrer pour pouvoir l'utiliser.", + "screenRecurring": "macOS vous demandera de temps en temps de confirmer cet accès. C'est normal." + }, + "footer": { + "screenRequired": "L'enregistrement de l'écran est nécessaire pour enregistrer.", + "ready": "Tout est prêt." + } } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index d831ec93b..9633a8ac1 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -45,7 +45,8 @@ "hide": "Nascondi OpenScreen", "hideOthers": "Nascondi gli altri", "unhide": "Mostra tutto", - "saveDiagnostics": "Salva dati diagnostici" + "saveDiagnostics": "Salva dati diagnostici", + "permissions": "Permessi…" }, "updates": { "available": "OpenScreen {{latestVersion}} è disponibile. Stai usando la versione {{currentVersion}}.", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index ed5088d67..2a7cc0a96 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -95,5 +95,54 @@ "about": "Info", "version": "Versione {{version}}", "checkingForUpdates": "Controllo…" + }, + "permissions": { + "title": "OpenScreen ha bisogno di alcuni permessi", + "subtitle": "macOS ti chiede di concederli una sola volta. Puoi modificarli in qualsiasi momento in Impostazioni di Sistema.", + "level": { + "required": "Obbligatorio", + "recommended": "Consigliato", + "optional": "Facoltativo" + }, + "rows": { + "screen": { + "name": "Schermo e audio di sistema", + "description": "Per registrare lo schermo e l'audio del tuo Mac." + }, + "accessibility": { + "name": "Accessibilità", + "description": "Per mostrare il cursore giusto (freccia, testo) nelle registrazioni." + }, + "microphone": { + "name": "Microfono", + "description": "Per registrare la tua voce." + }, + "camera": { + "name": "Fotocamera", + "description": "Per aggiungere la webcam alle registrazioni." + } + }, + "status": { + "granted": "Consentito", + "restricted": "Gestito dalla tua organizzazione" + }, + "actions": { + "continue": "Continua", + "allow": "Consenti", + "openSettings": "Apri Impostazioni", + "restart": "Riavvia OpenScreen", + "start": "Inizia" + }, + "help": { + "screenPrompt": "Poi macOS ti chiederà di attivare OpenScreen in Impostazioni di Sistema.", + "screenSettings": "In Impostazioni di Sistema, attiva OpenScreen. Se macOS propone di chiuderlo e riaprirlo, accetta: questa finestra tornerà.", + "screenNotListed": "OpenScreen non è nell'elenco? Fai clic su +, poi scegli OpenScreen in Applicazioni.", + "screenRestart": "Consentito. OpenScreen deve riavviarsi per poterlo usare.", + "screenRecurring": "Di tanto in tanto macOS ti chiederà di confermare questo accesso. È normale." + }, + "footer": { + "screenRequired": "Per registrare serve la registrazione dello schermo.", + "ready": "È tutto pronto." + } } } diff --git a/src/i18n/locales/ja-JP/common.json b/src/i18n/locales/ja-JP/common.json index a1375e0ed..55175fa26 100644 --- a/src/i18n/locales/ja-JP/common.json +++ b/src/i18n/locales/ja-JP/common.json @@ -45,7 +45,8 @@ "hide": "OpenScreenを隠す", "hideOthers": "ほかを隠す", "unhide": "すべて表示", - "saveDiagnostics": "診断情報を保存" + "saveDiagnostics": "診断情報を保存", + "permissions": "アクセス許可…" }, "updates": { "available": "OpenScreen {{latestVersion}} を利用できます。現在のバージョンは {{currentVersion}} です。", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index ab9cebbd5..9dc54e90a 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -95,5 +95,54 @@ "about": "情報", "version": "バージョン {{version}}", "checkingForUpdates": "確認中…" + }, + "permissions": { + "title": "OpenScreen にはいくつかの許可が必要です", + "subtitle": "macOS ではそれぞれ一度だけ許可します。システム設定でいつでも変更できます。", + "level": { + "required": "必須", + "recommended": "推奨", + "optional": "任意" + }, + "rows": { + "screen": { + "name": "画面とシステムオーディオ", + "description": "画面と Mac のサウンドを録画するため。" + }, + "accessibility": { + "name": "アクセシビリティ", + "description": "録画に正しいカーソル(矢印、テキスト)を表示するため。" + }, + "microphone": { + "name": "マイク", + "description": "声を録音するため。" + }, + "camera": { + "name": "カメラ", + "description": "録画に Web カメラを追加するため。" + } + }, + "status": { + "granted": "許可済み", + "restricted": "組織によって管理されています" + }, + "actions": { + "continue": "続ける", + "allow": "許可", + "openSettings": "設定を開く", + "restart": "OpenScreen を再起動", + "start": "はじめる" + }, + "help": { + "screenPrompt": "このあと macOS から、システム設定で OpenScreen をオンにするよう求められます。", + "screenSettings": "システム設定で OpenScreen をオンにしてください。macOS が終了して再度開くよう提案した場合は、そのまま進めてください。このウインドウが再び表示されます。", + "screenNotListed": "リストに OpenScreen がない場合は、+をクリックして「アプリケーション」から OpenScreen を選んでください。", + "screenRestart": "許可済みです。使用するには OpenScreen の再起動が必要です。", + "screenRecurring": "macOS からときどきこのアクセスの確認を求められます。これは正常な動作です。" + }, + "footer": { + "screenRequired": "録画には画面収録が必要です。", + "ready": "準備が整いました。" + } } } diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json index c9ddd9ba7..c83e9c634 100644 --- a/src/i18n/locales/ko-KR/common.json +++ b/src/i18n/locales/ko-KR/common.json @@ -45,7 +45,8 @@ "hide": "OpenScreen 숨기기", "hideOthers": "다른 항목 숨기기", "unhide": "모두 보기", - "saveDiagnostics": "진단 정보 저장" + "saveDiagnostics": "진단 정보 저장", + "permissions": "권한…" }, "updates": { "available": "OpenScreen {{latestVersion}} 버전을 사용할 수 있습니다. 현재 버전은 {{currentVersion}}입니다.", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 192089224..2e2abe3e9 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -95,5 +95,54 @@ "about": "정보", "version": "버전 {{version}}", "checkingForUpdates": "확인 중…" + }, + "permissions": { + "title": "OpenScreen에 몇 가지 권한이 필요합니다", + "subtitle": "macOS에서는 각 권한을 한 번만 허용하면 됩니다. 시스템 설정에서 언제든지 변경할 수 있습니다.", + "level": { + "required": "필수", + "recommended": "권장", + "optional": "선택" + }, + "rows": { + "screen": { + "name": "화면 및 시스템 오디오", + "description": "화면과 Mac의 소리를 녹화하기 위해 필요합니다." + }, + "accessibility": { + "name": "손쉬운 사용", + "description": "녹화에 올바른 커서(화살표, 텍스트)를 표시하기 위해 필요합니다." + }, + "microphone": { + "name": "마이크", + "description": "목소리를 녹음하기 위해 필요합니다." + }, + "camera": { + "name": "카메라", + "description": "녹화에 웹캠을 추가하기 위해 필요합니다." + } + }, + "status": { + "granted": "허용됨", + "restricted": "조직에서 관리함" + }, + "actions": { + "continue": "계속", + "allow": "허용", + "openSettings": "설정 열기", + "restart": "OpenScreen 재시작", + "start": "시작하기" + }, + "help": { + "screenPrompt": "이어서 macOS가 시스템 설정에서 OpenScreen을 켜도록 요청합니다.", + "screenSettings": "시스템 설정에서 OpenScreen을 켜세요. macOS가 종료 후 다시 열기를 제안하면 수락하세요. 이 창이 다시 표시됩니다.", + "screenNotListed": "목록에 OpenScreen이 없나요? +를 클릭한 다음 응용 프로그램에서 OpenScreen을 선택하세요.", + "screenRestart": "허용되었습니다. 이 권한을 사용하려면 OpenScreen을 재시작해야 합니다.", + "screenRecurring": "macOS가 가끔 이 접근 권한을 확인하도록 요청합니다. 정상적인 동작입니다." + }, + "footer": { + "screenRequired": "녹화하려면 화면 기록 권한이 필요합니다.", + "ready": "모든 준비가 끝났습니다." + } } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 15c673daf..f0b86df0d 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -45,7 +45,8 @@ "hide": "Ocultar OpenScreen", "hideOthers": "Ocultar Outros", "unhide": "Mostrar Todos", - "saveDiagnostics": "Salvar Diagnósticos" + "saveDiagnostics": "Salvar Diagnósticos", + "permissions": "Permissões…" }, "updates": { "available": "O OpenScreen {{latestVersion}} está disponível. Você está usando a versão {{currentVersion}}.", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 4d1fc9270..5cc6d8407 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -95,5 +95,54 @@ "about": "Sobre", "version": "Versão {{version}}", "checkingForUpdates": "Verificando…" + }, + "permissions": { + "title": "O OpenScreen precisa de algumas permissões", + "subtitle": "O macOS pede que você conceda cada uma apenas uma vez. Você pode alterá-las a qualquer momento nos Ajustes do Sistema.", + "level": { + "required": "Obrigatória", + "recommended": "Recomendada", + "optional": "Opcional" + }, + "rows": { + "screen": { + "name": "Tela e áudio do sistema", + "description": "Para gravar sua tela e o som do seu Mac." + }, + "accessibility": { + "name": "Acessibilidade", + "description": "Para mostrar o cursor certo (seta, texto) nas suas gravações." + }, + "microphone": { + "name": "Microfone", + "description": "Para gravar sua voz." + }, + "camera": { + "name": "Câmera", + "description": "Para adicionar sua webcam às gravações." + } + }, + "status": { + "granted": "Permitido", + "restricted": "Gerenciado pela sua organização" + }, + "actions": { + "continue": "Continuar", + "allow": "Permitir", + "openSettings": "Abrir Ajustes", + "restart": "Reiniciar o OpenScreen", + "start": "Começar" + }, + "help": { + "screenPrompt": "Em seguida, o macOS pedirá que você ative o OpenScreen nos Ajustes do Sistema.", + "screenSettings": "Nos Ajustes do Sistema, ative o OpenScreen. Se o macOS oferecer encerrar e reabrir o app, aceite: esta janela voltará.", + "screenNotListed": "O OpenScreen não está na lista? Clique em + e escolha o OpenScreen em Aplicativos.", + "screenRestart": "Permitido. O OpenScreen precisa reiniciar para usá-la.", + "screenRecurring": "De vez em quando, o macOS pedirá que você confirme este acesso. Isso é normal." + }, + "footer": { + "screenRequired": "A gravação de tela é necessária para gravar.", + "ready": "Tudo pronto." + } } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 67411840b..5abfda0f3 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -45,7 +45,8 @@ "hide": "Скрыть OpenScreen", "hideOthers": "Скрыть остальные", "unhide": "Показать все", - "saveDiagnostics": "Сохранить диагностику" + "saveDiagnostics": "Сохранить диагностику", + "permissions": "Разрешения…" }, "updates": { "available": "Доступен OpenScreen {{latestVersion}}. Установлена версия {{currentVersion}}.", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 0af9564c4..0fadfb5f4 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -95,5 +95,54 @@ "about": "О программе", "version": "Версия {{version}}", "checkingForUpdates": "Проверка…" + }, + "permissions": { + "title": "OpenScreen нужны некоторые разрешения", + "subtitle": "macOS запрашивает каждое разрешение только один раз. Их можно изменить в любой момент в Системных настройках.", + "level": { + "required": "Обязательно", + "recommended": "Рекомендуется", + "optional": "Необязательно" + }, + "rows": { + "screen": { + "name": "Экран и системный звук", + "description": "Чтобы записывать экран и звук вашего Mac." + }, + "accessibility": { + "name": "Универсальный доступ", + "description": "Чтобы в записях отображался правильный курсор (стрелка, текст)." + }, + "microphone": { + "name": "Микрофон", + "description": "Чтобы записывать ваш голос." + }, + "camera": { + "name": "Камера", + "description": "Чтобы добавлять веб-камеру в записи." + } + }, + "status": { + "granted": "Разрешено", + "restricted": "Управляется вашей организацией" + }, + "actions": { + "continue": "Продолжить", + "allow": "Разрешить", + "openSettings": "Открыть настройки", + "restart": "Перезапустить OpenScreen", + "start": "Начать" + }, + "help": { + "screenPrompt": "Затем macOS попросит включить OpenScreen в Системных настройках.", + "screenSettings": "Включите OpenScreen в Системных настройках. Если macOS предложит закрыть и снова открыть приложение, согласитесь: это окно вернётся.", + "screenNotListed": "OpenScreen нет в списке? Нажмите «+» и выберите OpenScreen в папке «Программы».", + "screenRestart": "Разрешено. OpenScreen нужно перезапустить, чтобы использовать это разрешение.", + "screenRecurring": "Время от времени macOS будет просить подтвердить этот доступ. Это нормально." + }, + "footer": { + "screenRequired": "Для записи нужна запись экрана.", + "ready": "Всё готово." + } } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index a5bb28a32..b082906bf 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -45,7 +45,8 @@ "hide": "OpenScreen’i Gizle", "hideOthers": "Diğerlerini Gizle", "unhide": "Tümünü Göster", - "saveDiagnostics": "Teşhis Verilerini Kaydet" + "saveDiagnostics": "Teşhis Verilerini Kaydet", + "permissions": "İzinler…" }, "updates": { "available": "OpenScreen {{latestVersion}} kullanılabilir. Mevcut sürümünüz {{currentVersion}}.", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 632709c82..eb7c2f3b1 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -95,5 +95,54 @@ "about": "Hakkında", "version": "Sürüm {{version}}", "checkingForUpdates": "Denetleniyor…" + }, + "permissions": { + "title": "OpenScreen'in birkaç izne ihtiyacı var", + "subtitle": "macOS her birini yalnızca bir kez ister. Bunları istediğiniz zaman Sistem Ayarları'ndan değiştirebilirsiniz.", + "level": { + "required": "Gerekli", + "recommended": "Önerilen", + "optional": "İsteğe bağlı" + }, + "rows": { + "screen": { + "name": "Ekran ve sistem sesi", + "description": "Ekranınızı ve Mac'inizin sesini kaydetmek için." + }, + "accessibility": { + "name": "Erişilebilirlik", + "description": "Kayıtlarınızda doğru imleci (ok, metin) göstermek için." + }, + "microphone": { + "name": "Mikrofon", + "description": "Sesinizi kaydetmek için." + }, + "camera": { + "name": "Kamera", + "description": "Web kameranızı kayıtlarınıza eklemek için." + } + }, + "status": { + "granted": "İzin verildi", + "restricted": "Kuruluşunuz tarafından yönetiliyor" + }, + "actions": { + "continue": "Devam", + "allow": "İzin ver", + "openSettings": "Ayarları Aç", + "restart": "OpenScreen'i yeniden başlat", + "start": "Başla" + }, + "help": { + "screenPrompt": "Ardından macOS, Sistem Ayarları'nda OpenScreen'i açmanızı isteyecek.", + "screenSettings": "Sistem Ayarları'nda OpenScreen'i açın. macOS uygulamayı kapatıp yeniden açmayı önerirse kabul edin: bu pencere geri gelecek.", + "screenNotListed": "OpenScreen listede yok mu? + düğmesine tıklayın ve Uygulamalar'dan OpenScreen'i seçin.", + "screenRestart": "İzin verildi. OpenScreen'in bunu kullanabilmesi için yeniden başlatılması gerekiyor.", + "screenRecurring": "macOS zaman zaman bu erişimi onaylamanızı isteyecek. Bu normaldir." + }, + "footer": { + "screenRequired": "Kayıt için ekran kaydı gereklidir.", + "ready": "Her şey hazır." + } } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 0d4cc878b..d55b90865 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -45,7 +45,8 @@ "hide": "Ẩn OpenScreen", "hideOthers": "Ẩn ứng dụng khác", "unhide": "Hiển thị tất cả", - "saveDiagnostics": "Lưu thông tin chẩn đoán" + "saveDiagnostics": "Lưu thông tin chẩn đoán", + "permissions": "Quyền…" }, "updates": { "available": "Đã có OpenScreen {{latestVersion}}. Bạn đang dùng phiên bản {{currentVersion}}.", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 98405bf99..9775e4622 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -95,5 +95,54 @@ "about": "Giới thiệu", "version": "Phiên bản {{version}}", "checkingForUpdates": "Đang kiểm tra…" + }, + "permissions": { + "title": "OpenScreen cần một vài quyền", + "subtitle": "macOS chỉ yêu cầu bạn cấp mỗi quyền một lần. Bạn có thể thay đổi bất cứ lúc nào trong Cài đặt hệ thống.", + "level": { + "required": "Bắt buộc", + "recommended": "Nên dùng", + "optional": "Tùy chọn" + }, + "rows": { + "screen": { + "name": "Màn hình & âm thanh hệ thống", + "description": "Để ghi màn hình và âm thanh của máy Mac." + }, + "accessibility": { + "name": "Trợ năng", + "description": "Để hiển thị đúng con trỏ (mũi tên, văn bản) trong bản ghi." + }, + "microphone": { + "name": "Micrô", + "description": "Để ghi giọng nói của bạn." + }, + "camera": { + "name": "Camera", + "description": "Để thêm webcam vào bản ghi." + } + }, + "status": { + "granted": "Đã cho phép", + "restricted": "Do tổ chức của bạn quản lý" + }, + "actions": { + "continue": "Tiếp tục", + "allow": "Cho phép", + "openSettings": "Mở Cài đặt", + "restart": "Khởi động lại OpenScreen", + "start": "Bắt đầu" + }, + "help": { + "screenPrompt": "Sau đó macOS sẽ yêu cầu bạn bật OpenScreen trong Cài đặt hệ thống.", + "screenSettings": "Trong Cài đặt hệ thống, hãy bật OpenScreen. Nếu macOS đề nghị thoát và mở lại, hãy đồng ý: cửa sổ này sẽ quay lại.", + "screenNotListed": "Không thấy OpenScreen trong danh sách? Bấm +, rồi chọn OpenScreen trong Ứng dụng.", + "screenRestart": "Đã cho phép. OpenScreen cần khởi động lại để sử dụng quyền này.", + "screenRecurring": "Thỉnh thoảng macOS sẽ yêu cầu bạn xác nhận quyền truy cập này. Điều đó là bình thường." + }, + "footer": { + "screenRequired": "Cần quyền ghi màn hình để ghi.", + "ready": "Mọi thứ đã sẵn sàng." + } } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index e789cbd5b..1c603d9e3 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -45,7 +45,8 @@ "hide": "隐藏 OpenScreen", "hideOthers": "隐藏其他", "unhide": "显示全部", - "saveDiagnostics": "保存诊断信息" + "saveDiagnostics": "保存诊断信息", + "permissions": "权限…" }, "updates": { "available": "OpenScreen {{latestVersion}} 已发布。当前版本为 {{currentVersion}}。", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index e7efe8eec..3e01503fe 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -95,5 +95,54 @@ "about": "关于", "version": "版本 {{version}}", "checkingForUpdates": "正在检查…" + }, + "permissions": { + "title": "OpenScreen 需要一些权限", + "subtitle": "macOS 只会请求你授予一次。你可以随时在“系统设置”中更改。", + "level": { + "required": "必需", + "recommended": "推荐", + "optional": "可选" + }, + "rows": { + "screen": { + "name": "屏幕与系统音频", + "description": "用于录制你的屏幕和 Mac 播放的声音。" + }, + "accessibility": { + "name": "辅助功能", + "description": "用于在录制中显示正确的光标(箭头、文本)。" + }, + "microphone": { + "name": "麦克风", + "description": "用于录制你的声音。" + }, + "camera": { + "name": "摄像头", + "description": "用于在录制中加入你的网络摄像头。" + } + }, + "status": { + "granted": "已允许", + "restricted": "由你的组织管理" + }, + "actions": { + "continue": "继续", + "allow": "允许", + "openSettings": "打开设置", + "restart": "重新启动 OpenScreen", + "start": "开始使用" + }, + "help": { + "screenPrompt": "接下来 macOS 会请你在“系统设置”中开启 OpenScreen。", + "screenSettings": "请在“系统设置”中开启 OpenScreen。如果 macOS 提示退出并重新打开,请同意:此窗口会重新出现。", + "screenNotListed": "列表中没有 OpenScreen?点按 +,然后在“应用程序”中选择 OpenScreen。", + "screenRestart": "已允许。OpenScreen 需要重新启动才能使用。", + "screenRecurring": "macOS 会不时请你确认此访问权限,这是正常现象。" + }, + "footer": { + "screenRequired": "录制需要屏幕录制权限。", + "ready": "一切就绪。" + } } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 8e3d0648c..f003532b1 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -45,7 +45,8 @@ "hide": "隱藏 OpenScreen", "hideOthers": "隱藏其他", "unhide": "全部顯示", - "saveDiagnostics": "儲存診斷資料" + "saveDiagnostics": "儲存診斷資料", + "permissions": "權限…" }, "updates": { "available": "OpenScreen {{latestVersion}} 已推出。目前版本為 {{currentVersion}}。", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 025e422e7..db01e620f 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -95,5 +95,54 @@ "about": "關於", "version": "版本 {{version}}", "checkingForUpdates": "檢查中…" + }, + "permissions": { + "title": "OpenScreen 需要一些權限", + "subtitle": "macOS 只會請你授予一次。你可以隨時在「系統設定」中更改。", + "level": { + "required": "必要", + "recommended": "建議", + "optional": "選用" + }, + "rows": { + "screen": { + "name": "螢幕與系統音訊", + "description": "用於錄製你的螢幕與 Mac 播放的聲音。" + }, + "accessibility": { + "name": "輔助使用", + "description": "用於在錄製中顯示正確的游標(箭頭、文字)。" + }, + "microphone": { + "name": "麥克風", + "description": "用於錄製你的聲音。" + }, + "camera": { + "name": "相機", + "description": "用於在錄製中加入你的網路攝影機。" + } + }, + "status": { + "granted": "已允許", + "restricted": "由你的組織管理" + }, + "actions": { + "continue": "繼續", + "allow": "允許", + "openSettings": "打開設定", + "restart": "重新啟動 OpenScreen", + "start": "開始使用" + }, + "help": { + "screenPrompt": "接下來 macOS 會請你在「系統設定」中開啟 OpenScreen。", + "screenSettings": "請在「系統設定」中開啟 OpenScreen。如果 macOS 提議結束並重新打開,請同意:此視窗會再次出現。", + "screenNotListed": "列表中沒有 OpenScreen?按一下 +,然後在「應用程式」中選擇 OpenScreen。", + "screenRestart": "已允許。OpenScreen 需要重新啟動才能使用。", + "screenRecurring": "macOS 會不時請你確認此存取權限,這是正常的。" + }, + "footer": { + "screenRequired": "錄製需要螢幕錄製權限。", + "ready": "一切就緒。" + } } } From a9e705214c3818791b7b91c24b87afdc6435161d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 23 Sep 2026 19:43:12 +0200 Subject: [PATCH 2/3] fix(macos): bring the permissions window back after Quit & Reopen System Settings offers 'Quit & Reopen' the moment Screen Recording is turned on, and the window tells the user it comes back from that. It did not: at launch it only opened while Screen Recording was missing, so the relaunch left Accessibility, the microphone and the camera behind, and broke the window's own promise. The window now also opens at launch when the onboarding was started (its prompt raised) and not finished. It counts as finished once it is closed with Screen Recording granted. Users who held the grant before the window existed never raised a prompt through it, so an update does not show it to them. --- electron/permissions/index.ts | 55 ++++++++++++++------- electron/permissions/macPermissions.test.ts | 47 +++++++++++++++++- electron/permissions/macPermissions.ts | 32 +++++++++++- 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/electron/permissions/index.ts b/electron/permissions/index.ts index 0002df9e8..60f0f4898 100644 --- a/electron/permissions/index.ts +++ b/electron/permissions/index.ts @@ -32,31 +32,45 @@ const STORE_FILE = "permissions.json"; * the System Settings pane instead, which still works. A lost one costs a request macOS * silently ignores. Both leave the user with something to act on. */ +interface StoreFile { + requested?: Partial>; + completedAt?: string; +} + function createFileStore(userData: string): PermissionsStore { const file = path.join(userData, STORE_FILE); - let requested: Partial> = {}; + let state: StoreFile = {}; try { const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); - if (parsed && typeof parsed === "object" && "requested" in parsed) { - requested = (parsed as { requested: typeof requested }).requested ?? {}; + if (parsed && typeof parsed === "object") { + state = parsed as StoreFile; } } catch { // Missing or unreadable: nothing has been asked yet. } + const save = (next: StoreFile) => { + state = next; + const temporary = `${file}.${process.pid}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + renameSync(temporary, file); + } catch (error) { + // Best effort: the in-memory note still gets the rest of this launch right. + console.warn("[permissions] failed to persist the permissions note:", error); + } finally { + rmSync(temporary, { force: true }); + } + }; + return { - hasRequested: (kind) => typeof requested[kind] === "string", - markRequested: (kind) => { - requested = { ...requested, [kind]: new Date().toISOString() }; - const temporary = `${file}.${process.pid}.tmp`; - try { - writeFileSync(temporary, `${JSON.stringify({ requested }, null, 2)}\n`, "utf8"); - renameSync(temporary, file); - } catch (error) { - // Best effort: the in-memory note still gets the rest of this launch right. - console.warn("[permissions] failed to persist the request note:", error); - } finally { - rmSync(temporary, { force: true }); + hasRequested: (kind) => typeof state.requested?.[kind] === "string", + markRequested: (kind) => + save({ ...state, requested: { ...state.requested, [kind]: new Date().toISOString() } }), + isCompleted: () => typeof state.completedAt === "string", + markCompleted: () => { + if (typeof state.completedAt !== "string") { + save({ ...state, completedAt: new Date().toISOString() }); } }, }; @@ -115,18 +129,23 @@ export function showPermissionsWindow(): void { permissionsWindow = createPermissionsWindow(); permissionsWindow.on("closed", () => { permissionsWindow = null; + const permissions = getMacPermissions(); + void permissions + .read() + .then((snapshot) => permissions.noteWindowClosed(snapshot)) + .catch(() => undefined); }); } -/** Opens the permissions window when recording cannot work yet. For app launch. */ +/** Opens the permissions window at launch when it belongs there (see shouldShowAtLaunch). */ export async function showPermissionsWindowIfNeeded(): Promise { // Not in the headless e2e runs either: there is no one to answer, and the probe would // hold the window open behind every spec. if (process.platform !== "darwin" || process.env["HEADLESS"] === "true") { return; } - const snapshot = await getMacPermissions().read(); - if (snapshot.screen !== "granted" || snapshot.screenRequiresRelaunch) { + const permissions = getMacPermissions(); + if (permissions.shouldShowAtLaunch(await permissions.read())) { showPermissionsWindow(); } } diff --git a/electron/permissions/macPermissions.test.ts b/electron/permissions/macPermissions.test.ts index 426bb3b53..61baf213c 100644 --- a/electron/permissions/macPermissions.test.ts +++ b/electron/permissions/macPermissions.test.ts @@ -9,6 +9,7 @@ import { function setup(overrides: Partial = {}) { const requested = new Set(); + const completed = { value: false }; const deps: MacPermissionsDeps = { platform: "darwin", macosMajor: 26, @@ -24,10 +25,14 @@ function setup(overrides: Partial = {}) { markRequested: (kind) => { requested.add(kind); }, + isCompleted: () => completed.value, + markCompleted: () => { + completed.value = true; + }, }, ...overrides, }; - return { deps, requested, permissions: createMacPermissions(deps) }; + return { deps, requested, completed, permissions: createMacPermissions(deps) }; } describe("read", () => { @@ -172,6 +177,46 @@ describe("request", () => { }); }); +describe("shouldShowAtLaunch", () => { + const granted = { probeScreen: async () => ({ answered: true, granted: true }) as const }; + + it("shows while Screen Recording is missing", async () => { + const { permissions } = setup(); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(true); + }); + + it("comes back after System Settings' Quit & Reopen, mid-onboarding", async () => { + // The grant is in and the app was relaunched by System Settings: the other rows are + // still waiting, and the window promised to return. + const { permissions, requested } = setup({ ...granted, appScreenGranted: () => true }); + requested.add("screen"); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(true); + }); + + it("stays away once the window was closed with the grant in hand", async () => { + const { permissions, requested } = setup({ ...granted, appScreenGranted: () => true }); + requested.add("screen"); + permissions.noteWindowClosed(await permissions.read()); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(false); + }); + + it("does not count a close without the grant as finishing", async () => { + const { permissions, completed } = setup(); + permissions.noteWindowClosed(await permissions.read()); + expect(completed.value).toBe(false); + }); + + it("never shows to someone who held the grant before the window existed", async () => { + const { permissions } = setup({ ...granted, appScreenGranted: () => true }); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(false); + }); + + it("never shows off macOS", async () => { + const { permissions } = setup({ platform: "linux" }); + expect(permissions.shouldShowAtLaunch(await permissions.read())).toBe(false); + }); +}); + describe("permissionSettingsUrl", () => { it("uses the pane anchors that open on every supported macOS", () => { expect(permissionSettingsUrl("screen")).toBe( diff --git a/electron/permissions/macPermissions.ts b/electron/permissions/macPermissions.ts index b86fd4cf6..14ee5b9be 100644 --- a/electron/permissions/macPermissions.ts +++ b/electron/permissions/macPermissions.ts @@ -58,6 +58,9 @@ export type NotedKind = "screen" | "accessibility"; export interface PermissionsStore { hasRequested(kind: NotedKind): boolean; markRequested(kind: NotedKind): void; + /** The window was closed with Screen Recording granted: the onboarding is over. */ + isCompleted(): boolean; + markCompleted(): void; } export interface MacPermissionsDeps { @@ -195,7 +198,34 @@ export function createMacPermissions(deps: MacPermissionsDeps) { deps.store.markRequested(kind); } - return { read, request, openSettings, noteRequested }; + /** + * Whether the permissions window belongs on screen at launch. + * + * While recording cannot work, obviously. But also after a relaunch in the middle of + * the onboarding: System Settings offers "Quit & Reopen" the moment Screen Recording is + * turned on, and the window has promised the user it comes back from that -- with + * Accessibility, the microphone and the camera still to go. Someone who already held + * the grant before this window existed never raised a prompt through it, so they do + * not get it on the first launch after an update. + */ + function shouldShowAtLaunch(snapshot: PermissionsSnapshot): boolean { + if (!snapshot.supported) { + return false; + } + if (snapshot.screen !== "granted" || snapshot.screenRequiresRelaunch) { + return true; + } + return deps.store.hasRequested("screen") && !deps.store.isCompleted(); + } + + /** Called when the window closes: done once Screen Recording is in hand. */ + function noteWindowClosed(snapshot: PermissionsSnapshot): void { + if (snapshot.screen === "granted") { + deps.store.markCompleted(); + } + } + + return { read, request, openSettings, noteRequested, shouldShowAtLaunch, noteWindowClosed }; } export type MacPermissions = ReturnType; From e94d78c9b32cefb836c5646997d40d5039a56265 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Wed, 23 Sep 2026 20:55:23 +0200 Subject: [PATCH 3/3] fix(macos): say which button the 'bypass the private window picker' alert needs From macOS 15 every ScreenCaptureKit app outside Apple's picker gets an alert asking whether it may bypass the system private window picker, again from time to time. The window's line about it ('macOS will occasionally ask you to confirm this access') was too vague for anyone to connect it with that alert when it arrived. The line now describes the alert, says to click Allow, and says it comes back. It stays an explanation rather than a row: replayd shows the alert on its own schedule whatever the last answer was, and its approval is unreadable, so there is nothing to trigger or to show a status for. --- src/components/permissions/PermissionsWindow.tsx | 9 ++++++++- src/i18n/locales/ar/launch.json | 2 +- src/i18n/locales/cs/launch.json | 2 +- src/i18n/locales/de/launch.json | 2 +- src/i18n/locales/en/launch.json | 2 +- src/i18n/locales/es/launch.json | 2 +- src/i18n/locales/fr/launch.json | 2 +- src/i18n/locales/it/launch.json | 2 +- src/i18n/locales/ja-JP/launch.json | 2 +- src/i18n/locales/ko-KR/launch.json | 2 +- src/i18n/locales/pt-BR/launch.json | 2 +- src/i18n/locales/ru/launch.json | 2 +- src/i18n/locales/tr/launch.json | 2 +- src/i18n/locales/vi/launch.json | 2 +- src/i18n/locales/zh-CN/launch.json | 2 +- src/i18n/locales/zh-TW/launch.json | 2 +- 16 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/components/permissions/PermissionsWindow.tsx b/src/components/permissions/PermissionsWindow.tsx index c70af98c3..066f24e9e 100644 --- a/src/components/permissions/PermissionsWindow.tsx +++ b/src/components/permissions/PermissionsWindow.tsx @@ -25,7 +25,14 @@ const ROWS: ReadonlyArray<{ { kind: "camera", level: "optional", Icon: Video }, ]; -/** macOS 15 re-confirms ScreenCaptureKit access periodically for every app that uses it. */ +/** + * From macOS 15, any app that uses ScreenCaptureKit outside Apple's system picker gets an + * alert asking whether it may "bypass the system private window picker", on top of the + * Screen Recording grant and again periodically. It cannot be raised on demand (replayd + * shows it on its own schedule, whatever the last answer was), and its approval cannot be + * read, so it cannot be a row with a status. What the window can do is say it is coming, + * and which button to press, before the first recording meets it. + */ const RECURRING_SCREEN_ALERT_FROM_MACOS = 15; export function PermissionsWindow() { diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index a19bd22a9..b30147f99 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -138,7 +138,7 @@ "screenSettings": "في إعدادات النظام، فعِّل OpenScreen. إذا عرض macOS إنهاء التطبيق وإعادة فتحه، فوافق: ستعود هذه النافذة.", "screenNotListed": "لا يظهر OpenScreen في القائمة؟ انقر على +، ثم اختر OpenScreen من التطبيقات.", "screenRestart": "مسموح. يجب إعادة تشغيل OpenScreen ليتمكن من استخدامه.", - "screenRecurring": "سيطلب منك macOS من حين لآخر تأكيد هذا الوصول. هذا أمر طبيعي." + "screenRecurring": "عند أول تسجيل، سيسألك macOS ما إذا كان يمكن لـ OpenScreen الوصول إلى شاشتك مباشرة دون منتقي النظام. انقر على «السماح». يكرر macOS هذا السؤال من حين لآخر، وهذا أمر طبيعي." }, "footer": { "screenRequired": "تسجيل الشاشة مطلوب للتسجيل.", diff --git a/src/i18n/locales/cs/launch.json b/src/i18n/locales/cs/launch.json index 6a44f4a6a..c1e8d145d 100644 --- a/src/i18n/locales/cs/launch.json +++ b/src/i18n/locales/cs/launch.json @@ -138,7 +138,7 @@ "screenSettings": "V Nastavení systému zapněte OpenScreen. Pokud macOS nabídne aplikaci ukončit a znovu otevřít, přijměte: toto okno se vrátí.", "screenNotListed": "OpenScreen v seznamu chybí? Klikněte na + a vyberte OpenScreen ve složce Aplikace.", "screenRestart": "Povoleno. OpenScreen se musí restartovat, aby oprávnění mohl použít.", - "screenRecurring": "macOS vás občas požádá o potvrzení tohoto přístupu. To je v pořádku." + "screenRecurring": "Při prvním nahrávání se vás macOS zeptá, zda smí OpenScreen přistupovat k obrazovce přímo, bez systémového výběru. Klikněte na Povolit. macOS se čas od času zeptá znovu, to je v pořádku." }, "footer": { "screenRequired": "K nahrávání je potřeba nahrávání obrazovky.", diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index 9aa284046..0f68c3070 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -138,7 +138,7 @@ "screenSettings": "Aktiviere OpenScreen in den Systemeinstellungen. Wenn macOS anbietet, die App zu beenden und neu zu öffnen, stimme zu: Dieses Fenster erscheint wieder.", "screenNotListed": "OpenScreen fehlt in der Liste? Klicke auf + und wähle OpenScreen unter „Programme“.", "screenRestart": "Erlaubt. OpenScreen muss neu starten, um die Berechtigung zu nutzen.", - "screenRecurring": "macOS bittet dich gelegentlich, diesen Zugriff zu bestätigen. Das ist normal." + "screenRecurring": "Bei deiner ersten Aufnahme fragt macOS, ob OpenScreen ohne die Systemauswahl direkt auf deinen Bildschirm zugreifen darf. Klicke auf „Erlauben“. macOS fragt von Zeit zu Zeit erneut – das ist normal." }, "footer": { "screenRequired": "Zum Aufnehmen ist die Bildschirmaufnahme erforderlich.", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 263b4e9b9..0830e722d 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -138,7 +138,7 @@ "screenSettings": "In System Settings, turn on OpenScreen. If macOS offers to quit and reopen it, accept: this window will come back.", "screenNotListed": "OpenScreen isn't in the list? Click +, then choose OpenScreen in Applications.", "screenRestart": "Allowed. OpenScreen needs to restart before it can use it.", - "screenRecurring": "macOS will occasionally ask you to confirm this access. That's expected." + "screenRecurring": "On your first recording, macOS will ask whether OpenScreen may access your screen directly, without the system picker. Click Allow. macOS asks again from time to time; that's expected." }, "footer": { "screenRequired": "Screen recording is needed to record.", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 4587d841d..2255a9d7e 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -138,7 +138,7 @@ "screenSettings": "En Ajustes del Sistema, activa OpenScreen. Si macOS ofrece cerrarlo y volver a abrirlo, acepta: esta ventana volverá.", "screenNotListed": "¿OpenScreen no aparece en la lista? Haz clic en + y elige OpenScreen en Aplicaciones.", "screenRestart": "Permitido. OpenScreen debe reiniciarse para poder usarlo.", - "screenRecurring": "De vez en cuando, macOS te pedirá que confirmes este acceso. Es normal." + "screenRecurring": "En tu primera grabación, macOS te preguntará si OpenScreen puede acceder directamente a tu pantalla, sin el selector del sistema. Haz clic en Permitir. macOS vuelve a preguntar de vez en cuando; es normal." }, "footer": { "screenRequired": "Se necesita la grabación de pantalla para grabar.", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 1d9f5fbb6..8713a0bb1 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -138,7 +138,7 @@ "screenSettings": "Dans Réglages Système, activez OpenScreen. Si macOS propose de le quitter et le rouvrir, acceptez : cette fenêtre reviendra.", "screenNotListed": "OpenScreen n'apparaît pas dans la liste ? Cliquez sur +, puis choisissez OpenScreen dans Applications.", "screenRestart": "Autorisé. OpenScreen doit redémarrer pour pouvoir l'utiliser.", - "screenRecurring": "macOS vous demandera de temps en temps de confirmer cet accès. C'est normal." + "screenRecurring": "À votre premier enregistrement, macOS vous demandera si OpenScreen peut accéder directement à votre écran, sans passer par le sélecteur du système. Cliquez sur Autoriser. macOS repose la question de temps en temps, c'est normal." }, "footer": { "screenRequired": "L'enregistrement de l'écran est nécessaire pour enregistrer.", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index 2a7cc0a96..76352d0b8 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -138,7 +138,7 @@ "screenSettings": "In Impostazioni di Sistema, attiva OpenScreen. Se macOS propone di chiuderlo e riaprirlo, accetta: questa finestra tornerà.", "screenNotListed": "OpenScreen non è nell'elenco? Fai clic su +, poi scegli OpenScreen in Applicazioni.", "screenRestart": "Consentito. OpenScreen deve riavviarsi per poterlo usare.", - "screenRecurring": "Di tanto in tanto macOS ti chiederà di confermare questo accesso. È normale." + "screenRecurring": "Alla prima registrazione, macOS ti chiederà se OpenScreen può accedere direttamente allo schermo, senza il selettore di sistema. Fai clic su Consenti. macOS lo richiede di tanto in tanto: è normale." }, "footer": { "screenRequired": "Per registrare serve la registrazione dello schermo.", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index 9dc54e90a..1b19f0a3d 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -138,7 +138,7 @@ "screenSettings": "システム設定で OpenScreen をオンにしてください。macOS が終了して再度開くよう提案した場合は、そのまま進めてください。このウインドウが再び表示されます。", "screenNotListed": "リストに OpenScreen がない場合は、+をクリックして「アプリケーション」から OpenScreen を選んでください。", "screenRestart": "許可済みです。使用するには OpenScreen の再起動が必要です。", - "screenRecurring": "macOS からときどきこのアクセスの確認を求められます。これは正常な動作です。" + "screenRecurring": "最初の録画時に、macOS から OpenScreen がシステムのピッカーを使わずに画面へ直接アクセスしてよいか確認されます。「許可」をクリックしてください。macOS はときどき再確認します。これは正常な動作です。" }, "footer": { "screenRequired": "録画には画面収録が必要です。", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 2e2abe3e9..bb16d5206 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -138,7 +138,7 @@ "screenSettings": "시스템 설정에서 OpenScreen을 켜세요. macOS가 종료 후 다시 열기를 제안하면 수락하세요. 이 창이 다시 표시됩니다.", "screenNotListed": "목록에 OpenScreen이 없나요? +를 클릭한 다음 응용 프로그램에서 OpenScreen을 선택하세요.", "screenRestart": "허용되었습니다. 이 권한을 사용하려면 OpenScreen을 재시작해야 합니다.", - "screenRecurring": "macOS가 가끔 이 접근 권한을 확인하도록 요청합니다. 정상적인 동작입니다." + "screenRecurring": "처음 녹화할 때 macOS가 OpenScreen이 시스템 선택기 없이 화면에 직접 접근해도 되는지 묻습니다. 허용을 클릭하세요. macOS는 가끔 다시 묻습니다. 정상적인 동작입니다." }, "footer": { "screenRequired": "녹화하려면 화면 기록 권한이 필요합니다.", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 5cc6d8407..ff20303d7 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -138,7 +138,7 @@ "screenSettings": "Nos Ajustes do Sistema, ative o OpenScreen. Se o macOS oferecer encerrar e reabrir o app, aceite: esta janela voltará.", "screenNotListed": "O OpenScreen não está na lista? Clique em + e escolha o OpenScreen em Aplicativos.", "screenRestart": "Permitido. O OpenScreen precisa reiniciar para usá-la.", - "screenRecurring": "De vez em quando, o macOS pedirá que você confirme este acesso. Isso é normal." + "screenRecurring": "Na sua primeira gravação, o macOS perguntará se o OpenScreen pode acessar sua tela diretamente, sem o seletor do sistema. Clique em Permitir. O macOS pergunta de novo de vez em quando; isso é normal." }, "footer": { "screenRequired": "A gravação de tela é necessária para gravar.", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 0fadfb5f4..bd1963f6b 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -138,7 +138,7 @@ "screenSettings": "Включите OpenScreen в Системных настройках. Если macOS предложит закрыть и снова открыть приложение, согласитесь: это окно вернётся.", "screenNotListed": "OpenScreen нет в списке? Нажмите «+» и выберите OpenScreen в папке «Программы».", "screenRestart": "Разрешено. OpenScreen нужно перезапустить, чтобы использовать это разрешение.", - "screenRecurring": "Время от времени macOS будет просить подтвердить этот доступ. Это нормально." + "screenRecurring": "При первой записи macOS спросит, может ли OpenScreen получать доступ к экрану напрямую, без системного окна выбора. Нажмите «Разрешить». Время от времени macOS будет спрашивать снова — это нормально." }, "footer": { "screenRequired": "Для записи нужна запись экрана.", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index eb7c2f3b1..23fece26d 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -138,7 +138,7 @@ "screenSettings": "Sistem Ayarları'nda OpenScreen'i açın. macOS uygulamayı kapatıp yeniden açmayı önerirse kabul edin: bu pencere geri gelecek.", "screenNotListed": "OpenScreen listede yok mu? + düğmesine tıklayın ve Uygulamalar'dan OpenScreen'i seçin.", "screenRestart": "İzin verildi. OpenScreen'in bunu kullanabilmesi için yeniden başlatılması gerekiyor.", - "screenRecurring": "macOS zaman zaman bu erişimi onaylamanızı isteyecek. Bu normaldir." + "screenRecurring": "İlk kaydınızda macOS, OpenScreen'in sistem seçicisi olmadan ekranınıza doğrudan erişip erişemeyeceğini soracak. İzin Ver'e tıklayın. macOS bunu zaman zaman yeniden sorar; bu normaldir." }, "footer": { "screenRequired": "Kayıt için ekran kaydı gereklidir.", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 9775e4622..82581c48e 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -138,7 +138,7 @@ "screenSettings": "Trong Cài đặt hệ thống, hãy bật OpenScreen. Nếu macOS đề nghị thoát và mở lại, hãy đồng ý: cửa sổ này sẽ quay lại.", "screenNotListed": "Không thấy OpenScreen trong danh sách? Bấm +, rồi chọn OpenScreen trong Ứng dụng.", "screenRestart": "Đã cho phép. OpenScreen cần khởi động lại để sử dụng quyền này.", - "screenRecurring": "Thỉnh thoảng macOS sẽ yêu cầu bạn xác nhận quyền truy cập này. Điều đó là bình thường." + "screenRecurring": "Ở lần ghi đầu tiên, macOS sẽ hỏi OpenScreen có được truy cập trực tiếp màn hình mà không qua bộ chọn của hệ thống hay không. Hãy bấm Cho phép. Thỉnh thoảng macOS sẽ hỏi lại; điều đó là bình thường." }, "footer": { "screenRequired": "Cần quyền ghi màn hình để ghi.", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 3e01503fe..54d6304ad 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -138,7 +138,7 @@ "screenSettings": "请在“系统设置”中开启 OpenScreen。如果 macOS 提示退出并重新打开,请同意:此窗口会重新出现。", "screenNotListed": "列表中没有 OpenScreen?点按 +,然后在“应用程序”中选择 OpenScreen。", "screenRestart": "已允许。OpenScreen 需要重新启动才能使用。", - "screenRecurring": "macOS 会不时请你确认此访问权限,这是正常现象。" + "screenRecurring": "首次录制时,macOS 会询问是否允许 OpenScreen 不通过系统选择器直接访问你的屏幕。请点按“允许”。macOS 会不时再次询问,这是正常现象。" }, "footer": { "screenRequired": "录制需要屏幕录制权限。", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index db01e620f..c2e457aae 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -138,7 +138,7 @@ "screenSettings": "請在「系統設定」中開啟 OpenScreen。如果 macOS 提議結束並重新打開,請同意:此視窗會再次出現。", "screenNotListed": "列表中沒有 OpenScreen?按一下 +,然後在「應用程式」中選擇 OpenScreen。", "screenRestart": "已允許。OpenScreen 需要重新啟動才能使用。", - "screenRecurring": "macOS 會不時請你確認此存取權限,這是正常的。" + "screenRecurring": "第一次錄製時,macOS 會詢問是否允許 OpenScreen 不透過系統選擇器直接取用你的螢幕。請按一下「允許」。macOS 會不時再次詢問,這是正常的。" }, "footer": { "screenRequired": "錄製需要螢幕錄製權限。",