fix(api): harden tauri adapter init and stream close

This commit is contained in:
2026-01-18 21:27:43 -05:00
parent f5df0559eb
commit cff3c2f80a
8 changed files with 133 additions and 27 deletions

View File

@@ -124,7 +124,7 @@ describe('tauri-adapter mapping (misc)', () => {
});
});
it('covers additional adapter commands', async () => {
it('covers additional adapter commands with payload assertions', async () => {
const { invoke, listen } = createInvokeListenMocks();
const annotation = {
@@ -321,5 +321,28 @@ describe('tauri-adapter mapping (misc)', () => {
await api.getDiarizationJobStatus('job');
await api.renameSpeaker('m1', 'old', 'new');
await api.cancelDiarization('job');
expect(invoke).toHaveBeenCalledWith('export_transcript', {
meeting_id: 'm1',
format: 1,
});
expect(invoke).toHaveBeenCalledWith('set_trigger_enabled', { enabled: true });
expect(invoke).toHaveBeenCalledWith('extract_entities', {
meeting_id: 'm1',
force_refresh: true,
});
expect(invoke).toHaveBeenCalledWith('register_webhook', {
request: {
workspace_id: 'w1',
name: 'Webhook',
url: 'https://example.com',
events: ['meeting.completed'],
},
});
expect(invoke).toHaveBeenCalledWith('list_calendar_events', {
hours_ahead: 2,
limit: 5,
provider: 'google',
});
});
});

View File

@@ -30,7 +30,7 @@ describe('tauri-adapter mapping (transcription)', () => {
expect(invoke).toHaveBeenCalledWith('start_recording', { meeting_id: 'm1' });
expect(invoke).toHaveBeenCalledWith('send_audio_chunk', {
meeting_id: 'm1',
audio_data: [0.25, -0.25],
audio_data: chunk.audio_data,
timestamp: 12.34,
sample_rate: 48000,
channels: 2,
@@ -60,7 +60,8 @@ describe('tauri-adapter mapping (transcription)', () => {
meeting_id: 'm2',
timestamp: 1.23,
});
const audioData = args.audio_data as number[] | undefined;
const audioData = args.audio_data as Float32Array | undefined;
expect(audioData).toBeInstanceOf(Float32Array);
expect(audioData).toHaveLength(1);
expect(audioData?.[0]).toBeCloseTo(0.1, 5);
});
@@ -157,12 +158,42 @@ describe('tauri-adapter mapping (transcription)', () => {
assertTranscriptionStream(stream);
await stream.onUpdate(() => {});
stream.close();
await stream.close();
expect(unlisten).toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });
});
it('propagates errors when closing a transcription stream fails', async () => {
const { invoke, listen } = createInvokeListenMocks();
const unlisten = vi.fn();
listen.mockResolvedValueOnce(unlisten);
invoke.mockResolvedValueOnce(undefined);
const stopError = new Error('STOP_RECORDING failed');
invoke.mockRejectedValueOnce(stopError);
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
const stream: unknown = await api.startTranscription('m1');
assertTranscriptionStream(stream);
const onError = vi.fn();
stream.onError(onError);
await stream.onUpdate(() => {});
await expect(stream.close()).rejects.toBe(stopError);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
code: 'stream_close_failed',
message: expect.stringContaining('Failed to stop recording'),
})
);
expect(unlisten).toHaveBeenCalledTimes(1);
});
it('cleans up pending transcript listener when closed before listen resolves', async () => {
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
let resolveListen: ((fn: () => void) => void) | null = null;

View File

@@ -1,6 +1,7 @@
import type { NoteFlowAPI } from '../interface';
import { extractErrorMessage } from '../helpers';
import { createTauriAPI } from './api';
import { addClientLog } from '@/lib/client-logs';
/** Check if running in a Tauri environment (synchronous hint). */
export function isTauriEnvironment(): boolean {
@@ -14,16 +15,28 @@ export function isTauriEnvironment(): boolean {
/** Dynamically import Tauri APIs and create the adapter. */
export async function initializeTauriAPI(): Promise<NoteFlowAPI> {
// Try to import Tauri APIs - this will fail in browser but succeed in Tauri
const [core, event] = await Promise.all([
import('@tauri-apps/api/core'),
import('@tauri-apps/api/event'),
]).catch((error) => {
addClientLog({
level: 'debug',
source: 'api',
message: 'Tauri adapter initialization: import failed',
details: extractErrorMessage(error, 'unknown error'),
});
throw new Error('Not running in Tauri environment.');
});
try {
const { invoke } = await import('@tauri-apps/api/core');
const { listen } = await import('@tauri-apps/api/event');
// Test if invoke actually works by calling a simple command
await invoke('is_connected');
return createTauriAPI(invoke, listen);
return createTauriAPI(core.invoke, event.listen);
} catch (error) {
throw new Error(
`Not running in Tauri environment: ${extractErrorMessage(error, 'unknown error')}`
);
addClientLog({
level: 'error',
source: 'api',
message: 'Tauri adapter initialization failed',
details: extractErrorMessage(error, 'unknown error'),
});
throw new Error('Failed to initialize Tauri API.');
}
}

View File

@@ -21,8 +21,8 @@ export function createPreferencesApi(invoke: TauriInvoke): Pick<
source: 'api',
message: 'TauriAdapter.getPreferences: received',
metadata: {
input: prefs.audio_devices?.input_device_id ?? 'UNDEFINED',
output: prefs.audio_devices?.output_device_id ?? 'UNDEFINED',
input: prefs.audio_devices?.input_device_id ? 'SET' : 'UNSET',
output: prefs.audio_devices?.output_device_id ? 'SET' : 'UNSET',
},
});
return prefs;

View File

@@ -11,14 +11,38 @@ import { TauriCommands } from '../../tauri-constants';
import { clientLog } from '@/lib/client-log-events';
import type { TauriInvoke } from '../types';
function sanitizeWebhookRequest<T extends {
url?: string;
name?: string;
secret?: string;
events?: unknown[];
}>(request: T): T {
const url = request.url?.trim();
if (request.url !== undefined && !url) {
throw new Error('Webhook URL is required.');
}
if (request.events !== undefined && request.events.length === 0) {
throw new Error('Webhook events are required.');
}
const name = request.name?.trim();
const secret = request.secret?.trim();
return {
...request,
...(url ? { url } : {}),
...(name ? { name } : {}),
...(secret ? { secret } : {}),
};
}
export function createWebhookApi(invoke: TauriInvoke): Pick<
NoteFlowAPI,
'registerWebhook' | 'listWebhooks' | 'updateWebhook' | 'deleteWebhook' | 'getWebhookDeliveries'
> {
return {
async registerWebhook(r: RegisterWebhookRequest): Promise<RegisteredWebhook> {
const request = sanitizeWebhookRequest(r);
const webhook = await invoke<RegisteredWebhook>(TauriCommands.REGISTER_WEBHOOK, {
request: r,
request,
});
clientLog.webhookRegistered(webhook.id, webhook.name);
return webhook;
@@ -29,7 +53,8 @@ export function createWebhookApi(invoke: TauriInvoke): Pick<
});
},
async updateWebhook(r: UpdateWebhookRequest): Promise<RegisteredWebhook> {
return invoke<RegisteredWebhook>(TauriCommands.UPDATE_WEBHOOK, { request: r });
const request = sanitizeWebhookRequest(r);
return invoke<RegisteredWebhook>(TauriCommands.UPDATE_WEBHOOK, { request });
},
async deleteWebhook(webhookId: string): Promise<DeleteWebhookResponse> {
const response = await invoke<DeleteWebhookResponse>(TauriCommands.DELETE_WEBHOOK, {

View File

@@ -38,6 +38,7 @@ export class TauriTranscriptionStream {
/** Queue for ordered, backpressure-aware chunk transmission. */
private readonly sendQueue: StreamingQueue;
private readonly drainTimeoutMs = 5000;
constructor(
private meetingId: string,
@@ -88,7 +89,7 @@ export class TauriTranscriptionStream {
const args: Record<string, unknown> = {
meeting_id: chunk.meeting_id,
audio_data: Array.from(chunk.audio_data),
audio_data: chunk.audio_data,
timestamp: chunk.timestamp,
};
if (typeof chunk.sample_rate === 'number') {
@@ -223,9 +224,16 @@ export class TauriTranscriptionStream {
// Drain the send queue to ensure all pending chunks are transmitted
try {
await this.sendQueue.drain();
const drainPromise = this.sendQueue.drain();
const timeoutPromise = new Promise<void>((_, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Queue drain timeout'));
}, this.drainTimeoutMs);
drainPromise.finally(() => clearTimeout(timeoutId));
});
await Promise.race([drainPromise, timeoutPromise]);
} catch {
// Queue drain failed - continue with cleanup anyway
// Queue drain failed or timed out - continue with cleanup anyway
}
if (this.unlistenFn) {

View File

@@ -23,12 +23,18 @@ export function recordingBlockedDetails(error: unknown): {
ruleLabel?: string;
appName?: string;
} | null {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: JSON.stringify(error);
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === 'string') {
message = error;
} else {
try {
message = JSON.stringify(error);
} catch {
message = String(error);
}
}
if (!message.includes(RECORDING_BLOCKED_PREFIX)) {
return null;

View File

@@ -44,7 +44,7 @@ describe('TauriTranscriptionStream', () => {
const expectedPayload: Record<string, unknown> = {
meeting_id: 'meeting-123',
audio_data: expect.arrayContaining([expect.any(Number), expect.any(Number)]),
audio_data: expect.any(Float32Array),
timestamp: 1.5,
sample_rate: 48000,
channels: 2,