fix(api): harden tauri adapter init and stream close
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user