diff --git a/client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts b/client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts index 1185479..6141985 100644 --- a/client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts +++ b/client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts @@ -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', + }); }); }); diff --git a/client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts b/client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts index d2b49e8..d65187d 100644 --- a/client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts +++ b/client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts @@ -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; diff --git a/client/src/api/tauri-adapter/environment.ts b/client/src/api/tauri-adapter/environment.ts index f980346..41f97fb 100644 --- a/client/src/api/tauri-adapter/environment.ts +++ b/client/src/api/tauri-adapter/environment.ts @@ -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 { - // 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.'); } } diff --git a/client/src/api/tauri-adapter/sections/preferences.ts b/client/src/api/tauri-adapter/sections/preferences.ts index cd4e3ae..ec64282 100644 --- a/client/src/api/tauri-adapter/sections/preferences.ts +++ b/client/src/api/tauri-adapter/sections/preferences.ts @@ -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; diff --git a/client/src/api/tauri-adapter/sections/webhooks.ts b/client/src/api/tauri-adapter/sections/webhooks.ts index 6ae41b9..7143fb7 100644 --- a/client/src/api/tauri-adapter/sections/webhooks.ts +++ b/client/src/api/tauri-adapter/sections/webhooks.ts @@ -11,14 +11,38 @@ import { TauriCommands } from '../../tauri-constants'; import { clientLog } from '@/lib/client-log-events'; import type { TauriInvoke } from '../types'; +function sanitizeWebhookRequest(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 { + const request = sanitizeWebhookRequest(r); const webhook = await invoke(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 { - return invoke(TauriCommands.UPDATE_WEBHOOK, { request: r }); + const request = sanitizeWebhookRequest(r); + return invoke(TauriCommands.UPDATE_WEBHOOK, { request }); }, async deleteWebhook(webhookId: string): Promise { const response = await invoke(TauriCommands.DELETE_WEBHOOK, { diff --git a/client/src/api/tauri-adapter/stream.ts b/client/src/api/tauri-adapter/stream.ts index 7cbef6e..9770f6d 100644 --- a/client/src/api/tauri-adapter/stream.ts +++ b/client/src/api/tauri-adapter/stream.ts @@ -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 = { 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((_, 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) { diff --git a/client/src/api/tauri-adapter/utils.ts b/client/src/api/tauri-adapter/utils.ts index 2e353c1..804214c 100644 --- a/client/src/api/tauri-adapter/utils.ts +++ b/client/src/api/tauri-adapter/utils.ts @@ -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;