From 273f54e0601eda30ce4e0ba43591fb2d5b37d347 Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:34:51 +0530 Subject: [PATCH 1/3] fix(dictation): refresh accessibility blocker --- docs/install/troubleshooting.md | 3 +- frontend/src/components/CaptureWidget.jsx | 33 +++++++++++++++++++ .../src/components/CaptureWidget.test.jsx | 27 ++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index 95ee48ac..328855af 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -768,7 +768,8 @@ unaffected and works normally. > **Tip:** current builds surface the live OS grant state in-app — **Settings → > Permissions** shows whether the microphone (and, on macOS, Accessibility) is > granted, denied, or not asked yet, with an **Open Settings** button that -> deep-links the exact OS pane described above. +> deep-links the exact OS pane described above. The dictation blocker rechecks +> Accessibility while it is visible and closes as soon as macOS reports the grant. ## Dub: "translation engine needs the optional … package" diff --git a/frontend/src/components/CaptureWidget.jsx b/frontend/src/components/CaptureWidget.jsx index fb64af24..c802873d 100644 --- a/frontend/src/components/CaptureWidget.jsx +++ b/frontend/src/components/CaptureWidget.jsx @@ -116,6 +116,11 @@ const IDLE_VISIBLE_GRACE_MS = 1200; // document reports itself hidden (the overwhelmingly common case). const IDLE_VISIBLE_POLL_MS = 600; +// The widget window deliberately does not take focus, so it cannot rely on +// the main window's focus-based permission refresh after System Settings. +// Reconcile only while the Accessibility blocker is visible. +const A11Y_SETUP_RECHECK_MS = 1000; + // A dictation model id is a sherpa-onnx live model when it carries the // `sherpa-` prefix the backend assigns (see services/sherpa_dictation.py). Only // then do we open the low-latency raw-PCM streaming path. Other models use a @@ -492,6 +497,34 @@ export default function CaptureWidget({ onDismiss }) { }; }, []); + useEffect(() => { + if (state !== 'setup' || !inTauri()) return undefined; + let cancelled = false; + let timerId; + + const reconcileAccessibility = async () => { + const ok = await checkAccessibility(); + if (cancelled || stateRef.current !== 'setup') return; + if (ok) { + stateRef.current = 'idle'; + setState('idle'); + await hideWidgetWindow(); + return; + } + timerId = setTimeout(() => { + void reconcileAccessibility(); + }, A11Y_SETUP_RECHECK_MS); + }; + + timerId = setTimeout(() => { + void reconcileAccessibility(); + }, A11Y_SETUP_RECHECK_MS); + return () => { + cancelled = true; + clearTimeout(timerId); + }; + }, [state]); + // ── Tray hotkey: tray-dictate (start) + tray-dictate-stop (release) ── // Toggle mode: tray-dictate flips start↔stop, tray-dictate-stop is ignored // (Tauri only emits tray-dictate-stop on key *release* in hold registration; diff --git a/frontend/src/components/CaptureWidget.test.jsx b/frontend/src/components/CaptureWidget.test.jsx index 766021d3..450b0dd0 100644 --- a/frontend/src/components/CaptureWidget.test.jsx +++ b/frontend/src/components/CaptureWidget.test.jsx @@ -57,7 +57,10 @@ vi.mock('../pages/Transcriptions', () => ({ addTranscription: vi.fn() })); vi.mock('../utils/copyText', () => ({ copyText: vi.fn(async () => {}) })); vi.mock('react-hot-toast', () => ({ toast: { error: vi.fn() } })); vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); -vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => {}) })); +vi.mock('@tauri-apps/api/event', () => ({ + emit: vi.fn(async () => {}), + listen: vi.fn(async () => () => {}), +})); vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ hide: vi.fn(async () => {}) }), })); @@ -318,6 +321,28 @@ describe('CaptureWidget', () => { expect(screen.queryByText(/Listening/)).not.toBeInTheDocument(); }); + it('clears the Accessibility setup pill after the native grant changes', async () => { + vi.useFakeTimers(); + try { + mocks.holder.a11y = false; + render(withI18n()); + + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText(/Allow Accessibility/)).toBeInTheDocument(); + + mocks.holder.a11y = true; + await act(async () => { + await vi.advanceTimersByTimeAsync(1100); + }); + + expect(screen.queryByText(/Allow Accessibility/)).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it('waveform bars move from the worklet mic frames', async () => { const { container } = render(withI18n()); await startSession(); From bcfe9ccc2c594dcff6564b6ae73d6670b8d2d952 Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:35:42 +0530 Subject: [PATCH 2/3] docs(changelog): note accessibility refresh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4b4083..d66d0e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ the frozen-backend fallback mirror it for their toolchains. - The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565) ### Fixed +- The macOS Accessibility blocker now rechecks while visible and closes as soon as the grant is enabled instead of keeping a stale permission prompt on screen (#1609) - The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y! - CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111! - Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques! From e77d66c406b81fb3bfa24df7459d4ce6e2e0440c Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:22:47 +0530 Subject: [PATCH 3/3] test(dictation): assert the native widget hide on accessibility grant The recheck regression asserted only that the Accessibility pill text left the DOM, so it still passed with hideWidgetWindow() removed and the native capsule stranded on screen. Hold one stable getCurrentWindow().hide spy and assert it after the poll (fails before the fix, passes after). --- frontend/src/components/CaptureWidget.test.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CaptureWidget.test.jsx b/frontend/src/components/CaptureWidget.test.jsx index 450b0dd0..33004c39 100644 --- a/frontend/src/components/CaptureWidget.test.jsx +++ b/frontend/src/components/CaptureWidget.test.jsx @@ -30,6 +30,9 @@ const mocks = vi.hoisted(() => { calls: [], // Captured micCapture frame callback (the worklet feed). onFrame: null, + // Stable spy for getCurrentWindow().hide — a fresh vi.fn() per call would + // make the native hide unassertable. + hideWindow: vi.fn(async () => {}), }; return { state, @@ -62,7 +65,7 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => {}), })); vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: () => ({ hide: vi.fn(async () => {}) }), + getCurrentWindow: () => ({ hide: mocks.holder.hideWindow }), })); vi.mock('../utils/aec/micCapture', () => ({ startMicCapture: async (stream, onFrame) => { @@ -138,6 +141,7 @@ describe('CaptureWidget', () => { mocks.holder.paste = async () => undefined; mocks.holder.calls = []; mocks.holder.onFrame = null; + mocks.holder.hideWindow.mockClear(); mocks.authenticatedWsUrl.mockClear(); mocks.authenticatedWsUrl.mockImplementation( async (path) => `ws://test${path}${path.includes('?') ? '&' : '?'}ws_ticket=one-use`, @@ -338,6 +342,9 @@ describe('CaptureWidget', () => { }); expect(screen.queryByText(/Allow Accessibility/)).not.toBeInTheDocument(); + // The unfocusable widget must also leave the screen — asserting only the + // pill text would still pass if hideWidgetWindow() were dropped. + expect(mocks.holder.hideWindow).toHaveBeenCalled(); } finally { vi.useRealTimers(); }