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