fix: retain dictation events until listener acknowledgement
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
Reference in New Issue
Block a user