Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
3 changes: 2 additions & 1 deletion docs/install/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
33 changes: 33 additions & 0 deletions frontend/src/components/CaptureWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 34 additions & 2 deletions frontend/src/components/CaptureWidget.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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(<CaptureWidget />));

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();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('waveform bars move from the worklet mic frames', async () => {
const { container } = render(withI18n(<CaptureWidget />));
await startSession();
Expand Down
Loading