From cfa3136d775af1f2bd44d9311d104b69e89bd6cf Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 28 Sep 2026 17:40:59 +0200 Subject: [PATCH 1/3] fix: handle macOS capture permission failures --- electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 32 ++++-- .../ipc/nativeMacMidCaptureErrorWatch.test.ts | 44 +++++-- electron/ipc/nativeMacMidCaptureErrorWatch.ts | 11 ++ electron/preload.ts | 5 + .../ai-edition/v4/FloatingInspector.tsx | 3 +- .../ai-edition/v4/MediaStage.test.ts | 28 ++++- src/components/ai-edition/v4/MediaStage.tsx | 15 ++- .../ai-edition/v4/ZoomLevelControl.test.tsx | 5 +- ...eenRecorder.nativeMacStartWarning.test.tsx | 108 +++++++++++++++++- src/hooks/useScreenRecorder.ts | 34 +++--- src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/cs/editor.json | 1 + src/i18n/locales/de/editor.json | 1 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/it/editor.json | 1 + src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-TW/editor.json | 1 + .../testing/manual-e2e-checklist.md | 10 +- 27 files changed, 262 insertions(+), 49 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 29d5f828b..669e3ed25 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -172,6 +172,7 @@ interface Window { startNativeMacRecording: ( request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, ) => Promise; + onNativeMacSystemAudioUnavailable: (callback: () => void) => () => void; pauseNativeMacRecording: () => Promise<{ success: boolean; error?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index bd19f1059..6e6bac45f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1605,6 +1605,7 @@ function inspectNativeMacCaptureOutput() { function attachNativeMacCaptureOutputDrain( proc: ChildProcessWithoutNullStreams, onTakeEnded: () => void, + onSystemAudioUnavailable: () => void, ) { let lineBuffer = ""; // Hooked here rather than on `nativeMacCaptureEvents`, which the start wait @@ -1612,6 +1613,7 @@ function attachNativeMacCaptureOutputDrain( const watchLiveTake = createNativeMacMidCaptureErrorWatch( () => nativeMacCaptureProcess === proc && !nativeMacStopInFlight, onTakeEnded, + onSystemAudioUnavailable, ); const drain = (chunk: Buffer) => { const text = chunk.toString(); @@ -2287,12 +2289,11 @@ export function registerIpcHandlers( return access; } - // 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. + // Accessibility improves cursor shape hints but is not required to record. + // Remember that the helper raised the system prompt so a later visit to the + // permissions window can direct the user to Settings, but don't reopen that + // window from every Record press. getMacPermissions().noteRequested("accessibility"); - showPermissionsWindow(); } return access; @@ -3108,12 +3109,21 @@ export function registerIpcHandlers( // When the take ends without the user — the helper reported an error or // exited — this drives the renderer's own stop, the same one the tray's Stop // Recording sends: it clears the HUD and surfaces the result. - attachNativeMacCaptureOutputDrain(proc, () => { - const hudWindow = getMainWindow(); - if (hudWindow && !hudWindow.isDestroyed()) { - hudWindow.webContents.send("stop-recording-from-tray"); - } - }); + attachNativeMacCaptureOutputDrain( + proc, + () => { + const hudWindow = getMainWindow(); + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.webContents.send("stop-recording-from-tray"); + } + }, + () => { + const hudWindow = getMainWindow(); + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.webContents.send("native-mac-system-audio-unavailable"); + } + }, + ); await waitForNativeMacCaptureStart(proc); const captureStartedAtMs = Date.now(); diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts index c6958cd4b..6eaec1ef3 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.test.ts @@ -6,7 +6,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => { it("fires on an error raised after recording started", () => { const onError = vi.fn(); - const watch = createNativeMacMidCaptureErrorWatch(() => true, onError); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onError, vi.fn()); watch({ event: "ready" }); watch({ event: "recording-started" }); @@ -17,7 +17,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => { it("leaves an error raised before recording started to the start wait", () => { const onError = vi.fn(); - const watch = createNativeMacMidCaptureErrorWatch(() => true, onError); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onError, vi.fn()); watch(error); @@ -26,7 +26,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => { it("ignores events that are not errors", () => { const onError = vi.fn(); - const watch = createNativeMacMidCaptureErrorWatch(() => true, onError); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onError, vi.fn()); watch({ event: "recording-started" }); watch({ event: "warning", code: "stop-capture-failed" }); @@ -38,7 +38,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => { it("ignores a helper that is no longer the current process", () => { const onError = vi.fn(); let current = true; - const watch = createNativeMacMidCaptureErrorWatch(() => current, onError); + const watch = createNativeMacMidCaptureErrorWatch(() => current, onError, vi.fn()); watch({ event: "recording-started" }); current = false; @@ -50,7 +50,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => { /** Killed or crashed: no error line ever comes, only the process closing. */ it("fires when the helper exits in the middle of a take", () => { const onTakeEnded = vi.fn(); - const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded, vi.fn()); watch({ event: "recording-started" }); watch.exited(); @@ -60,7 +60,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => { it("leaves an exit before recording started to the start wait", () => { const onTakeEnded = vi.fn(); - const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded); + const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded, vi.fn()); watch.exited(); @@ -71,7 +71,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => { it("ignores the exit of a take that is already being stopped", () => { const onTakeEnded = vi.fn(); let live = true; - const watch = createNativeMacMidCaptureErrorWatch(() => live, onTakeEnded); + const watch = createNativeMacMidCaptureErrorWatch(() => live, onTakeEnded, vi.fn()); watch({ event: "recording-started" }); live = false; @@ -79,4 +79,34 @@ describe("createNativeMacMidCaptureErrorWatch", () => { expect(onTakeEnded).not.toHaveBeenCalled(); }); + + it("surfaces a system-audio warning once without ending the take", () => { + const onTakeEnded = vi.fn(); + const onSystemAudioUnavailable = vi.fn(); + const watch = createNativeMacMidCaptureErrorWatch( + () => true, + onTakeEnded, + onSystemAudioUnavailable, + ); + + watch({ event: "recording-started" }); + watch({ event: "warning", code: "system-audio-unavailable" }); + watch({ event: "warning", code: "system-audio-unavailable" }); + + expect(onSystemAudioUnavailable).toHaveBeenCalledOnce(); + expect(onTakeEnded).not.toHaveBeenCalled(); + }); + + it("does not surface the system-audio warning for a take that is no longer live", () => { + const onSystemAudioUnavailable = vi.fn(); + const watch = createNativeMacMidCaptureErrorWatch( + () => false, + vi.fn(), + onSystemAudioUnavailable, + ); + + watch({ event: "warning", code: "system-audio-unavailable" }); + + expect(onSystemAudioUnavailable).not.toHaveBeenCalled(); + }); }); diff --git a/electron/ipc/nativeMacMidCaptureErrorWatch.ts b/electron/ipc/nativeMacMidCaptureErrorWatch.ts index 6c786d3f3..48f862888 100644 --- a/electron/ipc/nativeMacMidCaptureErrorWatch.ts +++ b/electron/ipc/nativeMacMidCaptureErrorWatch.ts @@ -15,12 +15,23 @@ export function createNativeMacMidCaptureErrorWatch( isLiveTake: () => boolean, onTakeEnded: () => void, + onSystemAudioUnavailable: () => void, ) { let recordingStarted = false; + let reportedSystemAudioUnavailable = false; const watch = (event: Record) => { if (event.event === "recording-started") { recordingStarted = true; } + if ( + event.event === "warning" && + event.code === "system-audio-unavailable" && + !reportedSystemAudioUnavailable && + isLiveTake() + ) { + reportedSystemAudioUnavailable = true; + onSystemAudioUnavailable(); + } if (event.event === "error" && recordingStarted && isLiveTake()) { onTakeEnded(); } diff --git a/electron/preload.ts b/electron/preload.ts index 80e583f24..59d54c217 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -255,6 +255,11 @@ contextBridge.exposeInMainWorld("electronAPI", { startNativeMacRecording: (request: NativeMacRecordingRequest) => { return ipcRenderer.invoke("start-native-mac-recording", request); }, + onNativeMacSystemAudioUnavailable: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("native-mac-system-audio-unavailable", listener); + return () => ipcRenderer.removeListener("native-mac-system-audio-unavailable", listener); + }, pauseNativeMacRecording: () => { return ipcRenderer.invoke("pause-native-mac-recording"); }, diff --git a/src/components/ai-edition/v4/FloatingInspector.tsx b/src/components/ai-edition/v4/FloatingInspector.tsx index 458664003..eb87497e3 100644 --- a/src/components/ai-edition/v4/FloatingInspector.tsx +++ b/src/components/ai-edition/v4/FloatingInspector.tsx @@ -497,6 +497,7 @@ export function ZoomLevelControl({ }; const presets = ZOOM_PRESETS.filter((preset) => preset.value <= maxScale); + const hasSelectedPreset = presets.some((preset) => preset.value === requested); return ( <> @@ -519,7 +520,7 @@ export function ZoomLevelControl({ type="text" inputMode="decimal" aria-label={ts("zoom.customScale")} - placeholder={`${requested}×`} + placeholder={hasSelectedPreset ? "" : `${requested}×`} value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={commitDraft} diff --git a/src/components/ai-edition/v4/MediaStage.test.ts b/src/components/ai-edition/v4/MediaStage.test.ts index ee6f7a0c5..6fa306e83 100644 --- a/src/components/ai-edition/v4/MediaStage.test.ts +++ b/src/components/ai-edition/v4/MediaStage.test.ts @@ -1,5 +1,31 @@ import { describe, expect, it, vi } from "vitest"; -import { addSelectedAssetToTimeline } from "./MediaStage"; +import type { AxcutTranscript } from "@/lib/ai-edition/schema"; +import { addSelectedAssetToTimeline, detectedLanguageForTranscript } from "./MediaStage"; + +function transcript(language: string, text = ""): AxcutTranscript { + return { + assetId: "asset-1", + language, + segments: text + ? [{ id: "segment-1", kind: "speech", startSec: 0, endSec: 1, text, wordIds: [] }] + : [], + words: [], + }; +} + +describe("detectedLanguageForTranscript", () => { + it("hides the detected language when the transcript has no speech", () => { + expect(detectedLanguageForTranscript(transcript("en"))).toBeNull(); + }); + + it("shows the detected language when the transcript contains speech", () => { + expect(detectedLanguageForTranscript(transcript("en", "hello there"))).toBe("en"); + }); + + it("does not show automatic language selection as a detected language", () => { + expect(detectedLanguageForTranscript(transcript("auto", "hello there"))).toBeNull(); + }); +}); describe("addSelectedAssetToTimeline", () => { it("reports success only once the selected asset has been added", async () => { diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx index ba21bc149..df2ee7f68 100644 --- a/src/components/ai-edition/v4/MediaStage.tsx +++ b/src/components/ai-edition/v4/MediaStage.tsx @@ -2,7 +2,7 @@ import { ArrowDown, Film, Plus, RotateCw, Search, X } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import type { AxcutAsset, TranscriptLanguageCode } from "@/lib/ai-edition/schema"; +import type { AxcutAsset, AxcutTranscript, TranscriptLanguageCode } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useAssetTranscriptions, @@ -17,6 +17,7 @@ import { type AssetTranscriptionStatus, type AssetTranscriptionView, isSilentFailure, + transcriptHasSpeech, } from "@/lib/ai-edition/transcription/status"; import { formatBytes } from "@/utils/formatBytes"; import { @@ -38,6 +39,13 @@ function basename(path: string): string { return path.split(/[\\/]/).pop() ?? path; } +export function detectedLanguageForTranscript(transcript: AxcutTranscript | null): string | null { + if (!transcript || transcript.language === "auto" || !transcriptHasSpeech(transcript)) { + return null; + } + return transcript.language; +} + export async function addSelectedAssetToTimeline( selected: Pick | null, onAddToTimeline: (assetId: string) => Promise, @@ -90,6 +98,7 @@ export function MediaStage({ const transcript = selected ? (document?.transcripts?.find((t) => t.assetId === selected.id) ?? null) : null; + const detectedLanguage = detectedLanguageForTranscript(transcript); const selectedTranscription: AssetTranscriptionView = selected ? (transcriptions[selected.id] ?? { assetId: selected.id, status: "idle" }) : { assetId: "", status: "idle" }; @@ -337,7 +346,7 @@ export function MediaStage({ reaching the document and being displayed nowhere. It belongs next to "Regenerate as" below in any case: that selector is the control you set BECAUSE of what was detected. */} - {transcript?.language && transcript.language !== "auto" ? ( + {detectedLanguage ? ( {t("mediaStage.detectedLanguage", { - language: languageLabel(transcript.language, locale), + language: languageLabel(detectedLanguage, locale), })} ) : null} diff --git a/src/components/ai-edition/v4/ZoomLevelControl.test.tsx b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx index b3d238cea..34c1e3503 100644 --- a/src/components/ai-edition/v4/ZoomLevelControl.test.tsx +++ b/src/components/ai-edition/v4/ZoomLevelControl.test.tsx @@ -73,7 +73,7 @@ describe("ZoomLevelControl", () => { updateZoomCustomScale.mockClear(); }); - it("renders four presets, labelled with the table value, current one pressed", () => { + it("renders the selected preset without repeating its value in the custom-scale field", () => { const { buttons, field } = renderControl(3); expect(buttons.map((b) => b.textContent)).toEqual( ([2, 3, 4, 5] as const).map((d) => `${ZOOM_DEPTH_SCALES[d]}×`), @@ -84,7 +84,8 @@ describe("ZoomLevelControl", () => { "false", "false", ]); - expect(field).toHaveAttribute("placeholder", "1.8×"); + expect(field).toBeInTheDocument(); + expect(field).toHaveAttribute("placeholder", ""); }); // The ends of the table and every custom level live in the field, not the row. diff --git a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx index 07733a3f0..b3183d7f7 100644 --- a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx +++ b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx @@ -18,8 +18,10 @@ type ElectronAPI = Window["electronAPI"]; const SOURCE = { id: "screen:0:0", name: "Screen 1", display_id: "1", thumbnail: "" }; let api: Record>; +let emitSystemAudioUnavailable: (() => void) | undefined; -function stubElectronAPI() { +function stubElectronAPI(systemAudioEnabled = false) { + emitSystemAudioUnavailable = undefined; api = { getRecordingPrefs: vi.fn(async () => ({ micEnabled: true, @@ -27,7 +29,7 @@ function stubElectronAPI() { micDeviceName: "USB Microphone", camEnabled: false, camDeviceId: null, - systemAudioEnabled: false, + systemAudioEnabled, cursorCaptureMode: "system", })), getPlatform: vi.fn(() => "darwin"), @@ -40,6 +42,10 @@ function stubElectronAPI() { recordingId: 7, microphoneDefaulted: true, })), + onNativeMacSystemAudioUnavailable: vi.fn((callback: () => void) => { + emitSystemAudioUnavailable = callback; + return vi.fn(); + }), stopNativeMacRecording: vi.fn(async () => ({ success: true, discarded: true })), showCountdownOverlay: vi.fn(async () => true), setCountdownOverlayValue: vi.fn(async () => true), @@ -58,6 +64,7 @@ beforeEach(() => { vi.useFakeTimers(); stubElectronAPI(); vi.mocked(toast.error).mockClear(); + vi.mocked(toast.warning).mockClear(); }); afterEach(() => { @@ -109,6 +116,25 @@ describe("useScreenRecorder native macOS start warnings", () => { expect(toast.error).toHaveBeenCalledWith("recording.microphoneUnavailable"); }); + it("warns about unavailable system audio while keeping the screen recording active", async () => { + stubElectronAPI(true); + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + expect(view.result.current.recording).toBe(true); + + await act(async () => { + emitSystemAudioUnavailable?.(); + }); + + expect(toast.warning).toHaveBeenCalledWith("recording.systemAudioUnavailable"); + expect(view.result.current.recording).toBe(true); + }); + // macOS 13 and 14 have no `captureMicrophone`: a saved "mic on" would ask for the // microphone and record a take without it (#700). it("does not apply a saved microphone on macOS 14", async () => { @@ -131,6 +157,84 @@ describe("useScreenRecorder native macOS start warnings", () => { ); }); + it("continues recording with limited cursor effects when Accessibility is pending", async () => { + api.getRecordingPrefs.mockResolvedValue({ + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay", + hideDesktopIcons: false, + autoZoomEnabled: true, + }); + api.requestNativeMacCursorAccess = vi.fn(async () => ({ + success: true, + granted: false, + status: "not-determined", + accessibilityTrusted: false, + })); + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + + expect(api.requestNativeMacCursorAccess).toHaveBeenCalledOnce(); + expect(api.startNativeMacRecording).toHaveBeenCalledOnce(); + expect(view.result.current.recording).toBe(true); + expect(toast.warning).toHaveBeenCalledWith("recording.cursorAccessibilityUnavailable"); + }); + + it("shows the Accessibility warning once across repeated recording attempts", async () => { + api.getRecordingPrefs.mockResolvedValue({ + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay", + hideDesktopIcons: false, + autoZoomEnabled: true, + }); + api.requestNativeMacCursorAccess = vi.fn(async () => ({ + success: true, + granted: false, + status: "not-determined", + accessibilityTrusted: false, + })); + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(1); + expect(api.requestNativeMacCursorAccess).toHaveBeenCalledOnce(); + expect(api.startNativeMacRecording).not.toHaveBeenCalled(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + + expect(api.requestNativeMacCursorAccess).toHaveBeenCalledTimes(2); + expect(api.startNativeMacRecording).toHaveBeenCalledOnce(); + expect(toast.warning).toHaveBeenCalledOnce(); + expect(view.result.current.recording).toBe(true); + }); + it("does not warn after the recording start is cancelled", async () => { let resolveStart: | ((result: Awaited>) => void) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0d1460412..42e2feebc 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -243,6 +243,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { useEffect(() => { tRef.current = t; }, [t]); + useEffect(() => { + return window.electronAPI?.onNativeMacSystemAudioUnavailable?.(() => { + toast.warning(tRef.current("recording.systemAudioUnavailable")); + }); + }, []); const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [saving, setSaving] = useState(false); @@ -341,6 +346,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const discardRecordingId = useRef(null); const restarting = useRef(false); const countdownRunId = useRef(0); + const cursorAccessibilityWarningShown = useRef(false); const [countdownActive, setCountdownActive] = useState(false); const webcamReady = useRef(false); const webcamAcquireId = useRef(0); @@ -1637,25 +1643,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const platform = window.electronAPI.getPlatform(); if (platform === "darwin" && cursorCaptureMode === "editable-overlay") { - // Stop before the countdown ONLY when the user genuinely denied - // Accessibility — the main process is showing them a dialog that - // deep-links to the settings pane, so pressing record again after - // granting it will work. - // - // When the helper simply could not run (missing from the build, killed - // by the loader, crashed, hung) there is nothing for the user to grant, - // and blocking here is what left macOS 12 unable to record at all - // (#515). Recording degrades on its own: the session falls back to - // position-only cursor telemetry and the editor draws the cursor from - // its bundled sprites, so only the pointer/text shape hints are lost. + // Accessibility only improves cursor shape hints. Keep recording when + // the grant is pending or the helper is unavailable; the cursor session + // falls back to position-only telemetry when needed. const access = await window.electronAPI.requestNativeMacCursorAccess(); - if (!access.granted && access.status === "not-determined") { - return; - } - if (!access.granted) { - console.warn( - `Editable cursor unavailable (${access.status}); recording with position-only cursor telemetry.`, - ); + if ( + !access.granted && + access.status === "not-determined" && + !cursorAccessibilityWarningShown.current + ) { + cursorAccessibilityWarningShown.current = true; + toast.warning(t("recording.cursorAccessibilityUnavailable")); } } } catch (error) { diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 7fa393cd2..63bbcf4ac 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "لا يمكن لهذا الإصدار من macOS تسجيل الميكروفون. يجري التسجيل بدون ميكروفون.", "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي.", + "cursorAccessibilityUnavailable": "إمكانية الوصول غير مفعّلة. سيستمر التسجيل، لكن تأثيرات المؤشر قد تكون محدودة. فعّلها من إعدادات النظام لاستخدام تأثيرات المؤشر كاملة.", "selectSource": "يرجى تحديد مصدر للتسجيل" }, "emptyState": { diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index eba1a63c1..7f871fc81 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "Tato verze macOS neumí nahrávat mikrofon. Nahrávání probíhá bez něj.", "permissionDenied": "Oprávnění k nahrávání odepřeno. Povolte prosím záznam obrazovky.", "accessibilityAllowAndRetry": "Povolte přístup k usnadnění pro OpenScreen a poté znovu stiskněte tlačítko nahrávání pro zahájení odpočítávání.", + "cursorAccessibilityUnavailable": "Přístup k usnadnění není povolen. Nahrávání bude pokračovat, ale efekty kurzoru mohou být omezené. Pro plné efekty kurzoru jej povolte v Nastavení systému.", "selectSource": "Vyberte prosím zdroj pro nahrávání" }, "emptyState": { diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 6e65c4c17..72e334b3d 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "Diese macOS-Version kann das Mikrofon nicht aufnehmen. Die Aufnahme läuft ohne Mikrofon.", "permissionDenied": "Aufnahmeberechtigung verweigert. Bitte erlaube die Bildschirmaufnahme.", "accessibilityAllowAndRetry": "Erlaube OpenScreen den Zugriff auf die Bedienungshilfen und drücke dann erneut auf Aufnehmen, um den Countdown zu starten.", + "cursorAccessibilityUnavailable": "Der Bedienungshilfen-Zugriff ist deaktiviert. Die Aufnahme wird fortgesetzt, aber Cursoreffekte können eingeschränkt sein. Aktiviere ihn in den Systemeinstellungen für alle Cursoreffekte.", "selectSource": "Bitte wähle eine Quelle für die Aufnahme" }, "emptyState": { diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index b241dba79..b9e2a88bc 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "This version of macOS can't record the microphone. Recording without it.", "permissionDenied": "Recording permission denied. Please allow screen recording.", "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown.", + "cursorAccessibilityUnavailable": "Accessibility is off. Recording will continue, but cursor effects may be limited. Enable it in System Settings for full cursor effects.", "selectSource": "Choose a screen or window to record" }, "emptyState": { diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 578b9cb12..2555f3f6c 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -43,6 +43,7 @@ "microphoneUnavailable": "Esta versión de macOS no puede grabar el micrófono. Grabando sin él.", "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla.", "accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás.", + "cursorAccessibilityUnavailable": "Accesibilidad está desactivada. La grabación continuará, pero los efectos del cursor pueden ser limitados. Actívala en Ajustes del Sistema para usar todos los efectos del cursor.", "selectSource": "Por favor selecciona una fuente para grabar" }, "loadingVideo": "Cargando video...", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 8494f0f27..b113b53af 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -49,6 +49,7 @@ "microphoneUnavailable": "Cette version de macOS ne permet pas d'enregistrer le micro. Enregistrement sans lui.", "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.", "accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours.", + "cursorAccessibilityUnavailable": "L'accès à Accessibilité est désactivé. L'enregistrement continue, mais les effets du curseur peuvent être limités. Activez-le dans Réglages Système pour profiter de tous les effets du curseur.", "selectSource": "Veuillez sélectionner une source à enregistrer" }, "loadingVideo": "Chargement de la vidéo...", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 4b7480f11..fb184fa0e 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "Questa versione di macOS non può registrare il microfono. Registrazione senza microfono.", "permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.", "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia.", + "cursorAccessibilityUnavailable": "L'accesso all'accessibilità è disattivato. La registrazione continuerà, ma gli effetti del cursore potrebbero essere limitati. Attivalo nelle Impostazioni di Sistema per usare tutti gli effetti del cursore.", "selectSource": "Seleziona una sorgente da registrare" }, "emptyState": { diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 97ca8c18e..fe0a4e098 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -51,6 +51,7 @@ "microphoneDefaulted": "選択したマイクを特定できませんでした。既定の入力を録音します。", "microphoneUnavailable": "このバージョンの macOS ではマイクを録音できません。マイクなしで録画します。", "accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。", + "cursorAccessibilityUnavailable": "アクセシビリティが許可されていません。録画は続行されますが、カーソル効果が制限される場合があります。すべてのカーソル効果を使うには、システム設定で許可してください。", "selectSource": "録画するソースを選択してください" }, "emptyState": { diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index bb885f92e..fc68c63cc 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -51,6 +51,7 @@ "microphoneDefaulted": "선택한 마이크를 확인할 수 없습니다. 기본 입력을 녹음합니다.", "microphoneUnavailable": "이 버전의 macOS에서는 마이크를 녹음할 수 없습니다. 마이크 없이 녹화합니다.", "accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요.", + "cursorAccessibilityUnavailable": "손쉬운 사용 권한이 꺼져 있습니다. 녹화는 계속되지만 커서 효과가 제한될 수 있습니다. 모든 커서 효과를 사용하려면 시스템 설정에서 권한을 켜세요.", "selectSource": "녹화할 소스를 선택해 주세요" }, "emptyState": { diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 9f2cd0a50..567dfaa88 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "Esta versão do macOS não consegue gravar o microfone. Gravando sem ele.", "permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela.", "accessibilityAllowAndRetry": "Permita o acesso de Acessibilidade para o OpenScreen e pressione gravar novamente para iniciar a contagem regressiva.", + "cursorAccessibilityUnavailable": "O acesso à Acessibilidade está desativado. A gravação continuará, mas os efeitos do cursor podem ser limitados. Ative-o nos Ajustes do Sistema para usar todos os efeitos do cursor.", "selectSource": "Por favor, selecione uma fonte para gravar" }, "emptyState": { diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index f9a147890..8484d1500 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "Эта версия macOS не может записывать микрофон. Запись идёт без него.", "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет.", + "cursorAccessibilityUnavailable": "Универсальный доступ выключен. Запись продолжится, но эффекты курсора могут быть ограничены. Включите его в Системных настройках, чтобы использовать все эффекты курсора.", "selectSource": "Пожалуйста, выберите источник для записи" }, "emptyState": { diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 96b0cb3c7..f0a18c103 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -43,6 +43,7 @@ "microphoneDefaulted": "Seçilen mikrofon belirlenemedi. Varsayılan giriş kaydediliyor.", "microphoneUnavailable": "Bu macOS sürümü mikrofonu kaydedemiyor. Mikrofonsuz kaydediliyor.", "accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın.", + "cursorAccessibilityUnavailable": "Erişilebilirlik erişimi kapalı. Kayıt devam edecek ancak imleç efektleri sınırlı olabilir. Tüm imleç efektlerini kullanmak için Sistem Ayarları'ndan erişimi açın.", "selectSource": "Lütfen kayıt için bir kaynak seçin" }, "loadingVideo": "Video yükleniyor...", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 2398c50d8..cfd85b753 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "Phiên bản macOS này không thể ghi micrô. Đang ghi mà không có micrô.", "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.", "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược.", + "cursorAccessibilityUnavailable": "Quyền Trợ năng đang tắt. Quá trình ghi vẫn tiếp tục nhưng hiệu ứng con trỏ có thể bị hạn chế. Hãy bật quyền này trong Cài đặt hệ thống để dùng đầy đủ hiệu ứng con trỏ.", "selectSource": "Vui lòng chọn một nguồn để ghi" }, "emptyState": { diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 0e7bf99cb..8114df182 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -51,6 +51,7 @@ "microphoneUnavailable": "此版本的 macOS 无法录制麦克风,正在不使用麦克风录制。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。", + "cursorAccessibilityUnavailable": "辅助功能权限未开启。录制将继续,但光标效果可能受限。请在系统设置中开启此权限以使用完整的光标效果。", "selectSource": "请选择要录制的源" }, "emptyState": { diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index e5f3a3f7e..6c63cc184 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -51,6 +51,7 @@ "microphoneDefaulted": "無法辨識所選麥克風,將錄製預設輸入裝置。", "microphoneUnavailable": "此版本的 macOS 無法錄製麥克風,將在沒有麥克風的情況下錄製。", "accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。", + "cursorAccessibilityUnavailable": "輔助使用權限尚未開啟。錄影會繼續,但游標效果可能受限。請在系統設定中開啟此權限,以使用完整的游標效果。", "selectSource": "請選擇要錄製的來源" }, "emptyState": { diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 2eee5ecce..cb7419c10 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -102,7 +102,7 @@ gh run download -R getopenscreen/openscreen -n openscreen-windows - [ ] Release the drag and confirm the HUD stays at the dropped position instead of jumping. - [ ] Activate the language button by its visible language code and confirm a menu of locale choices opens. - [ ] Press `Esc` with the language menu open and confirm it closes without changing the locale. -- [ ] Activate the camera toggle `[data-testid="launch-webcam-button"]` and the microphone toggle `[data-testid="launch-microphone-button"]` (absent on macOS 13 and 14) and confirm each turns on with one click. +- [ ] Activate the camera toggle `[data-testid="launch-webcam-button"]` and the microphone toggle `[data-testid="launch-microphone-button"]` (absent on macOS 13 and 14) and confirm each turns on with one click. On a Mac with no camera attached, confirm the webcam toggle stays off and reports that camera access is blocked or the camera is unavailable; recording remains available with the webcam off. - [ ] Open the gear `[data-testid="launch-device-settings-button"]` (*Device settings*) and confirm it lists the input devices with a level meter that moves when you speak, and the cameras with a live preview; pick another device, close with *Done*, and confirm the toggle now records from it. - [ ] Activate *Hide recording bar* and confirm the HUD hides without quitting the app. - [ ] Refocus the app from its system-tray icon and confirm the HUD returns to the foreground. @@ -679,11 +679,13 @@ Edits are saved as they land. The dot after the project name reads "Unsaved" (ho #### Permissions window — v2.0.0 -On macOS 15.2+ it opens at launch, until it has been closed once, while one of its rows was never asked. Before 15.2 it opens at launch while Screen Recording is missing or waits on a relaunch. Tray → *Permissions…* and the app menu open it any time. To see a first run on a Mac that has run OpenScreen before, its grants have to be reset first, which is the Mac owner's decision. +On macOS 15.2+ it opens at launch, until it has been closed once, while one of its rows was never asked. Before 15.2 it opens at launch while Screen Recording is missing or waits on a relaunch. Tray → *Permissions…* and the app menu open it any time. A Record-mode start with Cursor highlight enabled may request Accessibility from macOS; a pending grant does not reopen this window or block recording. To see a first run on a Mac that has run OpenScreen before, its grants have to be reset first, which is the Mac owner's decision. - [ ] On a first launch, confirm the window "OpenScreen needs a few permissions" lists, on macOS 15.2+, System audio (Optional), Accessibility (Recommended), Microphone (Optional) and Camera (Optional); before 15.2, or with `OPENSCREEN_MAC_SOURCE_PICKER=legacy`, Screen & system audio (Required) comes first instead of System audio. -- [ ] Activate each row's button (*Allow*, or *Continue* for the screen row) and confirm macOS raises its prompt, and that a granted row turns to *Allowed* on its own while the window stays open. -- [ ] Refuse one, reopen the window from the tray, and confirm that row now offers *Open Settings*, which opens its System Settings pane. +- [ ] For each row whose permission has not been requested, activate its button (*Allow*, or *Continue* for the screen row) and confirm macOS raises its prompt; allow it and confirm the row turns to *Allowed* while the window stays open. +- [ ] For a permission already requested but still denied, confirm the row offers *Open Settings* on the window's first appearance, and that it opens that permission's System Settings pane instead of raising a new prompt. +- [ ] With Screen Recording ready, Accessibility ungranted, and Cursor highlight enabled in Record mode, press *Start recording*. Confirm the macOS prompt may appear, the countdown still reaches an active take, and a warning says cursor effects may be limited while Accessibility is pending. Press *Start recording* again with the grant still pending; confirm the OpenScreen permissions window does not reopen and recording still starts. Grant Accessibility in System Settings and confirm a later take has full cursor effects. +- [ ] Refuse a permission, reopen the window from the tray, and confirm its row still offers *Open Settings* and opens the matching System Settings pane. - [ ] Before 15.2: confirm *Get started* stays disabled until Screen Recording is allowed; that the window offers *Restart OpenScreen* when macOS needs a relaunch to apply it; and that after System Settings' Quit & Reopen the window comes back. - [ ] Close the window with *Get started* and confirm it does not open again at the next launch. From b02336f8fff3a306ff62e8187443d0fd1eca1a4e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 28 Sep 2026 17:50:49 +0200 Subject: [PATCH 2/3] docs: stop take before retrying macOS permission check --- technical-documentation/testing/manual-e2e-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index cb7419c10..5d7e6e8f3 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -684,7 +684,7 @@ On macOS 15.2+ it opens at launch, until it has been closed once, while one of i - [ ] On a first launch, confirm the window "OpenScreen needs a few permissions" lists, on macOS 15.2+, System audio (Optional), Accessibility (Recommended), Microphone (Optional) and Camera (Optional); before 15.2, or with `OPENSCREEN_MAC_SOURCE_PICKER=legacy`, Screen & system audio (Required) comes first instead of System audio. - [ ] For each row whose permission has not been requested, activate its button (*Allow*, or *Continue* for the screen row) and confirm macOS raises its prompt; allow it and confirm the row turns to *Allowed* while the window stays open. - [ ] For a permission already requested but still denied, confirm the row offers *Open Settings* on the window's first appearance, and that it opens that permission's System Settings pane instead of raising a new prompt. -- [ ] With Screen Recording ready, Accessibility ungranted, and Cursor highlight enabled in Record mode, press *Start recording*. Confirm the macOS prompt may appear, the countdown still reaches an active take, and a warning says cursor effects may be limited while Accessibility is pending. Press *Start recording* again with the grant still pending; confirm the OpenScreen permissions window does not reopen and recording still starts. Grant Accessibility in System Settings and confirm a later take has full cursor effects. +- [ ] With Screen Recording ready, Accessibility ungranted, and Cursor highlight enabled in Record mode, press *Start recording*. Confirm the macOS prompt may appear, the countdown still reaches an active take, and a warning says cursor effects may be limited while Accessibility is pending. Stop the take while leaving Accessibility pending, then start another take; confirm the OpenScreen permissions window does not reopen and recording still starts. Grant Accessibility in System Settings and confirm a later take has full cursor effects. - [ ] Refuse a permission, reopen the window from the tray, and confirm its row still offers *Open Settings* and opens the matching System Settings pane. - [ ] Before 15.2: confirm *Get started* stays disabled until Screen Recording is allowed; that the window offers *Restart OpenScreen* when macOS needs a relaunch to apply it; and that after System Settings' Quit & Reopen the window comes back. - [ ] Close the window with *Get started* and confirm it does not open again at the next launch. From c42b16f4962d5607ec963be684000a7cf93eb898 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 28 Sep 2026 18:22:00 +0200 Subject: [PATCH 3/3] fix: add settings shortcut to cursor permission warning --- ...eenRecorder.nativeMacStartWarning.test.tsx | 21 ++++++++++++++++++- src/hooks/useScreenRecorder.ts | 11 +++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx index b3183d7f7..89158eb72 100644 --- a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx +++ b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx @@ -19,9 +19,11 @@ const SOURCE = { id: "screen:0:0", name: "Screen 1", display_id: "1", thumbnail: let api: Record>; let emitSystemAudioUnavailable: (() => void) | undefined; +let openAccessibilitySettings: ReturnType; function stubElectronAPI(systemAudioEnabled = false) { emitSystemAudioUnavailable = undefined; + openAccessibilitySettings = vi.fn(async () => undefined); api = { getRecordingPrefs: vi.fn(async () => ({ micEnabled: true, @@ -52,6 +54,9 @@ function stubElectronAPI(systemAudioEnabled = false) { hideCountdownOverlay: vi.fn(async () => true), }; window.electronAPI = api as unknown as ElectronAPI; + Object.defineProperty(window.electronAPI, "permissions", { + value: { openSettings: openAccessibilitySettings }, + }); } async function settle(ms = 0) { @@ -187,7 +192,21 @@ describe("useScreenRecorder native macOS start warnings", () => { expect(api.requestNativeMacCursorAccess).toHaveBeenCalledOnce(); expect(api.startNativeMacRecording).toHaveBeenCalledOnce(); expect(view.result.current.recording).toBe(true); - expect(toast.warning).toHaveBeenCalledWith("recording.cursorAccessibilityUnavailable"); + expect(toast.warning).toHaveBeenCalledWith( + "recording.cursorAccessibilityUnavailable", + expect.objectContaining({ + action: expect.objectContaining({ label: "permissions.actions.openSettings" }), + }), + ); + const warningOptions = vi.mocked(toast.warning).mock.calls.at(-1)?.[1] as + | { action?: { onClick?: () => void } } + | undefined; + + await act(async () => { + warningOptions?.action?.onClick?.(); + }); + + expect(openAccessibilitySettings).toHaveBeenCalledWith("accessibility"); }); it("shows the Accessibility warning once across repeated recording attempts", async () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 42e2feebc..3e036e46e 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -226,6 +226,7 @@ export async function finalizeWebcamAsset( export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); + const tLaunch = useScopedT("launch"); /** * `t` through a ref, for the callbacks that must not be rebuilt when it * changes identity. @@ -1653,7 +1654,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { !cursorAccessibilityWarningShown.current ) { cursorAccessibilityWarningShown.current = true; - toast.warning(t("recording.cursorAccessibilityUnavailable")); + toast.warning(t("recording.cursorAccessibilityUnavailable"), { + duration: 10_000, + action: { + label: tLaunch("permissions.actions.openSettings"), + onClick: () => { + void window.electronAPI?.permissions.openSettings("accessibility"); + }, + }, + }); } } } catch (error) {