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();