From 3441201be039a87302a1f19a189b96242f32f7a2 Mon Sep 17 00:00:00 2001
From: Palash Debnath <4178343+debpalash@users.noreply.github.com>
Date: Thu, 20 Aug 2026 17:10:56 +0000
Subject: [PATCH] fix(dictation): clear granted accessibility blocker (#1609)
* fix(dictation): refresh accessibility blocker
* docs(changelog): note accessibility refresh
* 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).
---
CHANGELOG.md | 1 +
docs/install/troubleshooting.md | 3 +-
frontend/src/components/CaptureWidget.jsx | 33 +++++++++++++++++
.../src/components/CaptureWidget.test.jsx | 36 +++++++++++++++++--
4 files changed, 70 insertions(+), 3 deletions(-)
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!
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..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,
@@ -57,9 +60,12 @@ 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 () => {}) }),
+ getCurrentWindow: () => ({ hide: mocks.holder.hideWindow }),
}));
vi.mock('../utils/aec/micCapture', () => ({
startMicCapture: async (stream, onFrame) => {
@@ -135,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`,
@@ -318,6 +325,31 @@ 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();
+ // 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();
+ }
+ });
+
it('waveform bars move from the worklet mic frames', async () => {
const { container } = render(withI18n());
await startSession();