fix: retain dictation events until listener acknowledgement

This commit is contained in:
Palash Debnath
2026-09-01 21:03:35 +05:30
parent 8f96692537
commit 67d7236edd
4 changed files with 170 additions and 30 deletions
+14
View File
@@ -1077,6 +1077,20 @@ pub fn mark_dictation_capture_ready(app: tauri::AppHandle, registration_id: u64)
}
}
#[tauri::command]
pub fn acknowledge_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.acknowledge(registration_id, delivery_id);
}
#[tauri::command]
pub fn end_dictation_capture_registration(app: tauri::AppHandle, registration_id: u64) {
let flags = app.state::<AppFlags>();
+77 -12
View File
@@ -104,6 +104,7 @@ pub struct CaptureDispatchState {
pub(crate) ready: bool,
pub(crate) pending: VecDeque<CaptureEvent>,
registration_counter: u64,
delivery_counter: u64,
active_registration: Option<u64>,
}
@@ -113,6 +114,7 @@ impl Default for CaptureDispatchState {
ready: false,
pending: VecDeque::new(),
registration_counter: 0,
delivery_counter: 0,
active_registration: None,
}
}
@@ -134,7 +136,36 @@ impl CaptureDispatchState {
return VecDeque::new();
}
self.ready = true;
std::mem::take(&mut self.pending)
self.pending
.iter()
.cloned()
.map(|mut event| {
event.payload.registration_id = registration_id;
event
})
.collect()
}
pub(crate) fn enqueue(&mut self, mut event: CaptureEvent) -> Option<CaptureEvent> {
self.delivery_counter = self.delivery_counter.wrapping_add(1).max(1);
event.payload.delivery_id = self.delivery_counter;
self.pending.push_back(event.clone());
let registration_id = self.active_registration.filter(|_| self.ready)?;
event.payload.registration_id = registration_id;
Some(event)
}
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) {
if self.active_registration != Some(registration_id) {
return;
}
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
self.pending.remove(index);
}
}
pub(crate) fn end_registration(&mut self, registration_id: u64) {
@@ -149,8 +180,11 @@ impl CaptureDispatchState {
#[serde(rename_all = "camelCase")]
pub(crate) struct DictationCapturePayload {
pub(crate) session_id: u64,
pub(crate) delivery_id: u64,
pub(crate) registration_id: u64,
}
#[derive(Clone)]
pub(crate) struct CaptureEvent {
pub(crate) name: &'static str,
pub(crate) payload: DictationCapturePayload,
@@ -186,13 +220,18 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
};
let capture_event = CaptureEvent {
name: event,
payload: DictationCapturePayload { session_id },
payload: DictationCapturePayload {
session_id,
delivery_id: 0,
registration_id: 0,
},
};
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
if capture.ready {
if let Some(capture_event) = capture.enqueue(capture_event) {
drop(capture);
// A press that reaches Rust but produces no recording is otherwise
// indistinguishable from one the compositor never delivered, so say
// which side of the handshake the press left on.
@@ -205,7 +244,6 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
log::warn!(
"Dictation capture '{action}' queued — the capture window has not registered yet"
);
capture.pending.push_back(capture_event);
}
}
@@ -215,6 +253,17 @@ mod dictation_capture_tests {
dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload,
};
fn capture_event(name: &'static str) -> CaptureEvent {
CaptureEvent {
name,
payload: DictationCapturePayload {
session_id: 7,
delivery_id: 0,
registration_id: 0,
},
}
}
#[test]
fn toggle_starts_when_idle_and_stops_when_recording() {
assert_eq!(dictation_capture_event("toggle", false), "tray-dictate");
@@ -224,18 +273,33 @@ mod dictation_capture_tests {
#[test]
fn readiness_queue_preserves_press_then_release() {
let mut state = CaptureDispatchState::default();
state.pending.push_back(CaptureEvent {
name: "tray-dictate",
payload: DictationCapturePayload { session_id: 7 },
});
state.pending.push_back(CaptureEvent {
name: "tray-dictate-stop",
payload: DictationCapturePayload { session_id: 7 },
});
state.enqueue(capture_event("tray-dictate"));
state.enqueue(capture_event("tray-dictate-stop"));
let names: Vec<_> = state.pending.into_iter().map(|event| event.name).collect();
assert_eq!(names, ["tray-dictate", "tray-dictate-stop"]);
}
#[test]
fn unacknowledged_delivery_survives_listener_replacement() {
let mut state = CaptureDispatchState::default();
state.enqueue(capture_event("tray-dictate"));
let stale = state.begin_registration();
let first_delivery = state.mark_registration_ready(stale);
let delivery_id = first_delivery[0].payload.delivery_id;
state.end_registration(stale);
let current = state.begin_registration();
let retried = state.mark_registration_ready(current);
assert_eq!(retried.len(), 1);
assert_eq!(retried[0].payload.delivery_id, delivery_id);
assert_eq!(retried[0].payload.registration_id, current);
state.acknowledge(stale, delivery_id);
assert_eq!(state.pending.len(), 1);
state.acknowledge(current, delivery_id);
assert!(state.pending.is_empty());
}
#[test]
fn stale_listener_cannot_claim_or_clear_a_newer_registration() {
let mut state = CaptureDispatchState::default();
@@ -661,6 +725,7 @@ pub fn run() {
commands::request_dictation_capture,
commands::begin_dictation_capture_registration,
commands::mark_dictation_capture_ready,
commands::acknowledge_dictation_capture_delivery,
commands::end_dictation_capture_registration,
commands::show_dictation_pill,
commands::get_launch_as_widget,
+41 -16
View File
@@ -684,16 +684,50 @@ export default function CaptureWidget({ onDismiss }) {
if (!inTauri()) return; // browser webui — the keyboard fallback below runs
let unlistenStart, unlistenStop;
let registrationId;
let endedRegistrationId;
let cancelled = false;
const teardownRegistration = async () => {
const stopStart = unlistenStart;
const stopStop = unlistenStop;
unlistenStart = undefined;
unlistenStop = undefined;
try {
stopStart?.();
} catch (err) {
console.warn('tray-dictate unlisten failed:', err);
}
try {
stopStop?.();
} catch (err) {
console.warn('tray-dictate-stop unlisten failed:', err);
}
if (registrationId && endedRegistrationId !== registrationId) {
endedRegistrationId = registrationId;
await tauriInvoke('end_dictation_capture_registration', { registrationId });
}
};
const acknowledgeDelivery = (event) => {
const deliveryId = event?.payload?.deliveryId;
const eventRegistrationId = event?.payload?.registrationId;
if (eventRegistrationId != null && eventRegistrationId !== registrationId) return false;
if (deliveryId != null) {
void tauriInvoke('acknowledge_dictation_capture_delivery', {
registrationId,
deliveryId,
}).catch((err) => console.warn('dictation delivery acknowledgement failed:', err));
}
return true;
};
(async () => {
try {
registrationId = await tauriInvoke('begin_dictation_capture_registration');
if (cancelled) {
await tauriInvoke('end_dictation_capture_registration', { registrationId });
await teardownRegistration();
return;
}
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', async (event) => {
if (!acknowledgeDelivery(event)) return;
const now = Date.now();
if (now - nativeEventAtRef.current.start < 150) return;
nativeEventAtRef.current.start = now;
@@ -796,7 +830,8 @@ export default function CaptureWidget({ onDismiss }) {
startRecordingRef.current?.(true, sessionId);
}
});
unlistenStop = await listen('tray-dictate-stop', async () => {
unlistenStop = await listen('tray-dictate-stop', async (event) => {
if (!acknowledgeDelivery(event)) return;
const now = Date.now();
if (now - nativeEventAtRef.current.stop < 150) return;
nativeEventAtRef.current.stop = now;
@@ -810,23 +845,17 @@ export default function CaptureWidget({ onDismiss }) {
});
await ensureDictationPrefsHydrated();
if (cancelled) {
unlistenStart?.();
unlistenStop?.();
await tauriInvoke('end_dictation_capture_registration', { registrationId });
await teardownRegistration();
return;
}
await tauriInvoke('mark_dictation_capture_ready', { registrationId });
// Unmounted while the dynamic import was in flight — drop the
// subscriptions we just created rather than leaking them.
if (cancelled) {
unlistenStart?.();
unlistenStop?.();
await tauriInvoke('end_dictation_capture_registration', { registrationId });
await teardownRegistration();
}
} catch (err) {
if (registrationId) {
void tauriInvoke('end_dictation_capture_registration', { registrationId });
}
await teardownRegistration().catch(() => {});
// Hotkey wiring failed inside Tauri — dictation still works via the
// in-page shortcut, but say so in the console for bug reports.
console.warn('tray-dictate listen failed:', err);
@@ -835,11 +864,7 @@ export default function CaptureWidget({ onDismiss }) {
return () => {
cancelled = true;
nativeStartSequenceRef.current += 1;
if (unlistenStart) unlistenStart();
if (unlistenStop) unlistenStop();
if (registrationId) {
void tauriInvoke('end_dictation_capture_registration', { registrationId });
}
void teardownRegistration();
};
// Attach ONCE — see stateRef above. Adding a dependency here reintroduces
// the dropped-press window that stranded the widget.
@@ -10,7 +10,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
const { toastMock, eventHandlers, eventState } = vi.hoisted(() => ({
const { toastMock, eventHandlers, eventState, eventUnlisteners } = vi.hoisted(() => ({
toastMock: Object.assign(vi.fn(), {
error: vi.fn(),
success: vi.fn(),
@@ -19,6 +19,7 @@ const { toastMock, eventHandlers, eventState } = vi.hoisted(() => ({
}),
eventHandlers: {},
eventState: { pendingStart: false },
eventUnlisteners: [],
}));
vi.mock('react-hot-toast', () => ({ default: toastMock, toast: toastMock }));
@@ -29,7 +30,9 @@ vi.mock('@tauri-apps/api/core', () => ({
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async (name, handler) => {
eventHandlers[name] = handler;
return () => delete eventHandlers[name];
const unlisten = vi.fn(() => delete eventHandlers[name]);
eventUnlisteners.push(unlisten);
return unlisten;
}),
}));
vi.mock('@tauri-apps/api/window', () => ({
@@ -131,6 +134,7 @@ beforeEach(() => {
return undefined;
});
eventState.pendingStart = false;
eventUnlisteners.length = 0;
FakeWS.instances = [];
storeState.dictationModelId = 'sherpa-parakeet-v3';
realWebSocket = globalThis.WebSocket;
@@ -156,6 +160,38 @@ afterEach(() => {
});
describe('CaptureWidget — connect-time asr_model_missing during mic setup', () => {
it('removes both native listeners when registration readiness fails', async () => {
invokeMock.mockImplementation(async (cmd) => {
if (cmd === 'begin_dictation_capture_registration') return 1;
if (cmd === 'mark_dictation_capture_ready') throw new Error('registration failed');
return undefined;
});
render(<CaptureWidget />);
await waitFor(() => expect(eventUnlisteners).toHaveLength(2));
await waitFor(() =>
expect(eventUnlisteners.every((unlisten) => unlisten.mock.calls.length)).toBe(true),
);
expect(invokeMock).toHaveBeenCalledWith('end_dictation_capture_registration', {
registrationId: 1,
});
});
it('acknowledges a queued native event after the listener receives it', async () => {
render(<CaptureWidget />);
await waitFor(() => expect(eventHandlers['tray-dictate-stop']).toBeTypeOf('function'));
await eventHandlers['tray-dictate-stop']({
payload: { sessionId: 7, deliveryId: 9, registrationId: 1 },
});
expect(invokeMock).toHaveBeenCalledWith('acknowledge_dictation_capture_delivery', {
registrationId: 1,
deliveryId: 9,
});
});
it('turns a PCM-fallback socket failure into a terminal error', async () => {
storeState.dictationModelId = 'whisperx';
render(<CaptureWidget />);