From f28cdddc693c1e6a0edd617c739a269c5ace413b Mon Sep 17 00:00:00 2001 From: Travis Vasceannie Date: Sun, 18 Jan 2026 20:28:04 -0500 Subject: [PATCH] refactor(api): split tauri adapter --- client/src/api/tauri-adapter.test.ts | 821 --------- client/src/api/tauri-adapter.ts | 1470 ----------------- .../__tests__/core-mapping.test.ts | 170 ++ .../__tests__/environment.test.ts | 58 + .../__tests__/misc-mapping.test.ts | 325 ++++ .../api/tauri-adapter/__tests__/test-utils.ts | 60 + .../__tests__/transcription-mapping.test.ts | 222 +++ client/src/api/tauri-adapter/api.ts | 46 + client/src/api/tauri-adapter/environment.ts | 29 + client/src/api/tauri-adapter/index.ts | 15 + .../api/tauri-adapter/sections/annotations.ts | 66 + client/src/api/tauri-adapter/sections/apps.ts | 25 + client/src/api/tauri-adapter/sections/asr.ts | 80 + .../src/api/tauri-adapter/sections/audio.ts | 117 ++ .../api/tauri-adapter/sections/calendar.ts | 98 ++ client/src/api/tauri-adapter/sections/core.ts | 116 ++ .../api/tauri-adapter/sections/diarization.ts | 51 + .../api/tauri-adapter/sections/entities.ts | 43 + .../api/tauri-adapter/sections/exporting.ts | 39 + .../tauri-adapter/sections/integrations.ts | 41 + .../api/tauri-adapter/sections/meetings.ts | 181 ++ .../tauri-adapter/sections/observability.ts | 35 + client/src/api/tauri-adapter/sections/oidc.ts | 76 + .../api/tauri-adapter/sections/playback.ts | 27 + .../api/tauri-adapter/sections/preferences.ts | 34 + .../api/tauri-adapter/sections/projects.ts | 142 ++ .../tauri-adapter/sections/summarization.ts | 132 ++ .../api/tauri-adapter/sections/triggers.ts | 38 + .../api/tauri-adapter/sections/webhooks.ts | 51 + client/src/api/tauri-adapter/stream.ts | 265 +++ client/src/api/tauri-adapter/types.ts | 21 + client/src/api/tauri-adapter/utils.ts | 56 + 32 files changed, 2659 insertions(+), 2291 deletions(-) delete mode 100644 client/src/api/tauri-adapter.test.ts delete mode 100644 client/src/api/tauri-adapter.ts create mode 100644 client/src/api/tauri-adapter/__tests__/core-mapping.test.ts create mode 100644 client/src/api/tauri-adapter/__tests__/environment.test.ts create mode 100644 client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts create mode 100644 client/src/api/tauri-adapter/__tests__/test-utils.ts create mode 100644 client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts create mode 100644 client/src/api/tauri-adapter/api.ts create mode 100644 client/src/api/tauri-adapter/environment.ts create mode 100644 client/src/api/tauri-adapter/index.ts create mode 100644 client/src/api/tauri-adapter/sections/annotations.ts create mode 100644 client/src/api/tauri-adapter/sections/apps.ts create mode 100644 client/src/api/tauri-adapter/sections/asr.ts create mode 100644 client/src/api/tauri-adapter/sections/audio.ts create mode 100644 client/src/api/tauri-adapter/sections/calendar.ts create mode 100644 client/src/api/tauri-adapter/sections/core.ts create mode 100644 client/src/api/tauri-adapter/sections/diarization.ts create mode 100644 client/src/api/tauri-adapter/sections/entities.ts create mode 100644 client/src/api/tauri-adapter/sections/exporting.ts create mode 100644 client/src/api/tauri-adapter/sections/integrations.ts create mode 100644 client/src/api/tauri-adapter/sections/meetings.ts create mode 100644 client/src/api/tauri-adapter/sections/observability.ts create mode 100644 client/src/api/tauri-adapter/sections/oidc.ts create mode 100644 client/src/api/tauri-adapter/sections/playback.ts create mode 100644 client/src/api/tauri-adapter/sections/preferences.ts create mode 100644 client/src/api/tauri-adapter/sections/projects.ts create mode 100644 client/src/api/tauri-adapter/sections/summarization.ts create mode 100644 client/src/api/tauri-adapter/sections/triggers.ts create mode 100644 client/src/api/tauri-adapter/sections/webhooks.ts create mode 100644 client/src/api/tauri-adapter/stream.ts create mode 100644 client/src/api/tauri-adapter/types.ts create mode 100644 client/src/api/tauri-adapter/utils.ts diff --git a/client/src/api/tauri-adapter.test.ts b/client/src/api/tauri-adapter.test.ts deleted file mode 100644 index 66c1b84..0000000 --- a/client/src/api/tauri-adapter.test.ts +++ /dev/null @@ -1,821 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); -vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() })); - -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; - -import { - createTauriAPI, - initializeTauriAPI, - isTauriEnvironment, - type TauriInvoke, - type TauriListen, -} from './tauri-adapter'; -import type { NoteFlowAPI, TranscriptionStream } from './interface'; -import type { AudioChunk, Meeting, Summary, TranscriptUpdate, UserPreferences } from './types'; -import { meetingCache } from '@/lib/cache/meeting-cache'; -import { defaultPreferences } from '@/lib/preferences/constants'; -import { clonePreferences } from '@/lib/preferences/core'; - -type InvokeMock = (cmd: string, args?: Record) => Promise; -type ListenMock = ( - event: string, - handler: (event: { payload: unknown }) => void -) => Promise<() => void>; - -function createMocks() { - const invoke = vi.fn, ReturnType>(); - const listen = vi - .fn, ReturnType>() - .mockResolvedValue(() => {}); - return { invoke, listen }; -} - -function assertTranscriptionStream(value: unknown): asserts value is TranscriptionStream { - if (!value || typeof value !== 'object') { - throw new Error('Expected transcription stream'); - } - const record = value as Record; - if (typeof record.send !== 'function' || typeof record.onUpdate !== 'function') { - throw new Error('Expected transcription stream'); - } -} - -function buildMeeting(id: string): Meeting { - return { - id, - title: `Meeting ${id}`, - state: 'created', - created_at: Date.now() / 1000, - duration_seconds: 0, - segments: [], - metadata: {}, - }; -} - -function buildSummary(meetingId: string): Summary { - return { - meeting_id: meetingId, - executive_summary: 'Test summary', - key_points: [], - action_items: [], - model_version: 'test-v1', - generated_at: Date.now() / 1000, - }; -} - -function buildPreferences(aiTemplate?: UserPreferences['ai_template']): UserPreferences { - const prefs = clonePreferences(defaultPreferences); - return { - ...prefs, - ai_template: aiTemplate ?? prefs.ai_template, - }; -} - -describe('tauri-adapter mapping', () => { - it('maps listMeetings args to snake_case', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValue({ meetings: [], total_count: 0 }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.listMeetings({ - states: ['recording'], - limit: 5, - offset: 10, - sort_order: 'newest', - }); - - expect(invoke).toHaveBeenCalledWith('list_meetings', { - states: [2], - limit: 5, - offset: 10, - sort_order: 1, - project_id: undefined, - project_ids: [], - }); - }); - - it('maps identity commands with expected payloads', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValueOnce({ user_id: 'u1', display_name: 'Local User' }); - invoke.mockResolvedValueOnce({ workspaces: [] }); - invoke.mockResolvedValueOnce({ success: true }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.getCurrentUser(); - await api.listWorkspaces(); - await api.switchWorkspace('w1'); - - expect(invoke).toHaveBeenCalledWith('get_current_user'); - expect(invoke).toHaveBeenCalledWith('list_workspaces'); - expect(invoke).toHaveBeenCalledWith('switch_workspace', { workspace_id: 'w1' }); - }); - - it('maps auth login commands with expected payloads', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state123' }); - invoke.mockResolvedValueOnce({ - success: true, - user_id: 'u1', - workspace_id: 'w1', - display_name: 'Test User', - email: 'test@example.com', - }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - - const authResult = await api.initiateAuthLogin('google', 'noteflow://callback'); - expect(authResult).toEqual({ auth_url: 'https://auth.example.com', state: 'state123' }); - expect(invoke).toHaveBeenCalledWith('initiate_auth_login', { - provider: 'google', - redirect_uri: 'noteflow://callback', - }); - - const completeResult = await api.completeAuthLogin('google', 'auth-code', 'state123'); - expect(completeResult.success).toBe(true); - expect(completeResult.user_id).toBe('u1'); - expect(invoke).toHaveBeenCalledWith('complete_auth_login', { - provider: 'google', - code: 'auth-code', - state: 'state123', - }); - }); - - it('maps initiateAuthLogin without redirect_uri', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state456' }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.initiateAuthLogin('outlook'); - - expect(invoke).toHaveBeenCalledWith('initiate_auth_login', { - provider: 'outlook', - redirect_uri: undefined, - }); - }); - - it('maps logout command with optional provider', async () => { - const { invoke, listen } = createMocks(); - invoke - .mockResolvedValueOnce({ success: true, tokens_revoked: true }) - .mockResolvedValueOnce({ success: true, tokens_revoked: false, revocation_error: 'timeout' }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - - // Logout specific provider - const result1 = await api.logout('google'); - expect(result1.success).toBe(true); - expect(result1.tokens_revoked).toBe(true); - expect(invoke).toHaveBeenCalledWith('logout', { provider: 'google' }); - - // Logout all providers - const result2 = await api.logout(); - expect(result2.success).toBe(true); - expect(result2.tokens_revoked).toBe(false); - expect(result2.revocation_error).toBe('timeout'); - expect(invoke).toHaveBeenCalledWith('logout', { provider: undefined }); - }); - - it('handles completeAuthLogin failure response', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValueOnce({ - success: false, - error_message: 'Invalid authorization code', - }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const result = await api.completeAuthLogin('google', 'bad-code', 'state'); - - expect(result.success).toBe(false); - expect(result.error_message).toBe('Invalid authorization code'); - expect(result.user_id).toBeUndefined(); - }); - - it('maps meeting and annotation args to snake_case', async () => { - const { invoke, listen } = createMocks(); - const meeting = buildMeeting('m1'); - invoke.mockResolvedValueOnce(meeting).mockResolvedValueOnce({ id: 'a1' }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.getMeeting({ meeting_id: 'm1', include_segments: true, include_summary: true }); - await api.addAnnotation({ - meeting_id: 'm1', - annotation_type: 'decision', - text: 'Ship it', - start_time: 1.25, - end_time: 2.5, - segment_ids: [1, 2], - }); - - expect(invoke).toHaveBeenCalledWith('get_meeting', { - meeting_id: 'm1', - include_segments: true, - include_summary: true, - }); - expect(invoke).toHaveBeenCalledWith('add_annotation', { - meeting_id: 'm1', - annotation_type: 2, - text: 'Ship it', - start_time: 1.25, - end_time: 2.5, - segment_ids: [1, 2], - }); - }); - - it('normalizes delete responses', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValueOnce({ success: true }).mockResolvedValueOnce(true); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await expect(api.deleteMeeting('m1')).resolves.toBe(true); - await expect(api.deleteAnnotation('a1')).resolves.toBe(true); - - expect(invoke).toHaveBeenCalledWith('delete_meeting', { meeting_id: 'm1' }); - expect(invoke).toHaveBeenCalledWith('delete_annotation', { annotation_id: 'a1' }); - }); - - it('sends audio chunk with snake_case keys', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValue(undefined); - - const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const stream: unknown = await api.startTranscription('m1'); - assertTranscriptionStream(stream); - - const chunk: AudioChunk = { - meeting_id: 'm1', - audio_data: new Float32Array([0.25, -0.25]), - timestamp: 12.34, - sample_rate: 48000, - channels: 2, - }; - - stream.send(chunk); - // Wait for async queue to process the chunk - await vi.waitFor(() => { - expect(invoke).toHaveBeenCalledWith('send_audio_chunk', expect.anything()); - }); - - expect(invoke).toHaveBeenCalledWith('start_recording', { meeting_id: 'm1' }); - expect(invoke).toHaveBeenCalledWith('send_audio_chunk', { - meeting_id: 'm1', - audio_data: [0.25, -0.25], - timestamp: 12.34, - sample_rate: 48000, - channels: 2, - }); - }); - - it('sends audio chunk without optional fields', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValue(undefined); - - const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const stream: unknown = await api.startTranscription('m2'); - assertTranscriptionStream(stream); - - const chunk: AudioChunk = { - meeting_id: 'm2', - audio_data: new Float32Array([0.1]), - timestamp: 1.23, - }; - - stream.send(chunk); - // Wait for async queue to process the chunk - await vi.waitFor(() => { - expect(invoke).toHaveBeenCalledWith('send_audio_chunk', expect.anything()); - }); - - const call = invoke.mock.calls.find((item) => item[0] === 'send_audio_chunk'); - expect(call).toBeDefined(); - const args = call?.[1] as Record; - expect(args).toMatchObject({ - meeting_id: 'm2', - timestamp: 1.23, - }); - const audioData = args.audio_data as number[] | undefined; - expect(audioData).toHaveLength(1); - expect(audioData?.[0]).toBeCloseTo(0.1, 5); - }); - - it('forwards transcript updates with full segment payload', async () => { - let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null; - const invoke = vi - .fn, ReturnType>() - .mockResolvedValue(undefined); - const listen = vi - .fn, ReturnType>() - .mockImplementation((_event, handler) => { - capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void; - return Promise.resolve(() => {}); - }); - - const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const stream: unknown = await api.startTranscription('m1'); - assertTranscriptionStream(stream); - - const callback = vi.fn(); - await stream.onUpdate(callback); - - const payload: TranscriptUpdate = { - meeting_id: 'm1', - update_type: 'final', - partial_text: undefined, - segment: { - segment_id: 12, - text: 'Hello world', - start_time: 1.2, - end_time: 2.3, - words: [ - { word: 'Hello', start_time: 1.2, end_time: 1.6, probability: 0.9 }, - { word: 'world', start_time: 1.6, end_time: 2.3, probability: 0.92 }, - ], - language: 'en', - language_confidence: 0.99, - avg_logprob: -0.2, - no_speech_prob: 0.01, - speaker_id: 'SPEAKER_00', - speaker_confidence: 0.95, - }, - server_timestamp: 123.45, - }; - - if (!capturedHandler) { - throw new Error('Transcript update handler not registered'); - } - - capturedHandler({ payload }); - - expect(callback).toHaveBeenCalledWith(payload); - }); - - it('ignores transcript updates for other meetings', async () => { - let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null; - const invoke = vi - .fn, ReturnType>() - .mockResolvedValue(undefined); - const listen = vi - .fn, ReturnType>() - .mockImplementation((_event, handler) => { - capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void; - return Promise.resolve(() => {}); - }); - - const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const stream: unknown = await api.startTranscription('m1'); - assertTranscriptionStream(stream); - const callback = vi.fn(); - await stream.onUpdate(callback); - - capturedHandler?.({ - payload: { - meeting_id: 'other', - update_type: 'partial', - partial_text: 'nope', - server_timestamp: 1, - }, - }); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('maps connection and export commands with snake_case args', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValue({ version: '1.0.0' }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.connect('localhost:50051'); - await api.saveExportFile('content', 'Meeting Notes', 'md'); - - expect(invoke).toHaveBeenCalledWith('connect', { server_url: 'localhost:50051' }); - expect(invoke).toHaveBeenCalledWith('save_export_file', { - content: 'content', - default_name: 'Meeting Notes', - extension: 'md', - }); - }); - - it('maps audio device selection with snake_case args', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValue([]); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.listAudioDevices(); - await api.selectAudioDevice('input:0:Mic', true); - - expect(invoke).toHaveBeenCalledWith('list_audio_devices'); - expect(invoke).toHaveBeenCalledWith('select_audio_device', { - device_id: 'input:0:Mic', - is_input: true, - }); - }); - - it('maps playback commands with snake_case args', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValue({ - meeting_id: 'm1', - position: 0, - duration: 0, - is_playing: true, - is_paused: false, - }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.startPlayback('m1', 12.5); - await api.seekPlayback(30); - await api.getPlaybackState(); - - expect(invoke).toHaveBeenCalledWith('start_playback', { - meeting_id: 'm1', - start_time: 12.5, - }); - expect(invoke).toHaveBeenCalledWith('seek_playback', { position: 30 }); - expect(invoke).toHaveBeenCalledWith('get_playback_state'); - }); - - it('stops transcription stream on close', async () => { - const { invoke, listen } = createMocks(); - const unlisten = vi.fn(); - listen.mockResolvedValueOnce(unlisten); - invoke.mockResolvedValue(undefined); - - const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const stream: unknown = await api.startTranscription('m1'); - assertTranscriptionStream(stream); - - await stream.onUpdate(() => {}); - await stream.close(); - - expect(unlisten).toHaveBeenCalled(); - expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' }); - }); - - 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; - const unlisten = vi.fn(); - const invoke = vi - .fn, ReturnType>() - .mockResolvedValue(undefined); - const listen = vi - .fn, ReturnType>() - .mockImplementation((_event, handler) => { - capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void; - return new Promise<() => void>((resolve) => { - resolveListen = resolve; - }); - }); - - const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const stream: unknown = await api.startTranscription('m1'); - assertTranscriptionStream(stream); - - const callback = vi.fn(); - const onUpdatePromise = stream.onUpdate(callback); - - stream.close(); - resolveListen?.(unlisten); - await onUpdatePromise; - - expect(unlisten).toHaveBeenCalled(); - - if (!capturedHandler) { - throw new Error('Transcript update handler not registered'); - } - - capturedHandler({ - payload: { - meeting_id: 'm1', - update_type: 'partial', - partial_text: 'late update', - server_timestamp: 1, - }, - }); - - expect(callback).not.toHaveBeenCalled(); - }); - - it('stops transcription stream even without listeners', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValue(undefined); - - const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const stream: unknown = await api.startTranscription('m1'); - assertTranscriptionStream(stream); - await stream.close(); - - expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' }); - }); - - it('only caches meetings when list includes items', async () => { - const { invoke, listen } = createMocks(); - const cacheSpy = vi.spyOn(meetingCache, 'cacheMeetings'); - - invoke.mockResolvedValueOnce({ meetings: [], total_count: 0 }); - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - await api.listMeetings({}); - expect(cacheSpy).not.toHaveBeenCalled(); - - invoke.mockResolvedValueOnce({ meetings: [buildMeeting('m1')], total_count: 1 }); - await api.listMeetings({}); - expect(cacheSpy).toHaveBeenCalled(); - }); - - it('returns false when delete meeting fails', async () => { - const { invoke, listen } = createMocks(); - invoke.mockResolvedValueOnce({ success: false }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const result = await api.deleteMeeting('m1'); - - expect(result).toBe(false); - }); - - it('generates summary with template options when available', async () => { - const { invoke, listen } = createMocks(); - const summary = buildSummary('m1'); - - invoke - .mockResolvedValueOnce( - buildPreferences({ tone: 'casual', format: 'narrative', verbosity: 'balanced' }) - ) - .mockResolvedValueOnce(summary); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const result = await api.generateSummary('m1', true); - - expect(result).toEqual(summary); - expect(invoke).toHaveBeenCalledWith('generate_summary', { - meeting_id: 'm1', - force_regenerate: true, - options: { tone: 'casual', format: 'narrative', verbosity: 'balanced' }, - }); - }); - - it('generates summary even if preferences lookup fails', async () => { - const { invoke, listen } = createMocks(); - const summary = buildSummary('m2'); - - invoke.mockRejectedValueOnce(new Error('no prefs')).mockResolvedValueOnce(summary); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - const result = await api.generateSummary('m2'); - - expect(result).toEqual(summary); - expect(invoke).toHaveBeenCalledWith('generate_summary', { - meeting_id: 'm2', - force_regenerate: false, - options: undefined, - }); - }); - - it('covers additional adapter commands', async () => { - const { invoke, listen } = createMocks(); - - const annotation = { - id: 'a1', - meeting_id: 'm1', - annotation_type: 'note', - text: 'Note', - start_time: 0, - end_time: 1, - segment_ids: [], - created_at: 1, - }; - - const annotationResponses: Array< - (typeof annotation)[] | { annotations: (typeof annotation)[] } - > = [{ annotations: [annotation] }, [annotation]]; - - invoke.mockImplementation(async (cmd) => { - switch (cmd) { - case 'list_annotations': - return annotationResponses.shift(); - case 'get_annotation': - return annotation; - case 'update_annotation': - return annotation; - case 'export_transcript': - return { content: 'data', format_name: 'Markdown', file_extension: '.md' }; - case 'save_export_file': - return true; - case 'list_audio_devices': - return []; - case 'get_default_audio_device': - return null; - case 'get_preferences': - return buildPreferences(); - case 'get_cloud_consent_status': - return { consent_granted: true }; - case 'get_trigger_status': - return { - enabled: false, - is_snoozed: false, - snooze_remaining_secs: 0, - pending_trigger: null, - }; - case 'accept_trigger': - return buildMeeting('m9'); - case 'extract_entities': - return { entities: [], total_count: 0, cached: false }; - case 'update_entity': - return { id: 'e1', text: 'Entity', category: 'other', segment_ids: [], confidence: 1 }; - case 'delete_entity': - return true; - case 'list_calendar_events': - return { events: [], total_count: 0 }; - case 'get_calendar_providers': - return { providers: [] }; - case 'initiate_oauth': - return { auth_url: 'https://auth', state: 'state' }; - case 'complete_oauth': - return { success: true, error_message: '', integration_id: 'int-123' }; - case 'get_oauth_connection_status': - return { - connection: { - provider: 'google', - status: 'disconnected', - email: '', - expires_at: 0, - error_message: '', - integration_type: 'calendar', - }, - }; - case 'disconnect_oauth': - return { success: true }; - case 'register_webhook': - return { - id: 'w1', - workspace_id: 'w1', - name: 'Webhook', - url: 'https://example.com', - events: ['meeting.completed'], - enabled: true, - timeout_ms: 1000, - max_retries: 3, - created_at: 1, - updated_at: 1, - }; - case 'list_webhooks': - return { webhooks: [], total_count: 0 }; - case 'update_webhook': - return { - id: 'w1', - workspace_id: 'w1', - name: 'Webhook', - url: 'https://example.com', - events: ['meeting.completed'], - enabled: false, - timeout_ms: 1000, - max_retries: 3, - created_at: 1, - updated_at: 2, - }; - case 'delete_webhook': - return { success: true }; - case 'get_webhook_deliveries': - return { deliveries: [], total_count: 0 }; - case 'start_integration_sync': - return { sync_run_id: 's1', status: 'running' }; - case 'get_sync_status': - return { status: 'success', items_synced: 1, items_total: 1, error_message: '' }; - case 'list_sync_history': - return { runs: [], total_count: 0 }; - case 'get_recent_logs': - return { logs: [], total_count: 0 }; - case 'get_performance_metrics': - return { - current: { - timestamp: 1, - cpu_percent: 0, - memory_percent: 0, - memory_mb: 0, - disk_percent: 0, - network_bytes_sent: 0, - network_bytes_recv: 0, - process_memory_mb: 0, - active_connections: 0, - }, - history: [], - }; - case 'refine_speakers': - return { job_id: 'job', status: 'queued', segments_updated: 0, speaker_ids: [] }; - case 'get_diarization_status': - return { job_id: 'job', status: 'completed', segments_updated: 1, speaker_ids: [] }; - case 'rename_speaker': - return { success: true }; - case 'cancel_diarization': - return { success: true, error_message: '', status: 'cancelled' }; - default: - return undefined; - } - }); - - const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); - - const list1 = await api.listAnnotations('m1'); - const list2 = await api.listAnnotations('m1'); - expect(list1).toHaveLength(1); - expect(list2).toHaveLength(1); - - await api.getAnnotation('a1'); - await api.updateAnnotation({ annotation_id: 'a1', text: 'Updated' }); - await api.exportTranscript('m1', 'markdown'); - await api.saveExportFile('content', 'Meeting', 'md'); - await api.listAudioDevices(); - await api.getDefaultAudioDevice(true); - await api.selectAudioDevice('mic', true); - await api.getPreferences(); - await api.savePreferences(buildPreferences()); - await api.grantCloudConsent(); - await api.revokeCloudConsent(); - await api.getCloudConsentStatus(); - await api.pausePlayback(); - await api.stopPlayback(); - await api.setTriggerEnabled(true); - await api.snoozeTriggers(5); - await api.resetSnooze(); - await api.getTriggerStatus(); - await api.dismissTrigger(); - await api.acceptTrigger('Title'); - await api.extractEntities('m1', true); - await api.updateEntity('m1', 'e1', 'Entity', 'other'); - await api.deleteEntity('m1', 'e1'); - await api.listCalendarEvents(2, 5, 'google'); - await api.getCalendarProviders(); - await api.initiateCalendarAuth('google', 'redirect'); - await api.completeCalendarAuth('google', 'code', 'state'); - await api.getOAuthConnectionStatus('google'); - await api.disconnectCalendar('google'); - await api.registerWebhook({ - workspace_id: 'w1', - name: 'Webhook', - url: 'https://example.com', - events: ['meeting.completed'], - }); - await api.listWebhooks(); - await api.updateWebhook({ webhook_id: 'w1', name: 'Webhook' }); - await api.deleteWebhook('w1'); - await api.getWebhookDeliveries('w1', 10); - await api.startIntegrationSync('int-1'); - await api.getSyncStatus('sync'); - await api.listSyncHistory('int-1', 10, 0); - await api.getRecentLogs({ limit: 10 }); - await api.getPerformanceMetrics({ history_limit: 5 }); - await api.refineSpeakers('m1', 2); - await api.getDiarizationJobStatus('job'); - await api.renameSpeaker('m1', 'old', 'new'); - await api.cancelDiarization('job'); - }); -}); - -describe('tauri-adapter environment', () => { - const invokeMock = vi.mocked(invoke); - const listenMock = vi.mocked(listen); - - beforeEach(() => { - invokeMock.mockReset(); - listenMock.mockReset(); - }); - - it('detects tauri environment flags', () => { - vi.stubGlobal('window', undefined as unknown as Window); - expect(isTauriEnvironment()).toBe(false); - vi.unstubAllGlobals(); - expect(isTauriEnvironment()).toBe(false); - - window.__TAURI__ = {}; - expect(isTauriEnvironment()).toBe(true); - delete window.__TAURI__; - - window.__TAURI_INTERNALS__ = {}; - expect(isTauriEnvironment()).toBe(true); - delete window.__TAURI_INTERNALS__; - - window.isTauri = true; - expect(isTauriEnvironment()).toBe(true); - delete window.isTauri; - }); - - it('initializes tauri api when available', async () => { - invokeMock.mockResolvedValueOnce(true); - listenMock.mockResolvedValue(() => {}); - - const api = await initializeTauriAPI(); - expect(api).toBeDefined(); - expect(invokeMock).toHaveBeenCalledWith('is_connected'); - }); - - it('throws when tauri api is unavailable', async () => { - invokeMock.mockRejectedValueOnce(new Error('no tauri')); - - await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment'); - }); - - it('throws a helpful error when invoke rejects with non-Error', async () => { - invokeMock.mockRejectedValueOnce('no tauri'); - await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment'); - }); -}); diff --git a/client/src/api/tauri-adapter.ts b/client/src/api/tauri-adapter.ts deleted file mode 100644 index f2a6847..0000000 --- a/client/src/api/tauri-adapter.ts +++ /dev/null @@ -1,1470 +0,0 @@ -/** Tauri API adapter implementing NoteFlowAPI via Rust backend IPC. */ -import type { NoteFlowAPI, TranscriptionStream } from './interface'; -import { IdentityDefaults, Timing } from './constants'; -import { TauriCommands, TauriEvents } from './tauri-constants'; - -// Re-export TauriEvents for external consumers -export { TauriEvents } from './tauri-constants'; -import { - annotationTypeToGrpc, - exportFormatToGrpc, - extractErrorDetails, - extractErrorMessage, - normalizeAnnotationList, - normalizeSuccessResponse, - sortOrderToGrpcEnum, - stateToGrpcEnum, -} from './helpers'; -import { meetingCache } from '@/lib/cache/meeting-cache'; -import { addClientLog } from '@/lib/client-logs'; -import { clientLog } from '@/lib/client-log-events'; -import { errorLog } from '@/lib/debug'; -import { StreamingQueue } from '@/lib/async-utils'; - -const logError = errorLog('TauriTranscriptionStream'); -const {DEFAULT_PROJECT_ID} = IdentityDefaults; - -function normalizeProjectId(projectId?: string): string | undefined { - const trimmed = projectId?.trim(); - if (!trimmed || trimmed === DEFAULT_PROJECT_ID) { - return undefined; - } - return trimmed; -} - -function normalizeProjectIds(projectIds: string[]): string[] { - return projectIds - .map((projectId) => projectId.trim()) - .filter((projectId) => projectId && projectId !== DEFAULT_PROJECT_ID); -} -import type { - AddAnnotationRequest, - Annotation, - ASRConfiguration, - ASRConfigurationJobStatus, - StreamingConfiguration, - AudioChunk, - AudioDeviceInfo, - DualCaptureConfigInfo, - AddProjectMemberRequest, - ArchiveSummarizationTemplateRequest, - CancelDiarizationResult, - CompleteAuthLoginResponse, - CompleteCalendarAuthResponse, - ConnectionDiagnostics, - StreamStateInfo, - CreateMeetingRequest, - CreateProjectRequest, - CreateSummarizationTemplateRequest, - DeleteOidcProviderResponse, - DeleteWebhookResponse, - DiarizationJobStatus, - DisconnectOAuthResponse, - EffectiveServerUrl, - ExportFormat, - ExportResult, - ExtractEntitiesResponse, - ExtractedEntity, - GetCalendarProvidersResponse, - GetCurrentUserResponse, - GetActiveProjectRequest, - GetActiveProjectResponse, - GetMeetingRequest, - GetOAuthClientConfigRequest, - GetOAuthClientConfigResponse, - GetOAuthConnectionStatusResponse, - GetProjectBySlugRequest, - GetProjectRequest, - GetSummarizationTemplateRequest, - GetSummarizationTemplateResponse, - GetPerformanceMetricsRequest, - GetPerformanceMetricsResponse, - GetRecentLogsRequest, - GetRecentLogsResponse, - GetSyncStatusResponse, - GetUserIntegrationsResponse, - GetWorkspaceSettingsRequest, - GetWorkspaceSettingsResponse, - GetWebhookDeliveriesResponse, - HuggingFaceTokenStatus, - ListInstalledAppsRequest, - ListInstalledAppsResponse, - InitiateAuthLoginResponse, - InitiateCalendarAuthResponse, - ListOidcPresetsResponse, - ListOidcProvidersResponse, - ListSummarizationTemplateVersionsRequest, - ListSummarizationTemplateVersionsResponse, - ListSummarizationTemplatesRequest, - ListSummarizationTemplatesResponse, - ListWorkspacesResponse, - LogoutResponse, - ListCalendarEventsResponse, - ListMeetingsRequest, - ListMeetingsResponse, - ListProjectMembersRequest, - ListProjectMembersResponse, - ListProjectsRequest, - ListProjectsResponse, - ListSyncHistoryResponse, - ListWebhooksResponse, - Meeting, - OidcProviderApi, - PlaybackInfo, - Project, - ProjectMembership, - RefreshOidcDiscoveryResponse, - RegisteredWebhook, - RegisterOidcProviderRequest, - RegisterWebhookRequest, - RemoveProjectMemberRequest, - RemoveProjectMemberResponse, - RestoreSummarizationTemplateVersionRequest, - ServerInfo, - SetActiveProjectRequest, - SetHuggingFaceTokenRequest, - SetHuggingFaceTokenResult, - SetOAuthClientConfigRequest, - SetOAuthClientConfigResponse, - StartIntegrationSyncResponse, - SwitchWorkspaceResponse, - SummarizationOptions, - SummarizationTemplate, - SummarizationTemplateMutationResponse, - Summary, - TestAudioConfig, - TestAudioResult, - TestEnvironmentInfo, - TranscriptUpdate, - TriggerStatus, - UpdateAnnotationRequest, - UpdateASRConfigurationRequest, - UpdateASRConfigurationResult, - UpdateStreamingConfigurationRequest, - UpdateOidcProviderRequest, - UpdateProjectMemberRoleRequest, - UpdateProjectRequest, - UpdateSummarizationTemplateRequest, - UpdateWorkspaceSettingsRequest, - UpdateWebhookRequest, - UserPreferences, - ValidateHuggingFaceTokenResult, -} from './types'; - -/** Type-safe wrapper for Tauri's invoke function. */ -export type TauriInvoke = (cmd: string, args?: Record) => Promise; -/** Type-safe wrapper for Tauri's event system. */ -export type TauriListen = ( - event: string, - handler: (event: { payload: T }) => void -) => Promise<() => void>; - -/** Error callback type for stream errors. */ -export type StreamErrorCallback = (error: { code: string; message: string }) => void; - -/** Congestion state for UI feedback. */ -export interface CongestionState { - /** Whether the stream is currently showing congestion to the user. */ - isBuffering: boolean; - /** Duration of congestion in milliseconds. */ - duration: number; -} - -/** Congestion callback type for stream health updates. */ -export type CongestionCallback = (state: CongestionState) => void; - -/** Consecutive failure threshold before emitting stream error. */ -export const CONSECUTIVE_FAILURE_THRESHOLD = 3; - -/** Threshold in milliseconds before showing buffering indicator (2 seconds). */ -export const CONGESTION_DISPLAY_THRESHOLD_MS = Timing.TWO_SECONDS_MS; - -const RECORDING_BLOCKED_PREFIX = 'Recording blocked by app policy'; - -function recordingBlockedDetails(error: unknown): { - ruleId?: string; - ruleLabel?: string; - appName?: string; -} | null { - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : JSON.stringify(error); - - if (!message.includes(RECORDING_BLOCKED_PREFIX)) { - return null; - } - - const details = message.split(RECORDING_BLOCKED_PREFIX)[1] ?? ''; - const cleaned = details.replace(/^\s*:\s*/, ''); - const parts = cleaned - .split(',') - .map((part) => part.trim()) - .filter(Boolean); - - const extracted: { ruleId?: string; ruleLabel?: string; appName?: string } = {}; - for (const part of parts) { - if (part.startsWith('rule_id=')) { - extracted.ruleId = part.replace('rule_id=', '').trim(); - } else if (part.startsWith('rule_label=')) { - extracted.ruleLabel = part.replace('rule_label=', '').trim(); - } else if (part.startsWith('app_name=')) { - extracted.appName = part.replace('app_name=', '').trim(); - } - } - - return extracted; -} - -/** Real-time transcription stream using Tauri events. */ -export class TauriTranscriptionStream implements TranscriptionStream { - private unlistenFn: (() => void) | null = null; - private healthUnlistenFn: (() => void) | null = null; - private healthListenerPending = false; - private errorCallback: StreamErrorCallback | null = null; - private congestionCallback: CongestionCallback | null = null; - - /** Latest ack_sequence received from server (for debugging/monitoring). */ - private lastAckedSequence = 0; - - /** Timestamp when congestion started (null if not congested). */ - private congestionStartTime: number | null = null; - - /** Whether buffering indicator is currently shown. */ - private isShowingBuffering = false; - - /** Whether the stream has been closed (prevents late listeners). */ - private isClosed = false; - - /** Queue for ordered, backpressure-aware chunk transmission. */ - private readonly sendQueue: StreamingQueue; - - constructor( - private meetingId: string, - private invoke: TauriInvoke, - private listen: TauriListen - ) { - this.sendQueue = new StreamingQueue({ - label: `audio-stream-${meetingId}`, - maxDepth: 50, // ~5 seconds of audio at 100ms chunks - failureThreshold: CONSECUTIVE_FAILURE_THRESHOLD, - onThresholdReached: (failures) => { - if (this.errorCallback) { - this.errorCallback({ - code: 'stream_send_failed', - message: `Audio streaming interrupted after ${failures} consecutive failures`, - }); - } - }, - onOverflow: () => { - // Queue full = severe backpressure, notify via congestion callback - if (this.congestionCallback) { - this.congestionCallback({ isBuffering: true, duration: 0 }); - } - }, - }); - } - - /** Get the last acknowledged chunk sequence number. */ - getLastAckedSequence(): number { - return this.lastAckedSequence; - } - - /** Get current queue depth (for monitoring). */ - getQueueDepth(): number { - return this.sendQueue.currentDepth; - } - - /** - * Send an audio chunk to the transcription service. - * - * Chunks are queued and sent in order with backpressure protection. - * Returns false if the queue is full (severe backpressure). - */ - send(chunk: AudioChunk): boolean { - if (this.isClosed) { - return false; - } - - const args: Record = { - meeting_id: chunk.meeting_id, - audio_data: Array.from(chunk.audio_data), - timestamp: chunk.timestamp, - }; - if (typeof chunk.sample_rate === 'number') { - args.sample_rate = chunk.sample_rate; - } - if (typeof chunk.channels === 'number') { - args.channels = chunk.channels; - } - - return this.sendQueue.enqueueWithSuccessReset(async () => { - await this.invoke(TauriCommands.SEND_AUDIO_CHUNK, args); - }); - } - - async onUpdate(callback: (update: TranscriptUpdate) => void): Promise { - // Clean up any existing listener to prevent memory leaks - // (calling onUpdate() multiple times should replace, not accumulate listeners) - if (this.unlistenFn) { - this.unlistenFn(); - this.unlistenFn = null; - } - - const unlisten = await this.listen(TauriEvents.TRANSCRIPT_UPDATE, (event) => { - if (this.isClosed) { - return; - } - if (event.payload.meeting_id === this.meetingId) { - // Track latest ack_sequence for monitoring - if ( - typeof event.payload.ack_sequence === 'number' && - event.payload.ack_sequence > this.lastAckedSequence - ) { - this.lastAckedSequence = event.payload.ack_sequence; - } - callback(event.payload); - } - }); - if (this.isClosed) { - unlisten(); - return; - } - this.unlistenFn = unlisten; - } - - /** Register callback for stream errors (connection failures, etc.). */ - onError(callback: StreamErrorCallback): void { - this.errorCallback = callback; - } - - /** Register callback for congestion state updates (buffering indicator). */ - onCongestion(callback: CongestionCallback): void { - this.congestionCallback = callback; - // Start listening for stream_health events - this.startHealthListener(); - } - - /** Start listening for stream_health events from the Rust backend. */ - private startHealthListener(): void { - if (this.isClosed) { - return; - } - if (this.healthUnlistenFn || this.healthListenerPending) { - return; - } // Already listening or setup in progress - - this.healthListenerPending = true; - - this.listen<{ - meeting_id: string; - is_congested: boolean; - processing_delay_ms: number; - queue_depth: number; - congested_duration_ms: number; - }>(TauriEvents.STREAM_HEALTH, (event) => { - if (this.isClosed) { - return; - } - if (event.payload.meeting_id !== this.meetingId) { - return; - } - - const { is_congested } = event.payload; - - if (is_congested) { - // Start tracking congestion if not already - this.congestionStartTime ??= Date.now(); - const duration = Date.now() - this.congestionStartTime; - - // Only show buffering after threshold is exceeded - if (duration >= CONGESTION_DISPLAY_THRESHOLD_MS && !this.isShowingBuffering) { - this.isShowingBuffering = true; - this.congestionCallback?.({ isBuffering: true, duration }); - } else if (this.isShowingBuffering) { - // Update duration while showing - this.congestionCallback?.({ isBuffering: true, duration }); - } - } else { - // Congestion cleared - if (this.isShowingBuffering) { - this.isShowingBuffering = false; - this.congestionCallback?.({ isBuffering: false, duration: 0 }); - } - this.congestionStartTime = null; - } - }) - .then((unlisten) => { - if (this.isClosed) { - unlisten(); - this.healthListenerPending = false; - return; - } - this.healthUnlistenFn = unlisten; - this.healthListenerPending = false; - }) - .catch(() => { - // Stream health listener failed - non-critical, monitoring degraded - this.healthListenerPending = false; - }); - } - - /** - * Close the stream and stop recording. - * - * Drains the send queue first to ensure all pending chunks are transmitted, - * then stops recording on the backend. - * - * Returns a Promise that resolves when the backend has confirmed the - * recording has stopped. The Promise rejects if stopping fails. - */ - async close(): Promise { - this.isClosed = true; - - // Drain the send queue to ensure all pending chunks are transmitted - try { - await this.sendQueue.drain(); - } catch { - // Queue drain failed - continue with cleanup anyway - } - - if (this.unlistenFn) { - this.unlistenFn(); - this.unlistenFn = null; - } - if (this.healthUnlistenFn) { - this.healthUnlistenFn(); - this.healthUnlistenFn = null; - } - // Reset congestion state - this.congestionStartTime = null; - this.isShowingBuffering = false; - - try { - await this.invoke(TauriCommands.STOP_RECORDING, { meeting_id: this.meetingId }); - } catch (err: unknown) { - const message = extractErrorMessage(err, 'Failed to stop recording'); - logError('stop_recording failed', message); - addClientLog({ - level: 'error', - source: 'api', - message: 'Tauri stream stop_recording failed', - details: message, - metadata: { context: 'tauri_stream_stop', meeting_id: this.meetingId }, - }); - // Emit error so UI can show notification - if (this.errorCallback) { - this.errorCallback({ - code: 'stream_close_failed', - message: `Failed to stop recording: ${message}`, - }); - } - throw err; // Re-throw so callers can handle if they await - } - } -} - -/** Creates a Tauri API adapter instance. */ -export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFlowAPI { - return { - async getServerInfo(): Promise { - return invoke(TauriCommands.GET_SERVER_INFO); - }, - async connect(serverUrl?: string): Promise { - try { - const info = await invoke(TauriCommands.CONNECT, { server_url: serverUrl }); - clientLog.connected(serverUrl); - return info; - } catch (error) { - clientLog.connectionFailed(extractErrorMessage(error, 'Connection failed')); - throw error; - } - }, - async disconnect(): Promise { - await invoke(TauriCommands.DISCONNECT); - clientLog.disconnected(); - }, - async isConnected(): Promise { - return invoke(TauriCommands.IS_CONNECTED); - }, - async getEffectiveServerUrl(): Promise { - return invoke(TauriCommands.GET_EFFECTIVE_SERVER_URL); - }, - - async getCurrentUser(): Promise { - return invoke(TauriCommands.GET_CURRENT_USER); - }, - - async listWorkspaces(): Promise { - return invoke(TauriCommands.LIST_WORKSPACES); - }, - - async switchWorkspace(workspaceId: string): Promise { - return invoke(TauriCommands.SWITCH_WORKSPACE, { - workspace_id: workspaceId, - }); - }, - - async getWorkspaceSettings( - request: GetWorkspaceSettingsRequest - ): Promise { - return invoke(TauriCommands.GET_WORKSPACE_SETTINGS, { - workspace_id: request.workspace_id, - }); - }, - - async updateWorkspaceSettings( - request: UpdateWorkspaceSettingsRequest - ): Promise { - return invoke(TauriCommands.UPDATE_WORKSPACE_SETTINGS, { - workspace_id: request.workspace_id, - settings: request.settings, - }); - }, - - async initiateAuthLogin( - provider: string, - redirectUri?: string - ): Promise { - return invoke(TauriCommands.INITIATE_AUTH_LOGIN, { - provider, - redirect_uri: redirectUri, - }); - }, - - async completeAuthLogin( - provider: string, - code: string, - state: string - ): Promise { - const response = await invoke(TauriCommands.COMPLETE_AUTH_LOGIN, { - provider, - code, - state, - }); - clientLog.loginCompleted(provider); - return response; - }, - - async logout(provider?: string): Promise { - const response = await invoke(TauriCommands.LOGOUT, { - provider, - }); - clientLog.loggedOut(provider); - return response; - }, - - async createProject(request: CreateProjectRequest): Promise { - return invoke(TauriCommands.CREATE_PROJECT, { - request, - }); - }, - - async getProject(request: GetProjectRequest): Promise { - return invoke(TauriCommands.GET_PROJECT, { - project_id: request.project_id, - }); - }, - - async getProjectBySlug(request: GetProjectBySlugRequest): Promise { - return invoke(TauriCommands.GET_PROJECT_BY_SLUG, { - workspace_id: request.workspace_id, - slug: request.slug, - }); - }, - - async listProjects(request: ListProjectsRequest): Promise { - return invoke(TauriCommands.LIST_PROJECTS, { - workspace_id: request.workspace_id, - include_archived: request.include_archived ?? false, - limit: request.limit, - offset: request.offset, - }); - }, - - async updateProject(request: UpdateProjectRequest): Promise { - return invoke(TauriCommands.UPDATE_PROJECT, { - request, - }); - }, - - async archiveProject(projectId: string): Promise { - return invoke(TauriCommands.ARCHIVE_PROJECT, { - project_id: projectId, - }); - }, - - async restoreProject(projectId: string): Promise { - return invoke(TauriCommands.RESTORE_PROJECT, { - project_id: projectId, - }); - }, - - async deleteProject(projectId: string): Promise { - const response = await invoke<{ success: boolean }>(TauriCommands.DELETE_PROJECT, { - project_id: projectId, - }); - return normalizeSuccessResponse(response); - }, - - async setActiveProject(request: SetActiveProjectRequest): Promise { - await invoke(TauriCommands.SET_ACTIVE_PROJECT, { - workspace_id: request.workspace_id, - project_id: normalizeProjectId(request.project_id), - }); - }, - - async getActiveProject(request: GetActiveProjectRequest): Promise { - return invoke(TauriCommands.GET_ACTIVE_PROJECT, { - workspace_id: request.workspace_id, - }); - }, - - async addProjectMember(request: AddProjectMemberRequest): Promise { - return invoke(TauriCommands.ADD_PROJECT_MEMBER, { - request, - }); - }, - - async updateProjectMemberRole( - request: UpdateProjectMemberRoleRequest - ): Promise { - return invoke(TauriCommands.UPDATE_PROJECT_MEMBER_ROLE, { - request, - }); - }, - - async removeProjectMember( - request: RemoveProjectMemberRequest - ): Promise { - return invoke(TauriCommands.REMOVE_PROJECT_MEMBER, { - request, - }); - }, - - async listProjectMembers( - request: ListProjectMembersRequest - ): Promise { - return invoke(TauriCommands.LIST_PROJECT_MEMBERS, { - project_id: request.project_id, - limit: request.limit, - offset: request.offset, - }); - }, - - async createMeeting(request: CreateMeetingRequest): Promise { - const meeting = await invoke(TauriCommands.CREATE_MEETING, { - title: request.title, - metadata: request.metadata ?? {}, - project_id: normalizeProjectId(request.project_id), - }); - meetingCache.cacheMeeting(meeting); - clientLog.meetingCreated(meeting.id, meeting.title); - return meeting; - }, - async listMeetings(request: ListMeetingsRequest): Promise { - const response = await invoke(TauriCommands.LIST_MEETINGS, { - states: request.states?.map(stateToGrpcEnum) ?? [], - limit: request.limit ?? 50, - offset: request.offset ?? 0, - sort_order: sortOrderToGrpcEnum(request.sort_order), - project_id: normalizeProjectId(request.project_id), - project_ids: normalizeProjectIds(request.project_ids ?? []), - }); - if (response.meetings?.length) { - meetingCache.cacheMeetings(response.meetings); - } - return response; - }, - async getMeeting(request: GetMeetingRequest): Promise { - const meeting = await invoke(TauriCommands.GET_MEETING, { - meeting_id: request.meeting_id, - include_segments: request.include_segments ?? false, - include_summary: request.include_summary ?? false, - }); - meetingCache.cacheMeeting(meeting); - return meeting; - }, - async stopMeeting(meetingId: string): Promise { - const meeting = await invoke(TauriCommands.STOP_MEETING, { - meeting_id: meetingId, - }); - meetingCache.cacheMeeting(meeting); - clientLog.meetingStopped(meeting.id, meeting.title); - return meeting; - }, - async deleteMeeting(meetingId: string): Promise { - const result = normalizeSuccessResponse( - await invoke(TauriCommands.DELETE_MEETING, { - meeting_id: meetingId, - }) - ); - if (result) { - meetingCache.removeMeeting(meetingId); - clientLog.meetingDeleted(meetingId); - } - return result; - }, - - async startTranscription(meetingId: string): Promise { - try { - await invoke(TauriCommands.START_RECORDING, { meeting_id: meetingId }); - return new TauriTranscriptionStream(meetingId, invoke, listen); - } catch (error) { - const details = extractErrorDetails(error, 'Failed to start recording'); - clientLog.recordingStartFailed( - meetingId, - details.message, - details.grpcStatus, - details.category, - details.retryable - ); - const blocked = recordingBlockedDetails(error); - if (blocked) { - addClientLog({ - level: 'warning', - source: 'system', - message: RECORDING_BLOCKED_PREFIX, - metadata: { - rule_id: blocked.ruleId ?? '', - rule_label: blocked.ruleLabel ?? '', - app_name: blocked.appName ?? '', - }, - }); - } - throw error; - } - }, - - async getStreamState(): Promise { - return invoke(TauriCommands.GET_STREAM_STATE); - }, - - async resetStreamState(): Promise { - const info = await invoke(TauriCommands.RESET_STREAM_STATE); - addClientLog({ - level: 'warning', - source: 'system', - message: `Stream state force-reset from ${info.state}${info.meeting_id ? ` (meeting: ${info.meeting_id})` : ''}`, - metadata: { - previous_state: info.state, - meeting_id: info.meeting_id ?? '', - started_at_secs_ago: String(info.started_at_secs_ago ?? 0), - }, - }); - return info; - }, - - async generateSummary(meetingId: string, forceRegenerate?: boolean): Promise { - let options: SummarizationOptions | undefined; - try { - const prefs = await invoke(TauriCommands.GET_PREFERENCES); - if (prefs?.ai_template) { - options = { - tone: prefs.ai_template.tone, - format: prefs.ai_template.format, - verbosity: prefs.ai_template.verbosity, - }; - } - } catch { - /* Preferences unavailable */ - } - clientLog.summarizing(meetingId); - try { - const summary = await invoke(TauriCommands.GENERATE_SUMMARY, { - meeting_id: meetingId, - force_regenerate: forceRegenerate ?? false, - options, - }); - clientLog.summaryGenerated(meetingId, summary.model_version); - return summary; - } catch (error) { - clientLog.summaryFailed(meetingId, extractErrorMessage(error, 'Summary generation failed')); - throw error; - } - }, - - async listSummarizationTemplates( - request: ListSummarizationTemplatesRequest - ): Promise { - return invoke( - TauriCommands.LIST_SUMMARIZATION_TEMPLATES, - { - workspace_id: request.workspace_id, - include_system: request.include_system ?? true, - include_archived: request.include_archived ?? false, - limit: request.limit, - offset: request.offset, - } - ); - }, - - async getSummarizationTemplate( - request: GetSummarizationTemplateRequest - ): Promise { - return invoke(TauriCommands.GET_SUMMARIZATION_TEMPLATE, { - template_id: request.template_id, - include_current_version: request.include_current_version ?? true, - }); - }, - - async createSummarizationTemplate( - request: CreateSummarizationTemplateRequest - ): Promise { - return invoke( - TauriCommands.CREATE_SUMMARIZATION_TEMPLATE, - { - workspace_id: request.workspace_id, - name: request.name, - description: request.description, - content: request.content, - change_note: request.change_note, - } - ); - }, - - async updateSummarizationTemplate( - request: UpdateSummarizationTemplateRequest - ): Promise { - return invoke( - TauriCommands.UPDATE_SUMMARIZATION_TEMPLATE, - { - template_id: request.template_id, - name: request.name, - description: request.description, - content: request.content, - change_note: request.change_note, - } - ); - }, - - async archiveSummarizationTemplate( - request: ArchiveSummarizationTemplateRequest - ): Promise { - return invoke(TauriCommands.ARCHIVE_SUMMARIZATION_TEMPLATE, { - template_id: request.template_id, - }); - }, - - async listSummarizationTemplateVersions( - request: ListSummarizationTemplateVersionsRequest - ): Promise { - return invoke( - TauriCommands.LIST_SUMMARIZATION_TEMPLATE_VERSIONS, - { - template_id: request.template_id, - limit: request.limit, - offset: request.offset, - } - ); - }, - - async restoreSummarizationTemplateVersion( - request: RestoreSummarizationTemplateVersionRequest - ): Promise { - return invoke(TauriCommands.RESTORE_SUMMARIZATION_TEMPLATE_VERSION, { - template_id: request.template_id, - version_id: request.version_id, - }); - }, - - async grantCloudConsent(): Promise { - await invoke(TauriCommands.GRANT_CLOUD_CONSENT); - clientLog.cloudConsentGranted(); - }, - async revokeCloudConsent(): Promise { - await invoke(TauriCommands.REVOKE_CLOUD_CONSENT); - clientLog.cloudConsentRevoked(); - }, - async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> { - return invoke<{ consent_granted: boolean }>(TauriCommands.GET_CLOUD_CONSENT_STATUS).then( - (r) => ({ consentGranted: r.consent_granted }) - ); - }, - - // --- ASR Configuration (Sprint 19) --- - - async getAsrConfiguration(): Promise { - return invoke(TauriCommands.GET_ASR_CONFIGURATION); - }, - - async updateAsrConfiguration( - request: UpdateASRConfigurationRequest - ): Promise { - return invoke(TauriCommands.UPDATE_ASR_CONFIGURATION, { - request, - }); - }, - - async getAsrJobStatus(jobId: string): Promise { - return invoke(TauriCommands.GET_ASR_JOB_STATUS, { - job_id: jobId, - }); - }, - - // --- Streaming Configuration (Sprint 20) --- - - async getStreamingConfiguration(): Promise { - return invoke(TauriCommands.GET_STREAMING_CONFIGURATION); - }, - - async updateStreamingConfiguration( - request: UpdateStreamingConfigurationRequest - ): Promise { - return invoke(TauriCommands.UPDATE_STREAMING_CONFIGURATION, { - request, - }); - }, - - // --- HuggingFace Token (Sprint 19) --- - - async setHuggingFaceToken( - request: SetHuggingFaceTokenRequest - ): Promise { - return invoke(TauriCommands.SET_HUGGINGFACE_TOKEN, { - request, - }); - }, - - async getHuggingFaceTokenStatus(): Promise { - return invoke(TauriCommands.GET_HUGGINGFACE_TOKEN_STATUS); - }, - - async deleteHuggingFaceToken(): Promise { - return invoke(TauriCommands.DELETE_HUGGINGFACE_TOKEN); - }, - - async validateHuggingFaceToken(): Promise { - return invoke(TauriCommands.VALIDATE_HUGGINGFACE_TOKEN); - }, - - async listAnnotations( - meetingId: string, - startTime?: number, - endTime?: number - ): Promise { - return normalizeAnnotationList( - await invoke(TauriCommands.LIST_ANNOTATIONS, { - meeting_id: meetingId, - start_time: startTime ?? 0, - end_time: endTime ?? 0, - }) - ); - }, - async addAnnotation(request: AddAnnotationRequest): Promise { - return invoke(TauriCommands.ADD_ANNOTATION, { - meeting_id: request.meeting_id, - annotation_type: annotationTypeToGrpc(request.annotation_type), - text: request.text, - start_time: request.start_time, - end_time: request.end_time, - segment_ids: request.segment_ids ?? [], - }); - }, - async getAnnotation(annotationId: string): Promise { - return invoke(TauriCommands.GET_ANNOTATION, { annotation_id: annotationId }); - }, - async updateAnnotation(request: UpdateAnnotationRequest): Promise { - return invoke(TauriCommands.UPDATE_ANNOTATION, { - annotation_id: request.annotation_id, - annotation_type: request.annotation_type - ? annotationTypeToGrpc(request.annotation_type) - : undefined, - text: request.text, - start_time: request.start_time, - end_time: request.end_time, - segment_ids: request.segment_ids, - }); - }, - async deleteAnnotation(annotationId: string): Promise { - return normalizeSuccessResponse( - await invoke(TauriCommands.DELETE_ANNOTATION, { - annotation_id: annotationId, - }) - ); - }, - - async exportTranscript(meetingId: string, format: ExportFormat): Promise { - clientLog.exportStarted(meetingId, format); - try { - const result = await invoke(TauriCommands.EXPORT_TRANSCRIPT, { - meeting_id: meetingId, - format: exportFormatToGrpc(format), - }); - clientLog.exportCompleted(meetingId, format); - return result; - } catch (error) { - clientLog.exportFailed(meetingId, format, extractErrorMessage(error, 'Export failed')); - throw error; - } - }, - async saveExportFile( - content: string, - defaultName: string, - extension: string - ): Promise { - return invoke(TauriCommands.SAVE_EXPORT_FILE, { - content, - default_name: defaultName, - extension, - }); - }, - - async startPlayback(meetingId: string, startTime?: number): Promise { - await invoke(TauriCommands.START_PLAYBACK, { meeting_id: meetingId, start_time: startTime }); - }, - async pausePlayback(): Promise { - await invoke(TauriCommands.PAUSE_PLAYBACK); - }, - async stopPlayback(): Promise { - await invoke(TauriCommands.STOP_PLAYBACK); - }, - async seekPlayback(position: number): Promise { - return invoke(TauriCommands.SEEK_PLAYBACK, { position }); - }, - async getPlaybackState(): Promise { - return invoke(TauriCommands.GET_PLAYBACK_STATE); - }, - - async refineSpeakers(meetingId: string, numSpeakers?: number): Promise { - const status = await invoke(TauriCommands.REFINE_SPEAKERS, { - meeting_id: meetingId, - num_speakers: numSpeakers ?? 0, - }); - if (status?.job_id) { - clientLog.diarizationStarted(meetingId, status.job_id); - } - return status; - }, - async getDiarizationJobStatus(jobId: string): Promise { - return invoke(TauriCommands.GET_DIARIZATION_STATUS, { job_id: jobId }); - }, - async renameSpeaker( - meetingId: string, - oldSpeakerId: string, - newName: string - ): Promise { - const result = await invoke<{ success: boolean }>(TauriCommands.RENAME_SPEAKER, { - meeting_id: meetingId, - old_speaker_id: oldSpeakerId, - new_speaker_name: newName, - }); - if (result.success) { - clientLog.speakerRenamed(meetingId, oldSpeakerId, newName); - } - return result.success; - }, - async cancelDiarization(jobId: string): Promise { - return invoke(TauriCommands.CANCEL_DIARIZATION, { job_id: jobId }); - }, - async getActiveDiarizationJobs(): Promise { - return invoke(TauriCommands.GET_ACTIVE_DIARIZATION_JOBS); - }, - - async getPreferences(): Promise { - addClientLog({ - level: 'debug', - source: 'api', - message: 'TauriAdapter.getPreferences: calling invoke', - }); - const prefs = await invoke(TauriCommands.GET_PREFERENCES); - addClientLog({ - level: 'debug', - source: 'api', - message: 'TauriAdapter.getPreferences: received', - metadata: { - input: prefs.audio_devices?.input_device_id ?? 'UNDEFINED', - output: prefs.audio_devices?.output_device_id ?? 'UNDEFINED', - }, - }); - return prefs; - }, - async savePreferences(preferences: UserPreferences): Promise { - await invoke(TauriCommands.SAVE_PREFERENCES, { preferences }); - }, - - async listAudioDevices(): Promise { - return invoke(TauriCommands.LIST_AUDIO_DEVICES); - }, - async getDefaultAudioDevice(isInput: boolean): Promise { - return invoke(TauriCommands.GET_DEFAULT_AUDIO_DEVICE, { - is_input: isInput, - }); - }, - async selectAudioDevice(deviceId: string, isInput: boolean): Promise { - await invoke(TauriCommands.SELECT_AUDIO_DEVICE, { device_id: deviceId, is_input: isInput }); - }, - - // Dual capture (system audio) - async listLoopbackDevices(): Promise { - return invoke(TauriCommands.LIST_LOOPBACK_DEVICES); - }, - async setSystemAudioDevice(deviceId: string | null): Promise { - await invoke(TauriCommands.SET_SYSTEM_AUDIO_DEVICE, { device_id: deviceId }); - }, - async setDualCaptureEnabled(enabled: boolean): Promise { - await invoke(TauriCommands.SET_DUAL_CAPTURE_ENABLED, { enabled }); - }, - async setAudioMixLevels(micGain: number, systemGain: number): Promise { - await invoke(TauriCommands.SET_AUDIO_MIX_LEVELS, { - mic_gain: micGain, - system_gain: systemGain, - }); - }, - async getDualCaptureConfig(): Promise { - return invoke(TauriCommands.GET_DUAL_CAPTURE_CONFIG); - }, - - async checkTestEnvironment(): Promise { - const result = await invoke<{ - has_input_devices: boolean; - has_virtual_device: boolean; - input_devices: string[]; - is_server_connected: boolean; - can_run_audio_tests: boolean; - }>(TauriCommands.CHECK_TEST_ENVIRONMENT); - return { - hasInputDevices: result.has_input_devices, - hasVirtualDevice: result.has_virtual_device, - inputDevices: result.input_devices, - isServerConnected: result.is_server_connected, - canRunAudioTests: result.can_run_audio_tests, - }; - }, - async injectTestAudio(meetingId: string, config: TestAudioConfig): Promise { - const result = await invoke<{ - chunks_sent: number; - duration_seconds: number; - sample_rate: number; - }>(TauriCommands.INJECT_TEST_AUDIO, { - meeting_id: meetingId, - config: { - wav_path: config.wavPath, - speed: config.speed ?? 1.0, - chunk_ms: config.chunkMs ?? 100, - }, - }); - return { - chunksSent: result.chunks_sent, - durationSeconds: result.duration_seconds, - sampleRate: result.sample_rate, - }; - }, - async injectTestTone( - meetingId: string, - frequencyHz: number, - durationSeconds: number, - sampleRate?: number - ): Promise { - const result = await invoke<{ - chunks_sent: number; - duration_seconds: number; - sample_rate: number; - }>(TauriCommands.INJECT_TEST_TONE, { - meeting_id: meetingId, - frequency_hz: frequencyHz, - duration_seconds: durationSeconds, - sample_rate: sampleRate, - }); - return { - chunksSent: result.chunks_sent, - durationSeconds: result.duration_seconds, - sampleRate: result.sample_rate, - }; - }, - - async listInstalledApps( - options?: ListInstalledAppsRequest - ): Promise { - return invoke(TauriCommands.LIST_INSTALLED_APPS, { - common_only: options?.commonOnly ?? false, - page: options?.page ?? 0, - page_size: options?.pageSize ?? 50, - force_refresh: options?.forceRefresh ?? false, - }); - }, - async invalidateAppCache(): Promise { - await invoke(TauriCommands.INVALIDATE_APP_CACHE); - }, - - async setTriggerEnabled(enabled: boolean): Promise { - await invoke(TauriCommands.SET_TRIGGER_ENABLED, { enabled }); - }, - async snoozeTriggers(minutes?: number): Promise { - await invoke(TauriCommands.SNOOZE_TRIGGERS, { minutes }); - clientLog.triggersSnoozed(minutes); - }, - async resetSnooze(): Promise { - await invoke(TauriCommands.RESET_SNOOZE); - clientLog.triggerSnoozeCleared(); - }, - async getTriggerStatus(): Promise { - return invoke(TauriCommands.GET_TRIGGER_STATUS); - }, - async dismissTrigger(): Promise { - await invoke(TauriCommands.DISMISS_TRIGGER); - }, - async acceptTrigger(title?: string): Promise { - return invoke(TauriCommands.ACCEPT_TRIGGER, { title }); - }, - - async extractEntities( - meetingId: string, - forceRefresh?: boolean - ): Promise { - const response = await invoke(TauriCommands.EXTRACT_ENTITIES, { - meeting_id: meetingId, - force_refresh: forceRefresh ?? false, - }); - clientLog.entitiesExtracted(meetingId, response.entities?.length ?? 0); - return response; - }, - async updateEntity( - meetingId: string, - entityId: string, - text?: string, - category?: string - ): Promise { - return invoke(TauriCommands.UPDATE_ENTITY, { - meeting_id: meetingId, - entity_id: entityId, - text, - category, - }); - }, - async deleteEntity(meetingId: string, entityId: string): Promise { - return invoke(TauriCommands.DELETE_ENTITY, { - meeting_id: meetingId, - entity_id: entityId, - }); - }, - - async listCalendarEvents( - hoursAhead?: number, - limit?: number, - provider?: string - ): Promise { - return invoke(TauriCommands.LIST_CALENDAR_EVENTS, { - hours_ahead: hoursAhead, - limit, - provider, - }); - }, - async getCalendarProviders(): Promise { - return invoke(TauriCommands.GET_CALENDAR_PROVIDERS); - }, - async initiateCalendarAuth( - provider: string, - redirectUri?: string - ): Promise { - return invoke(TauriCommands.INITIATE_OAUTH, { - provider, - redirect_uri: redirectUri, - }); - }, - async completeCalendarAuth( - provider: string, - code: string, - state: string - ): Promise { - const response = await invoke(TauriCommands.COMPLETE_OAUTH, { - provider, - code, - state, - }); - clientLog.calendarConnected(provider); - return response; - }, - async getOAuthConnectionStatus(provider: string): Promise { - return invoke(TauriCommands.GET_OAUTH_CONNECTION_STATUS, { - provider, - }); - }, - async getOAuthClientConfig( - request: GetOAuthClientConfigRequest - ): Promise { - return invoke(TauriCommands.GET_OAUTH_CLIENT_CONFIG, { - provider: request.provider, - workspace_id: request.workspace_id, - integration_type: request.integration_type, - }); - }, - async setOAuthClientConfig( - request: SetOAuthClientConfigRequest - ): Promise { - return invoke(TauriCommands.SET_OAUTH_CLIENT_CONFIG, { - provider: request.provider, - workspace_id: request.workspace_id, - integration_type: request.integration_type, - config: request.config, - }); - }, - async disconnectCalendar(provider: string): Promise { - const response = await invoke(TauriCommands.DISCONNECT_OAUTH, { - provider, - }); - clientLog.calendarDisconnected(provider); - return response; - }, - - async registerWebhook(r: RegisterWebhookRequest): Promise { - const webhook = await invoke(TauriCommands.REGISTER_WEBHOOK, { - request: r, - }); - clientLog.webhookRegistered(webhook.id, webhook.name); - return webhook; - }, - async listWebhooks(enabledOnly?: boolean): Promise { - return invoke(TauriCommands.LIST_WEBHOOKS, { - enabled_only: enabledOnly ?? false, - }); - }, - async updateWebhook(r: UpdateWebhookRequest): Promise { - return invoke(TauriCommands.UPDATE_WEBHOOK, { request: r }); - }, - async deleteWebhook(webhookId: string): Promise { - const response = await invoke(TauriCommands.DELETE_WEBHOOK, { - webhook_id: webhookId, - }); - clientLog.webhookDeleted(webhookId); - return response; - }, - async getWebhookDeliveries( - webhookId: string, - limit?: number - ): Promise { - return invoke(TauriCommands.GET_WEBHOOK_DELIVERIES, { - webhook_id: webhookId, - limit: limit ?? 50, - }); - }, - - // Integration Sync (Sprint 9) - async startIntegrationSync(integrationId: string): Promise { - return invoke(TauriCommands.START_INTEGRATION_SYNC, { - integration_id: integrationId, - }); - }, - async getSyncStatus(syncRunId: string): Promise { - return invoke(TauriCommands.GET_SYNC_STATUS, { - sync_run_id: syncRunId, - }); - }, - async listSyncHistory( - integrationId: string, - limit?: number, - offset?: number - ): Promise { - return invoke(TauriCommands.LIST_SYNC_HISTORY, { - integration_id: integrationId, - limit, - offset, - }); - }, - async getUserIntegrations(): Promise { - return invoke(TauriCommands.GET_USER_INTEGRATIONS); - }, - - // Observability (Sprint 9) - async getRecentLogs(request?: GetRecentLogsRequest): Promise { - return invoke(TauriCommands.GET_RECENT_LOGS, { - limit: request?.limit, - level: request?.level, - source: request?.source, - }); - }, - async getPerformanceMetrics( - request?: GetPerformanceMetricsRequest - ): Promise { - return invoke(TauriCommands.GET_PERFORMANCE_METRICS, { - history_limit: request?.history_limit, - }); - }, - - // --- Diagnostics --- - - async runConnectionDiagnostics(): Promise { - return invoke(TauriCommands.RUN_CONNECTION_DIAGNOSTICS); - }, - - // --- OIDC Provider Management (Sprint 17) --- - - async registerOidcProvider(request: RegisterOidcProviderRequest): Promise { - return invoke(TauriCommands.REGISTER_OIDC_PROVIDER, { request }); - }, - - async listOidcProviders( - workspaceId?: string, - enabledOnly?: boolean - ): Promise { - return invoke(TauriCommands.LIST_OIDC_PROVIDERS, { - workspace_id: workspaceId, - enabled_only: enabledOnly ?? false, - }); - }, - - async getOidcProvider(providerId: string): Promise { - return invoke(TauriCommands.GET_OIDC_PROVIDER, { - provider_id: providerId, - }); - }, - - async updateOidcProvider(request: UpdateOidcProviderRequest): Promise { - return invoke(TauriCommands.UPDATE_OIDC_PROVIDER, { request }); - }, - - async deleteOidcProvider(providerId: string): Promise { - return invoke(TauriCommands.DELETE_OIDC_PROVIDER, { - provider_id: providerId, - }); - }, - - async refreshOidcDiscovery( - providerId?: string, - workspaceId?: string - ): Promise { - return invoke(TauriCommands.REFRESH_OIDC_DISCOVERY, { - provider_id: providerId, - workspace_id: workspaceId, - }); - }, - - async testOidcConnection(providerId: string): Promise { - return invoke(TauriCommands.TEST_OIDC_CONNECTION, { - provider_id: providerId, - }); - }, - - async listOidcPresets(): Promise { - return invoke(TauriCommands.LIST_OIDC_PRESETS); - }, - }; -} - -/** Check if running in a Tauri environment (synchronous hint). */ -export function isTauriEnvironment(): boolean { - if (typeof window === 'undefined') { - return false; - } - // Tauri 2.x injects __TAURI_INTERNALS__ into the window - // Only check for Tauri-injected globals, not our own globals like __NOTEFLOW_API__ - return '__TAURI_INTERNALS__' in window || '__TAURI__' in window || 'isTauri' in window; -} - -/** 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 - 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); - } catch (error) { - throw new Error( - `Not running in Tauri environment: ${extractErrorMessage(error, 'unknown error')}` - ); - } -} diff --git a/client/src/api/tauri-adapter/__tests__/core-mapping.test.ts b/client/src/api/tauri-adapter/__tests__/core-mapping.test.ts new file mode 100644 index 0000000..b4d51b4 --- /dev/null +++ b/client/src/api/tauri-adapter/__tests__/core-mapping.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { createTauriAPI, type TauriInvoke, type TauriListen } from '@/api/tauri-adapter'; +import { buildMeeting, createMocks } from './test-utils'; + +function createInvokeListenMocks() { + return createMocks(); +} + +describe('tauri-adapter mapping (core)', () => { + it('maps listMeetings args to snake_case', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValue({ meetings: [], total_count: 0 }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.listMeetings({ + states: ['recording'], + limit: 5, + offset: 10, + sort_order: 'newest', + }); + + expect(invoke).toHaveBeenCalledWith('list_meetings', { + states: [2], + limit: 5, + offset: 10, + sort_order: 1, + project_id: undefined, + project_ids: [], + }); + }); + + it('maps identity commands with expected payloads', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValueOnce({ user_id: 'u1', display_name: 'Local User' }); + invoke.mockResolvedValueOnce({ workspaces: [] }); + invoke.mockResolvedValueOnce({ success: true }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.getCurrentUser(); + await api.listWorkspaces(); + await api.switchWorkspace('w1'); + + expect(invoke).toHaveBeenCalledWith('get_current_user'); + expect(invoke).toHaveBeenCalledWith('list_workspaces'); + expect(invoke).toHaveBeenCalledWith('switch_workspace', { workspace_id: 'w1' }); + }); + + it('maps auth login commands with expected payloads', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state123' }); + invoke.mockResolvedValueOnce({ + success: true, + user_id: 'u1', + workspace_id: 'w1', + display_name: 'Test User', + email: 'test@example.com', + }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + + const authResult = await api.initiateAuthLogin('google', 'noteflow://callback'); + expect(authResult).toEqual({ auth_url: 'https://auth.example.com', state: 'state123' }); + expect(invoke).toHaveBeenCalledWith('initiate_auth_login', { + provider: 'google', + redirect_uri: 'noteflow://callback', + }); + + const completeResult = await api.completeAuthLogin('google', 'auth-code', 'state123'); + expect(completeResult.success).toBe(true); + expect(completeResult.user_id).toBe('u1'); + expect(invoke).toHaveBeenCalledWith('complete_auth_login', { + provider: 'google', + code: 'auth-code', + state: 'state123', + }); + }); + + it('maps initiateAuthLogin without redirect_uri', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValueOnce({ auth_url: 'https://auth.example.com', state: 'state456' }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.initiateAuthLogin('outlook'); + + expect(invoke).toHaveBeenCalledWith('initiate_auth_login', { + provider: 'outlook', + redirect_uri: undefined, + }); + }); + + it('maps logout command with optional provider', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke + .mockResolvedValueOnce({ success: true, tokens_revoked: true }) + .mockResolvedValueOnce({ success: true, tokens_revoked: false, revocation_error: 'timeout' }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + + // Logout specific provider + const result1 = await api.logout('google'); + expect(result1.success).toBe(true); + expect(result1.tokens_revoked).toBe(true); + expect(invoke).toHaveBeenCalledWith('logout', { provider: 'google' }); + + // Logout all providers + const result2 = await api.logout(); + expect(result2.success).toBe(true); + expect(result2.tokens_revoked).toBe(false); + expect(result2.revocation_error).toBe('timeout'); + expect(invoke).toHaveBeenCalledWith('logout', { provider: undefined }); + }); + + it('handles completeAuthLogin failure response', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValueOnce({ + success: false, + error_message: 'Invalid authorization code', + }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const result = await api.completeAuthLogin('google', 'bad-code', 'state'); + + expect(result.success).toBe(false); + expect(result.error_message).toBe('Invalid authorization code'); + expect(result.user_id).toBeUndefined(); + }); + + it('maps meeting and annotation args to snake_case', async () => { + const { invoke, listen } = createInvokeListenMocks(); + const meeting = buildMeeting('m1'); + invoke.mockResolvedValueOnce(meeting).mockResolvedValueOnce({ id: 'a1' }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.getMeeting({ meeting_id: 'm1', include_segments: true, include_summary: true }); + await api.addAnnotation({ + meeting_id: 'm1', + annotation_type: 'decision', + text: 'Ship it', + start_time: 1.25, + end_time: 2.5, + segment_ids: [1, 2], + }); + + expect(invoke).toHaveBeenCalledWith('get_meeting', { + meeting_id: 'm1', + include_segments: true, + include_summary: true, + }); + expect(invoke).toHaveBeenCalledWith('add_annotation', { + meeting_id: 'm1', + annotation_type: 2, + text: 'Ship it', + start_time: 1.25, + end_time: 2.5, + segment_ids: [1, 2], + }); + }); + + it('normalizes delete responses', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValueOnce({ success: true }).mockResolvedValueOnce(true); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await expect(api.deleteMeeting('m1')).resolves.toBe(true); + await expect(api.deleteAnnotation('a1')).resolves.toBe(true); + + expect(invoke).toHaveBeenCalledWith('delete_meeting', { meeting_id: 'm1' }); + expect(invoke).toHaveBeenCalledWith('delete_annotation', { annotation_id: 'a1' }); + }); +}); diff --git a/client/src/api/tauri-adapter/__tests__/environment.test.ts b/client/src/api/tauri-adapter/__tests__/environment.test.ts new file mode 100644 index 0000000..496182d --- /dev/null +++ b/client/src/api/tauri-adapter/__tests__/environment.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); +vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() })); + +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +import { initializeTauriAPI, isTauriEnvironment } from '@/api/tauri-adapter'; + +describe('tauri-adapter environment', () => { + const invokeMock = vi.mocked(invoke); + const listenMock = vi.mocked(listen); + + beforeEach(() => { + invokeMock.mockReset(); + listenMock.mockReset(); + }); + + it('detects tauri environment flags', () => { + vi.stubGlobal('window', undefined as unknown as Window); + expect(isTauriEnvironment()).toBe(false); + vi.unstubAllGlobals(); + expect(isTauriEnvironment()).toBe(false); + + window.__TAURI__ = {}; + expect(isTauriEnvironment()).toBe(true); + delete window.__TAURI__; + + window.__TAURI_INTERNALS__ = {}; + expect(isTauriEnvironment()).toBe(true); + delete window.__TAURI_INTERNALS__; + + window.isTauri = true; + expect(isTauriEnvironment()).toBe(true); + delete window.isTauri; + }); + + it('initializes tauri api when available', async () => { + invokeMock.mockResolvedValueOnce(true); + listenMock.mockResolvedValue(() => {}); + + const api = await initializeTauriAPI(); + expect(api).toBeDefined(); + expect(invokeMock).toHaveBeenCalledWith('is_connected'); + }); + + it('throws when tauri api is unavailable', async () => { + invokeMock.mockRejectedValueOnce(new Error('no tauri')); + + await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment'); + }); + + it('throws a helpful error when invoke rejects with non-Error', async () => { + invokeMock.mockRejectedValueOnce('no tauri'); + await expect(initializeTauriAPI()).rejects.toThrow('Not running in Tauri environment'); + }); +}); diff --git a/client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts b/client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts new file mode 100644 index 0000000..1185479 --- /dev/null +++ b/client/src/api/tauri-adapter/__tests__/misc-mapping.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriAPI, type TauriInvoke, type TauriListen } from '@/api/tauri-adapter'; +import { meetingCache } from '@/lib/cache/meeting-cache'; +import { buildMeeting, buildPreferences, buildSummary, createMocks } from './test-utils'; + +function createInvokeListenMocks() { + return createMocks(); +} + +describe('tauri-adapter mapping (misc)', () => { + it('maps connection and export commands with snake_case args', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValue({ version: '1.0.0' }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.connect('localhost:50051'); + await api.saveExportFile('content', 'Meeting Notes', 'md'); + + expect(invoke).toHaveBeenCalledWith('connect', { server_url: 'localhost:50051' }); + expect(invoke).toHaveBeenCalledWith('save_export_file', { + content: 'content', + default_name: 'Meeting Notes', + extension: 'md', + }); + }); + + it('maps audio device selection with snake_case args', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValue([]); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.listAudioDevices(); + await api.selectAudioDevice('input:0:Mic', true); + + expect(invoke).toHaveBeenCalledWith('list_audio_devices'); + expect(invoke).toHaveBeenCalledWith('select_audio_device', { + device_id: 'input:0:Mic', + is_input: true, + }); + }); + + it('maps playback commands with snake_case args', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValue({ + meeting_id: 'm1', + position: 0, + duration: 0, + is_playing: true, + is_paused: false, + }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.startPlayback('m1', 12.5); + await api.seekPlayback(30); + await api.getPlaybackState(); + + expect(invoke).toHaveBeenCalledWith('start_playback', { + meeting_id: 'm1', + start_time: 12.5, + }); + expect(invoke).toHaveBeenCalledWith('seek_playback', { position: 30 }); + expect(invoke).toHaveBeenCalledWith('get_playback_state'); + }); + + it('only caches meetings when list includes items', async () => { + const { invoke, listen } = createInvokeListenMocks(); + const cacheSpy = vi.spyOn(meetingCache, 'cacheMeetings'); + + invoke.mockResolvedValueOnce({ meetings: [], total_count: 0 }); + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + await api.listMeetings({}); + expect(cacheSpy).not.toHaveBeenCalled(); + + invoke.mockResolvedValueOnce({ meetings: [buildMeeting('m1')], total_count: 1 }); + await api.listMeetings({}); + expect(cacheSpy).toHaveBeenCalled(); + }); + + it('returns false when delete meeting fails', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValueOnce({ success: false }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const result = await api.deleteMeeting('m1'); + + expect(result).toBe(false); + }); + + it('generates summary with template options when available', async () => { + const { invoke, listen } = createInvokeListenMocks(); + const summary = buildSummary('m1'); + + invoke + .mockResolvedValueOnce( + buildPreferences({ tone: 'casual', format: 'narrative', verbosity: 'balanced' }) + ) + .mockResolvedValueOnce(summary); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const result = await api.generateSummary('m1', true); + + expect(result).toEqual(summary); + expect(invoke).toHaveBeenCalledWith('generate_summary', { + meeting_id: 'm1', + force_regenerate: true, + options: { tone: 'casual', format: 'narrative', verbosity: 'balanced' }, + }); + }); + + it('generates summary even if preferences lookup fails', async () => { + const { invoke, listen } = createInvokeListenMocks(); + const summary = buildSummary('m2'); + + invoke.mockRejectedValueOnce(new Error('no prefs')).mockResolvedValueOnce(summary); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const result = await api.generateSummary('m2'); + + expect(result).toEqual(summary); + expect(invoke).toHaveBeenCalledWith('generate_summary', { + meeting_id: 'm2', + force_regenerate: false, + options: undefined, + }); + }); + + it('covers additional adapter commands', async () => { + const { invoke, listen } = createInvokeListenMocks(); + + const annotation = { + id: 'a1', + meeting_id: 'm1', + annotation_type: 'note', + text: 'Note', + start_time: 0, + end_time: 1, + segment_ids: [], + created_at: 1, + }; + + const annotationResponses: Array< + (typeof annotation)[] | { annotations: (typeof annotation)[] } + > = [{ annotations: [annotation] }, [annotation]]; + + invoke.mockImplementation(async (cmd) => { + switch (cmd) { + case 'list_annotations': + return annotationResponses.shift(); + case 'get_annotation': + return annotation; + case 'update_annotation': + return annotation; + case 'export_transcript': + return { content: 'data', format_name: 'Markdown', file_extension: '.md' }; + case 'save_export_file': + return true; + case 'list_audio_devices': + return []; + case 'get_default_audio_device': + return null; + case 'get_preferences': + return buildPreferences(); + case 'get_cloud_consent_status': + return { consent_granted: true }; + case 'get_trigger_status': + return { + enabled: false, + is_snoozed: false, + snooze_remaining_secs: 0, + pending_trigger: null, + }; + case 'accept_trigger': + return buildMeeting('m9'); + case 'extract_entities': + return { entities: [], total_count: 0, cached: false }; + case 'update_entity': + return { id: 'e1', text: 'Entity', category: 'other', segment_ids: [], confidence: 1 }; + case 'delete_entity': + return true; + case 'list_calendar_events': + return { events: [], total_count: 0 }; + case 'get_calendar_providers': + return { providers: [] }; + case 'initiate_oauth': + return { auth_url: 'https://auth', state: 'state' }; + case 'complete_oauth': + return { success: true, error_message: '', integration_id: 'int-123' }; + case 'get_oauth_connection_status': + return { + connection: { + provider: 'google', + status: 'disconnected', + email: '', + expires_at: 0, + error_message: '', + integration_type: 'calendar', + }, + }; + case 'disconnect_oauth': + return { success: true }; + case 'register_webhook': + return { + id: 'w1', + workspace_id: 'w1', + name: 'Webhook', + url: 'https://example.com', + events: ['meeting.completed'], + enabled: true, + timeout_ms: 1000, + max_retries: 3, + created_at: 1, + updated_at: 1, + }; + case 'list_webhooks': + return { webhooks: [], total_count: 0 }; + case 'update_webhook': + return { + id: 'w1', + workspace_id: 'w1', + name: 'Webhook', + url: 'https://example.com', + events: ['meeting.completed'], + enabled: false, + timeout_ms: 1000, + max_retries: 3, + created_at: 1, + updated_at: 2, + }; + case 'delete_webhook': + return { success: true }; + case 'get_webhook_deliveries': + return { deliveries: [], total_count: 0 }; + case 'start_integration_sync': + return { sync_run_id: 's1', status: 'running' }; + case 'get_sync_status': + return { status: 'success', items_synced: 1, items_total: 1, error_message: '' }; + case 'list_sync_history': + return { runs: [], total_count: 0 }; + case 'get_recent_logs': + return { logs: [], total_count: 0 }; + case 'get_performance_metrics': + return { + current: { + timestamp: 1, + cpu_percent: 0, + memory_percent: 0, + memory_mb: 0, + disk_percent: 0, + network_bytes_sent: 0, + network_bytes_recv: 0, + process_memory_mb: 0, + active_connections: 0, + }, + history: [], + }; + case 'refine_speakers': + return { job_id: 'job', status: 'queued', segments_updated: 0, speaker_ids: [] }; + case 'get_diarization_status': + return { job_id: 'job', status: 'completed', segments_updated: 1, speaker_ids: [] }; + case 'rename_speaker': + return { success: true }; + case 'cancel_diarization': + return { success: true, error_message: '', status: 'cancelled' }; + default: + return undefined; + } + }); + + const api = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + + const list1 = await api.listAnnotations('m1'); + const list2 = await api.listAnnotations('m1'); + expect(list1).toHaveLength(1); + expect(list2).toHaveLength(1); + + await api.getAnnotation('a1'); + await api.updateAnnotation({ annotation_id: 'a1', text: 'Updated' }); + await api.exportTranscript('m1', 'markdown'); + await api.saveExportFile('content', 'Meeting', 'md'); + await api.listAudioDevices(); + await api.getDefaultAudioDevice(true); + await api.selectAudioDevice('mic', true); + await api.getPreferences(); + await api.savePreferences(buildPreferences()); + await api.grantCloudConsent(); + await api.revokeCloudConsent(); + await api.getCloudConsentStatus(); + await api.pausePlayback(); + await api.stopPlayback(); + await api.setTriggerEnabled(true); + await api.snoozeTriggers(5); + await api.resetSnooze(); + await api.getTriggerStatus(); + await api.dismissTrigger(); + await api.acceptTrigger('Title'); + await api.extractEntities('m1', true); + await api.updateEntity('m1', 'e1', 'Entity', 'other'); + await api.deleteEntity('m1', 'e1'); + await api.listCalendarEvents(2, 5, 'google'); + await api.getCalendarProviders(); + await api.initiateCalendarAuth('google', 'redirect'); + await api.completeCalendarAuth('google', 'code', 'state'); + await api.getOAuthConnectionStatus('google'); + await api.disconnectCalendar('google'); + await api.registerWebhook({ + workspace_id: 'w1', + name: 'Webhook', + url: 'https://example.com', + events: ['meeting.completed'], + }); + await api.listWebhooks(); + await api.updateWebhook({ webhook_id: 'w1', name: 'Webhook' }); + await api.deleteWebhook('w1'); + await api.getWebhookDeliveries('w1', 10); + await api.startIntegrationSync('int-1'); + await api.getSyncStatus('sync'); + await api.listSyncHistory('int-1', 10, 0); + await api.getRecentLogs({ limit: 10 }); + await api.getPerformanceMetrics({ history_limit: 5 }); + await api.refineSpeakers('m1', 2); + await api.getDiarizationJobStatus('job'); + await api.renameSpeaker('m1', 'old', 'new'); + await api.cancelDiarization('job'); + }); +}); diff --git a/client/src/api/tauri-adapter/__tests__/test-utils.ts b/client/src/api/tauri-adapter/__tests__/test-utils.ts new file mode 100644 index 0000000..ac64a10 --- /dev/null +++ b/client/src/api/tauri-adapter/__tests__/test-utils.ts @@ -0,0 +1,60 @@ +import { vi } from 'vitest'; +import type { Meeting, Summary, UserPreferences } from '../../types'; +import { defaultPreferences } from '@/lib/preferences/constants'; +import { clonePreferences } from '@/lib/preferences/core'; +import type { TranscriptionStream } from '../../interface'; + +export type InvokeMock = (cmd: string, args?: Record) => Promise; +export type ListenMock = ( + event: string, + handler: (event: { payload: unknown }) => void +) => Promise<() => void>; + +export function createMocks() { + const invoke = vi.fn, ReturnType>(); + const listen = vi + .fn, ReturnType>() + .mockResolvedValue(() => {}); + return { invoke, listen }; +} + +export function assertTranscriptionStream(value: unknown): asserts value is TranscriptionStream { + if (!value || typeof value !== 'object') { + throw new Error('Expected transcription stream'); + } + const record = value as Record; + if (typeof record.send !== 'function' || typeof record.onUpdate !== 'function') { + throw new Error('Expected transcription stream'); + } +} + +export function buildMeeting(id: string): Meeting { + return { + id, + title: `Meeting ${id}`, + state: 'created', + created_at: Date.now() / 1000, + duration_seconds: 0, + segments: [], + metadata: {}, + }; +} + +export function buildSummary(meetingId: string): Summary { + return { + meeting_id: meetingId, + executive_summary: 'Test summary', + key_points: [], + action_items: [], + model_version: 'test-v1', + generated_at: Date.now() / 1000, + }; +} + +export function buildPreferences(aiTemplate?: UserPreferences['ai_template']): UserPreferences { + const prefs = clonePreferences(defaultPreferences); + return { + ...prefs, + ai_template: aiTemplate ?? prefs.ai_template, + }; +} diff --git a/client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts b/client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts new file mode 100644 index 0000000..d2b49e8 --- /dev/null +++ b/client/src/api/tauri-adapter/__tests__/transcription-mapping.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTauriAPI, type TauriInvoke, type TauriListen } from '@/api/tauri-adapter'; +import type { NoteFlowAPI } from '../../interface'; +import type { AudioChunk, TranscriptUpdate } from '../../types'; +import { assertTranscriptionStream, createMocks } from './test-utils'; + +function createInvokeListenMocks() { + return createMocks(); +} + +describe('tauri-adapter mapping (transcription)', () => { + it('sends audio chunk with snake_case keys', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValue(undefined); + + const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const stream: unknown = await api.startTranscription('m1'); + assertTranscriptionStream(stream); + + const chunk: AudioChunk = { + meeting_id: 'm1', + audio_data: new Float32Array([0.25, -0.25]), + timestamp: 12.34, + sample_rate: 48000, + channels: 2, + }; + + stream.send(chunk); + + expect(invoke).toHaveBeenCalledWith('start_recording', { meeting_id: 'm1' }); + expect(invoke).toHaveBeenCalledWith('send_audio_chunk', { + meeting_id: 'm1', + audio_data: [0.25, -0.25], + timestamp: 12.34, + sample_rate: 48000, + channels: 2, + }); + }); + + it('sends audio chunk without optional fields', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValue(undefined); + + const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const stream: unknown = await api.startTranscription('m2'); + assertTranscriptionStream(stream); + + const chunk: AudioChunk = { + meeting_id: 'm2', + audio_data: new Float32Array([0.1]), + timestamp: 1.23, + }; + + stream.send(chunk); + + const call = invoke.mock.calls.find((item) => item[0] === 'send_audio_chunk'); + expect(call).toBeDefined(); + const args = call?.[1] as Record; + expect(args).toMatchObject({ + meeting_id: 'm2', + timestamp: 1.23, + }); + const audioData = args.audio_data as number[] | undefined; + expect(audioData).toHaveLength(1); + expect(audioData?.[0]).toBeCloseTo(0.1, 5); + }); + + it('forwards transcript updates with full segment payload', async () => { + let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null; + const invoke = vi + .fn, ReturnType>() + .mockResolvedValue(undefined); + const listen = vi + .fn, ReturnType>() + .mockImplementation((_event, handler) => { + capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void; + return Promise.resolve(() => {}); + }); + + const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const stream: unknown = await api.startTranscription('m1'); + assertTranscriptionStream(stream); + + const callback = vi.fn(); + await stream.onUpdate(callback); + + const payload: TranscriptUpdate = { + meeting_id: 'm1', + update_type: 'final', + partial_text: undefined, + segment: { + segment_id: 12, + text: 'Hello world', + start_time: 1.2, + end_time: 2.3, + words: [ + { word: 'Hello', start_time: 1.2, end_time: 1.6, probability: 0.9 }, + { word: 'world', start_time: 1.6, end_time: 2.3, probability: 0.92 }, + ], + language: 'en', + language_confidence: 0.99, + avg_logprob: -0.2, + no_speech_prob: 0.01, + speaker_id: 'SPEAKER_00', + speaker_confidence: 0.95, + }, + server_timestamp: 123.45, + }; + + if (!capturedHandler) { + throw new Error('Transcript update handler not registered'); + } + + capturedHandler({ payload }); + + expect(callback).toHaveBeenCalledWith(payload); + }); + + it('ignores transcript updates for other meetings', async () => { + let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null; + const invoke = vi + .fn, ReturnType>() + .mockResolvedValue(undefined); + const listen = vi + .fn, ReturnType>() + .mockImplementation((_event, handler) => { + capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void; + return Promise.resolve(() => {}); + }); + + const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const stream: unknown = await api.startTranscription('m1'); + assertTranscriptionStream(stream); + const callback = vi.fn(); + await stream.onUpdate(callback); + + capturedHandler?.({ + payload: { + meeting_id: 'other', + update_type: 'partial', + partial_text: 'nope', + server_timestamp: 1, + }, + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('stops transcription stream on close', async () => { + const { invoke, listen } = createInvokeListenMocks(); + const unlisten = vi.fn(); + listen.mockResolvedValueOnce(unlisten); + invoke.mockResolvedValue(undefined); + + const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const stream: unknown = await api.startTranscription('m1'); + assertTranscriptionStream(stream); + + await stream.onUpdate(() => {}); + stream.close(); + + expect(unlisten).toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' }); + }); + + 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; + const unlisten = vi.fn(); + const invoke = vi + .fn, ReturnType>() + .mockResolvedValue(undefined); + const listen = vi + .fn, ReturnType>() + .mockImplementation((_event, handler) => { + capturedHandler = handler as (event: { payload: TranscriptUpdate }) => void; + return new Promise<() => void>((resolve) => { + resolveListen = resolve; + }); + }); + + const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const stream: unknown = await api.startTranscription('m1'); + assertTranscriptionStream(stream); + + const callback = vi.fn(); + const onUpdatePromise = stream.onUpdate(callback); + + stream.close(); + resolveListen?.(unlisten); + await onUpdatePromise; + + expect(unlisten).toHaveBeenCalled(); + + if (!capturedHandler) { + throw new Error('Transcript update handler not registered'); + } + + capturedHandler({ + payload: { + meeting_id: 'm1', + update_type: 'partial', + partial_text: 'late update', + server_timestamp: 1, + }, + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('stops transcription stream even without listeners', async () => { + const { invoke, listen } = createInvokeListenMocks(); + invoke.mockResolvedValue(undefined); + + const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen); + const stream: unknown = await api.startTranscription('m1'); + assertTranscriptionStream(stream); + stream.close(); + + expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' }); + }); +}); diff --git a/client/src/api/tauri-adapter/api.ts b/client/src/api/tauri-adapter/api.ts new file mode 100644 index 0000000..c44c2e1 --- /dev/null +++ b/client/src/api/tauri-adapter/api.ts @@ -0,0 +1,46 @@ +import type { NoteFlowAPI } from '../interface'; +import type { TauriInvoke, TauriListen } from './types'; +import { createAnnotationApi } from './sections/annotations'; +import { createAppsApi } from './sections/apps'; +import { createAsrApi } from './sections/asr'; +import { createAudioApi } from './sections/audio'; +import { createCalendarApi } from './sections/calendar'; +import { createCoreApi } from './sections/core'; +import { createDiarizationApi } from './sections/diarization'; +import { createEntityApi } from './sections/entities'; +import { createExportApi } from './sections/exporting'; +import { createIntegrationApi } from './sections/integrations'; +import { createMeetingApi } from './sections/meetings'; +import { createObservabilityApi } from './sections/observability'; +import { createOidcApi } from './sections/oidc'; +import { createPlaybackApi } from './sections/playback'; +import { createPreferencesApi } from './sections/preferences'; +import { createProjectApi } from './sections/projects'; +import { createSummarizationApi } from './sections/summarization'; +import { createTriggerApi } from './sections/triggers'; +import { createWebhookApi } from './sections/webhooks'; + +/** Creates a Tauri API adapter instance. */ +export function createTauriAPI(invoke: TauriInvoke, listen: TauriListen): NoteFlowAPI { + return { + ...createCoreApi(invoke), + ...createProjectApi(invoke), + ...createMeetingApi(invoke, listen), + ...createSummarizationApi(invoke), + ...createAsrApi(invoke), + ...createAnnotationApi(invoke), + ...createExportApi(invoke), + ...createPlaybackApi(invoke), + ...createDiarizationApi(invoke), + ...createPreferencesApi(invoke), + ...createAudioApi(invoke), + ...createAppsApi(invoke), + ...createTriggerApi(invoke), + ...createEntityApi(invoke), + ...createCalendarApi(invoke), + ...createWebhookApi(invoke), + ...createIntegrationApi(invoke), + ...createObservabilityApi(invoke), + ...createOidcApi(invoke), + }; +} diff --git a/client/src/api/tauri-adapter/environment.ts b/client/src/api/tauri-adapter/environment.ts new file mode 100644 index 0000000..f980346 --- /dev/null +++ b/client/src/api/tauri-adapter/environment.ts @@ -0,0 +1,29 @@ +import type { NoteFlowAPI } from '../interface'; +import { extractErrorMessage } from '../helpers'; +import { createTauriAPI } from './api'; + +/** Check if running in a Tauri environment (synchronous hint). */ +export function isTauriEnvironment(): boolean { + if (typeof window === 'undefined') { + return false; + } + // Tauri 2.x injects __TAURI_INTERNALS__ into the window + // Only check for Tauri-injected globals, not our own globals like __NOTEFLOW_API__ + return '__TAURI_INTERNALS__' in window || '__TAURI__' in window || 'isTauri' in window; +} + +/** 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 + 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); + } catch (error) { + throw new Error( + `Not running in Tauri environment: ${extractErrorMessage(error, 'unknown error')}` + ); + } +} diff --git a/client/src/api/tauri-adapter/index.ts b/client/src/api/tauri-adapter/index.ts new file mode 100644 index 0000000..f37a6db --- /dev/null +++ b/client/src/api/tauri-adapter/index.ts @@ -0,0 +1,15 @@ +export { createTauriAPI } from './api'; +export { initializeTauriAPI, isTauriEnvironment } from './environment'; +export { + CONGESTION_DISPLAY_THRESHOLD_MS, + CONSECUTIVE_FAILURE_THRESHOLD, + TauriTranscriptionStream, +} from './stream'; +export type { + CongestionCallback, + CongestionState, + StreamErrorCallback, + TauriInvoke, + TauriListen, +} from './types'; +export { TauriEvents } from '../tauri-constants'; diff --git a/client/src/api/tauri-adapter/sections/annotations.ts b/client/src/api/tauri-adapter/sections/annotations.ts new file mode 100644 index 0000000..f7e65fe --- /dev/null +++ b/client/src/api/tauri-adapter/sections/annotations.ts @@ -0,0 +1,66 @@ +import type { + AddAnnotationRequest, + Annotation, + UpdateAnnotationRequest, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { annotationTypeToGrpc, normalizeAnnotationList, normalizeSuccessResponse } from '../../helpers'; +import type { TauriInvoke } from '../types'; + +export function createAnnotationApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'listAnnotations' + | 'addAnnotation' + | 'getAnnotation' + | 'updateAnnotation' + | 'deleteAnnotation' +> { + return { + async listAnnotations( + meetingId: string, + startTime?: number, + endTime?: number + ): Promise { + return normalizeAnnotationList( + await invoke(TauriCommands.LIST_ANNOTATIONS, { + meeting_id: meetingId, + start_time: startTime ?? 0, + end_time: endTime ?? 0, + }) + ); + }, + async addAnnotation(request: AddAnnotationRequest): Promise { + return invoke(TauriCommands.ADD_ANNOTATION, { + meeting_id: request.meeting_id, + annotation_type: annotationTypeToGrpc(request.annotation_type), + text: request.text, + start_time: request.start_time, + end_time: request.end_time, + segment_ids: request.segment_ids ?? [], + }); + }, + async getAnnotation(annotationId: string): Promise { + return invoke(TauriCommands.GET_ANNOTATION, { annotation_id: annotationId }); + }, + async updateAnnotation(request: UpdateAnnotationRequest): Promise { + return invoke(TauriCommands.UPDATE_ANNOTATION, { + annotation_id: request.annotation_id, + annotation_type: request.annotation_type + ? annotationTypeToGrpc(request.annotation_type) + : undefined, + text: request.text, + start_time: request.start_time, + end_time: request.end_time, + segment_ids: request.segment_ids, + }); + }, + async deleteAnnotation(annotationId: string): Promise { + return normalizeSuccessResponse( + await invoke(TauriCommands.DELETE_ANNOTATION, { + annotation_id: annotationId, + }) + ); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/apps.ts b/client/src/api/tauri-adapter/sections/apps.ts new file mode 100644 index 0000000..ca98a95 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/apps.ts @@ -0,0 +1,25 @@ +import type { ListInstalledAppsRequest, ListInstalledAppsResponse } from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import type { TauriInvoke } from '../types'; + +export function createAppsApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'listInstalledApps' | 'invalidateAppCache' +> { + return { + async listInstalledApps( + options?: ListInstalledAppsRequest + ): Promise { + return invoke(TauriCommands.LIST_INSTALLED_APPS, { + common_only: options?.commonOnly ?? false, + page: options?.page ?? 0, + page_size: options?.pageSize ?? 50, + force_refresh: options?.forceRefresh ?? false, + }); + }, + async invalidateAppCache(): Promise { + await invoke(TauriCommands.INVALIDATE_APP_CACHE); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/asr.ts b/client/src/api/tauri-adapter/sections/asr.ts new file mode 100644 index 0000000..45f2684 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/asr.ts @@ -0,0 +1,80 @@ +import type { + ASRConfiguration, + ASRConfigurationJobStatus, + HuggingFaceTokenStatus, + SetHuggingFaceTokenRequest, + SetHuggingFaceTokenResult, + StreamingConfiguration, + UpdateASRConfigurationRequest, + UpdateASRConfigurationResult, + UpdateStreamingConfigurationRequest, + ValidateHuggingFaceTokenResult, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import type { TauriInvoke } from '../types'; + +export function createAsrApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'getAsrConfiguration' + | 'updateAsrConfiguration' + | 'getAsrJobStatus' + | 'getStreamingConfiguration' + | 'updateStreamingConfiguration' + | 'setHuggingFaceToken' + | 'getHuggingFaceTokenStatus' + | 'deleteHuggingFaceToken' + | 'validateHuggingFaceToken' +> { + return { + async getAsrConfiguration(): Promise { + return invoke(TauriCommands.GET_ASR_CONFIGURATION); + }, + + async updateAsrConfiguration( + request: UpdateASRConfigurationRequest + ): Promise { + return invoke(TauriCommands.UPDATE_ASR_CONFIGURATION, { + request, + }); + }, + + async getAsrJobStatus(jobId: string): Promise { + return invoke(TauriCommands.GET_ASR_JOB_STATUS, { + job_id: jobId, + }); + }, + + async getStreamingConfiguration(): Promise { + return invoke(TauriCommands.GET_STREAMING_CONFIGURATION); + }, + + async updateStreamingConfiguration( + request: UpdateStreamingConfigurationRequest + ): Promise { + return invoke(TauriCommands.UPDATE_STREAMING_CONFIGURATION, { + request, + }); + }, + + async setHuggingFaceToken( + request: SetHuggingFaceTokenRequest + ): Promise { + return invoke(TauriCommands.SET_HUGGINGFACE_TOKEN, { + request, + }); + }, + + async getHuggingFaceTokenStatus(): Promise { + return invoke(TauriCommands.GET_HUGGINGFACE_TOKEN_STATUS); + }, + + async deleteHuggingFaceToken(): Promise { + return invoke(TauriCommands.DELETE_HUGGINGFACE_TOKEN); + }, + + async validateHuggingFaceToken(): Promise { + return invoke(TauriCommands.VALIDATE_HUGGINGFACE_TOKEN); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/audio.ts b/client/src/api/tauri-adapter/sections/audio.ts new file mode 100644 index 0000000..4653d52 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/audio.ts @@ -0,0 +1,117 @@ +import type { + AudioDeviceInfo, + DualCaptureConfigInfo, + TestAudioConfig, + TestAudioResult, + TestEnvironmentInfo, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import type { TauriInvoke } from '../types'; + +export function createAudioApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'listAudioDevices' + | 'getDefaultAudioDevice' + | 'selectAudioDevice' + | 'listLoopbackDevices' + | 'setSystemAudioDevice' + | 'setDualCaptureEnabled' + | 'setAudioMixLevels' + | 'getDualCaptureConfig' + | 'checkTestEnvironment' + | 'injectTestAudio' + | 'injectTestTone' +> { + return { + async listAudioDevices(): Promise { + return invoke(TauriCommands.LIST_AUDIO_DEVICES); + }, + async getDefaultAudioDevice(isInput: boolean): Promise { + return invoke(TauriCommands.GET_DEFAULT_AUDIO_DEVICE, { + is_input: isInput, + }); + }, + async selectAudioDevice(deviceId: string, isInput: boolean): Promise { + await invoke(TauriCommands.SELECT_AUDIO_DEVICE, { device_id: deviceId, is_input: isInput }); + }, + + // Dual capture (system audio) + async listLoopbackDevices(): Promise { + return invoke(TauriCommands.LIST_LOOPBACK_DEVICES); + }, + async setSystemAudioDevice(deviceId: string | null): Promise { + await invoke(TauriCommands.SET_SYSTEM_AUDIO_DEVICE, { device_id: deviceId }); + }, + async setDualCaptureEnabled(enabled: boolean): Promise { + await invoke(TauriCommands.SET_DUAL_CAPTURE_ENABLED, { enabled }); + }, + async setAudioMixLevels(micGain: number, systemGain: number): Promise { + await invoke(TauriCommands.SET_AUDIO_MIX_LEVELS, { + mic_gain: micGain, + system_gain: systemGain, + }); + }, + async getDualCaptureConfig(): Promise { + return invoke(TauriCommands.GET_DUAL_CAPTURE_CONFIG); + }, + + async checkTestEnvironment(): Promise { + const result = await invoke<{ + has_input_devices: boolean; + has_virtual_device: boolean; + input_devices: string[]; + is_server_connected: boolean; + can_run_audio_tests: boolean; + }>(TauriCommands.CHECK_TEST_ENVIRONMENT); + return { + hasInputDevices: result.has_input_devices, + hasVirtualDevice: result.has_virtual_device, + inputDevices: result.input_devices, + isServerConnected: result.is_server_connected, + canRunAudioTests: result.can_run_audio_tests, + }; + }, + async injectTestAudio(meetingId: string, config: TestAudioConfig): Promise { + const result = await invoke<{ + chunks_sent: number; + duration_seconds: number; + sample_rate: number; + }>(TauriCommands.INJECT_TEST_AUDIO, { + meeting_id: meetingId, + config: { + wav_path: config.wavPath, + speed: config.speed ?? 1.0, + chunk_ms: config.chunkMs ?? 100, + }, + }); + return { + chunksSent: result.chunks_sent, + durationSeconds: result.duration_seconds, + sampleRate: result.sample_rate, + }; + }, + async injectTestTone( + meetingId: string, + frequencyHz: number, + durationSeconds: number, + sampleRate?: number + ): Promise { + const result = await invoke<{ + chunks_sent: number; + duration_seconds: number; + sample_rate: number; + }>(TauriCommands.INJECT_TEST_TONE, { + meeting_id: meetingId, + frequency_hz: frequencyHz, + duration_seconds: durationSeconds, + sample_rate: sampleRate, + }); + return { + chunksSent: result.chunks_sent, + durationSeconds: result.duration_seconds, + sampleRate: result.sample_rate, + }; + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/calendar.ts b/client/src/api/tauri-adapter/sections/calendar.ts new file mode 100644 index 0000000..5cc4fd0 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/calendar.ts @@ -0,0 +1,98 @@ +import type { + CompleteCalendarAuthResponse, + DisconnectOAuthResponse, + GetCalendarProvidersResponse, + GetOAuthClientConfigRequest, + GetOAuthClientConfigResponse, + GetOAuthConnectionStatusResponse, + InitiateCalendarAuthResponse, + ListCalendarEventsResponse, + SetOAuthClientConfigRequest, + SetOAuthClientConfigResponse, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createCalendarApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'listCalendarEvents' + | 'getCalendarProviders' + | 'initiateCalendarAuth' + | 'completeCalendarAuth' + | 'getOAuthConnectionStatus' + | 'getOAuthClientConfig' + | 'setOAuthClientConfig' + | 'disconnectCalendar' +> { + return { + async listCalendarEvents( + hoursAhead?: number, + limit?: number, + provider?: string + ): Promise { + return invoke(TauriCommands.LIST_CALENDAR_EVENTS, { + hours_ahead: hoursAhead, + limit, + provider, + }); + }, + async getCalendarProviders(): Promise { + return invoke(TauriCommands.GET_CALENDAR_PROVIDERS); + }, + async initiateCalendarAuth( + provider: string, + redirectUri?: string + ): Promise { + return invoke(TauriCommands.INITIATE_OAUTH, { + provider, + redirect_uri: redirectUri, + }); + }, + async completeCalendarAuth( + provider: string, + code: string, + state: string + ): Promise { + const response = await invoke(TauriCommands.COMPLETE_OAUTH, { + provider, + code, + state, + }); + clientLog.calendarConnected(provider); + return response; + }, + async getOAuthConnectionStatus(provider: string): Promise { + return invoke(TauriCommands.GET_OAUTH_CONNECTION_STATUS, { + provider, + }); + }, + async getOAuthClientConfig( + request: GetOAuthClientConfigRequest + ): Promise { + return invoke(TauriCommands.GET_OAUTH_CLIENT_CONFIG, { + provider: request.provider, + workspace_id: request.workspace_id, + integration_type: request.integration_type, + }); + }, + async setOAuthClientConfig( + request: SetOAuthClientConfigRequest + ): Promise { + return invoke(TauriCommands.SET_OAUTH_CLIENT_CONFIG, { + provider: request.provider, + workspace_id: request.workspace_id, + integration_type: request.integration_type, + config: request.config, + }); + }, + async disconnectCalendar(provider: string): Promise { + const response = await invoke(TauriCommands.DISCONNECT_OAUTH, { + provider, + }); + clientLog.calendarDisconnected(provider); + return response; + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/core.ts b/client/src/api/tauri-adapter/sections/core.ts new file mode 100644 index 0000000..92e499d --- /dev/null +++ b/client/src/api/tauri-adapter/sections/core.ts @@ -0,0 +1,116 @@ +import type { + CompleteAuthLoginResponse, + EffectiveServerUrl, + GetCurrentUserResponse, + GetWorkspaceSettingsRequest, + GetWorkspaceSettingsResponse, + InitiateAuthLoginResponse, + ListWorkspacesResponse, + LogoutResponse, + ServerInfo, + SwitchWorkspaceResponse, + UpdateWorkspaceSettingsRequest, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { extractErrorMessage } from '../../helpers'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createCoreApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'getServerInfo' + | 'connect' + | 'disconnect' + | 'isConnected' + | 'getEffectiveServerUrl' + | 'getCurrentUser' + | 'listWorkspaces' + | 'switchWorkspace' + | 'getWorkspaceSettings' + | 'updateWorkspaceSettings' + | 'initiateAuthLogin' + | 'completeAuthLogin' + | 'logout' +> { + return { + async getServerInfo(): Promise { + return invoke(TauriCommands.GET_SERVER_INFO); + }, + async connect(serverUrl?: string): Promise { + try { + const info = await invoke(TauriCommands.CONNECT, { server_url: serverUrl }); + clientLog.connected(serverUrl); + return info; + } catch (error) { + clientLog.connectionFailed(extractErrorMessage(error, 'Connection failed')); + throw error; + } + }, + async disconnect(): Promise { + await invoke(TauriCommands.DISCONNECT); + clientLog.disconnected(); + }, + async isConnected(): Promise { + return invoke(TauriCommands.IS_CONNECTED); + }, + async getEffectiveServerUrl(): Promise { + return invoke(TauriCommands.GET_EFFECTIVE_SERVER_URL); + }, + async getCurrentUser(): Promise { + return invoke(TauriCommands.GET_CURRENT_USER); + }, + async listWorkspaces(): Promise { + return invoke(TauriCommands.LIST_WORKSPACES); + }, + async switchWorkspace(workspaceId: string): Promise { + return invoke(TauriCommands.SWITCH_WORKSPACE, { + workspace_id: workspaceId, + }); + }, + async getWorkspaceSettings( + request: GetWorkspaceSettingsRequest + ): Promise { + return invoke(TauriCommands.GET_WORKSPACE_SETTINGS, { + workspace_id: request.workspace_id, + }); + }, + async updateWorkspaceSettings( + request: UpdateWorkspaceSettingsRequest + ): Promise { + return invoke(TauriCommands.UPDATE_WORKSPACE_SETTINGS, { + workspace_id: request.workspace_id, + settings: request.settings, + }); + }, + async initiateAuthLogin( + provider: string, + redirectUri?: string + ): Promise { + return invoke(TauriCommands.INITIATE_AUTH_LOGIN, { + provider, + redirect_uri: redirectUri, + }); + }, + async completeAuthLogin( + provider: string, + code: string, + state: string + ): Promise { + const response = await invoke(TauriCommands.COMPLETE_AUTH_LOGIN, { + provider, + code, + state, + }); + clientLog.loginCompleted(provider); + return response; + }, + async logout(provider?: string): Promise { + const response = await invoke(TauriCommands.LOGOUT, { + provider, + }); + clientLog.loggedOut(provider); + return response; + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/diarization.ts b/client/src/api/tauri-adapter/sections/diarization.ts new file mode 100644 index 0000000..5b38904 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/diarization.ts @@ -0,0 +1,51 @@ +import type { CancelDiarizationResult, DiarizationJobStatus } from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createDiarizationApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'refineSpeakers' + | 'getDiarizationJobStatus' + | 'renameSpeaker' + | 'cancelDiarization' + | 'getActiveDiarizationJobs' +> { + return { + async refineSpeakers(meetingId: string, numSpeakers?: number): Promise { + const status = await invoke(TauriCommands.REFINE_SPEAKERS, { + meeting_id: meetingId, + num_speakers: numSpeakers ?? 0, + }); + if (status?.job_id) { + clientLog.diarizationStarted(meetingId, status.job_id); + } + return status; + }, + async getDiarizationJobStatus(jobId: string): Promise { + return invoke(TauriCommands.GET_DIARIZATION_STATUS, { job_id: jobId }); + }, + async renameSpeaker( + meetingId: string, + oldSpeakerId: string, + newName: string + ): Promise { + const result = await invoke<{ success: boolean }>(TauriCommands.RENAME_SPEAKER, { + meeting_id: meetingId, + old_speaker_id: oldSpeakerId, + new_speaker_name: newName, + }); + if (result.success) { + clientLog.speakerRenamed(meetingId, oldSpeakerId, newName); + } + return result.success; + }, + async cancelDiarization(jobId: string): Promise { + return invoke(TauriCommands.CANCEL_DIARIZATION, { job_id: jobId }); + }, + async getActiveDiarizationJobs(): Promise { + return invoke(TauriCommands.GET_ACTIVE_DIARIZATION_JOBS); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/entities.ts b/client/src/api/tauri-adapter/sections/entities.ts new file mode 100644 index 0000000..904ff76 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/entities.ts @@ -0,0 +1,43 @@ +import type { ExtractedEntity, ExtractEntitiesResponse } from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createEntityApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'extractEntities' | 'updateEntity' | 'deleteEntity' +> { + return { + async extractEntities( + meetingId: string, + forceRefresh?: boolean + ): Promise { + const response = await invoke(TauriCommands.EXTRACT_ENTITIES, { + meeting_id: meetingId, + force_refresh: forceRefresh ?? false, + }); + clientLog.entitiesExtracted(meetingId, response.entities?.length ?? 0); + return response; + }, + async updateEntity( + meetingId: string, + entityId: string, + text?: string, + category?: string + ): Promise { + return invoke(TauriCommands.UPDATE_ENTITY, { + meeting_id: meetingId, + entity_id: entityId, + text, + category, + }); + }, + async deleteEntity(meetingId: string, entityId: string): Promise { + return invoke(TauriCommands.DELETE_ENTITY, { + meeting_id: meetingId, + entity_id: entityId, + }); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/exporting.ts b/client/src/api/tauri-adapter/sections/exporting.ts new file mode 100644 index 0000000..95d2f8e --- /dev/null +++ b/client/src/api/tauri-adapter/sections/exporting.ts @@ -0,0 +1,39 @@ +import type { ExportFormat, ExportResult } from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { exportFormatToGrpc, extractErrorMessage } from '../../helpers'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createExportApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'exportTranscript' | 'saveExportFile' +> { + return { + async exportTranscript(meetingId: string, format: ExportFormat): Promise { + clientLog.exportStarted(meetingId, format); + try { + const result = await invoke(TauriCommands.EXPORT_TRANSCRIPT, { + meeting_id: meetingId, + format: exportFormatToGrpc(format), + }); + clientLog.exportCompleted(meetingId, format); + return result; + } catch (error) { + clientLog.exportFailed(meetingId, format, extractErrorMessage(error, 'Export failed')); + throw error; + } + }, + async saveExportFile( + content: string, + defaultName: string, + extension: string + ): Promise { + return invoke(TauriCommands.SAVE_EXPORT_FILE, { + content, + default_name: defaultName, + extension, + }); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/integrations.ts b/client/src/api/tauri-adapter/sections/integrations.ts new file mode 100644 index 0000000..334670a --- /dev/null +++ b/client/src/api/tauri-adapter/sections/integrations.ts @@ -0,0 +1,41 @@ +import type { + GetSyncStatusResponse, + GetUserIntegrationsResponse, + ListSyncHistoryResponse, + StartIntegrationSyncResponse, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import type { TauriInvoke } from '../types'; + +export function createIntegrationApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'startIntegrationSync' | 'getSyncStatus' | 'listSyncHistory' | 'getUserIntegrations' +> { + return { + async startIntegrationSync(integrationId: string): Promise { + return invoke(TauriCommands.START_INTEGRATION_SYNC, { + integration_id: integrationId, + }); + }, + async getSyncStatus(syncRunId: string): Promise { + return invoke(TauriCommands.GET_SYNC_STATUS, { + sync_run_id: syncRunId, + }); + }, + async listSyncHistory( + integrationId: string, + limit?: number, + offset?: number + ): Promise { + return invoke(TauriCommands.LIST_SYNC_HISTORY, { + integration_id: integrationId, + limit, + offset, + }); + }, + async getUserIntegrations(): Promise { + return invoke(TauriCommands.GET_USER_INTEGRATIONS); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/meetings.ts b/client/src/api/tauri-adapter/sections/meetings.ts new file mode 100644 index 0000000..d15731b --- /dev/null +++ b/client/src/api/tauri-adapter/sections/meetings.ts @@ -0,0 +1,181 @@ +import type { + CreateMeetingRequest, + GetMeetingRequest, + ListMeetingsRequest, + ListMeetingsResponse, + Meeting, + StreamStateInfo, + Summary, + SummarizationOptions, + UserPreferences, +} from '../../types'; +import type { NoteFlowAPI, TranscriptionStream } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { + extractErrorDetails, + extractErrorMessage, + normalizeSuccessResponse, + sortOrderToGrpcEnum, + stateToGrpcEnum, +} from '../../helpers'; +import { meetingCache } from '@/lib/cache/meeting-cache'; +import { addClientLog } from '@/lib/client-logs'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke, TauriListen } from '../types'; +import { TauriTranscriptionStream } from '../stream'; +import { + normalizeProjectId, + normalizeProjectIds, + recordingBlockedDetails, + RECORDING_BLOCKED_PREFIX, +} from '../utils'; + +export function createMeetingApi( + invoke: TauriInvoke, + listen: TauriListen +): Pick< + NoteFlowAPI, + | 'createMeeting' + | 'listMeetings' + | 'getMeeting' + | 'stopMeeting' + | 'deleteMeeting' + | 'startTranscription' + | 'getStreamState' + | 'resetStreamState' + | 'generateSummary' +> { + return { + async createMeeting(request: CreateMeetingRequest): Promise { + const meeting = await invoke(TauriCommands.CREATE_MEETING, { + title: request.title, + metadata: request.metadata ?? {}, + project_id: normalizeProjectId(request.project_id), + }); + meetingCache.cacheMeeting(meeting); + clientLog.meetingCreated(meeting.id, meeting.title); + return meeting; + }, + async listMeetings(request: ListMeetingsRequest): Promise { + const response = await invoke(TauriCommands.LIST_MEETINGS, { + states: request.states?.map(stateToGrpcEnum) ?? [], + limit: request.limit ?? 50, + offset: request.offset ?? 0, + sort_order: sortOrderToGrpcEnum(request.sort_order), + project_id: normalizeProjectId(request.project_id), + project_ids: normalizeProjectIds(request.project_ids ?? []), + }); + if (response.meetings?.length) { + meetingCache.cacheMeetings(response.meetings); + } + return response; + }, + async getMeeting(request: GetMeetingRequest): Promise { + const meeting = await invoke(TauriCommands.GET_MEETING, { + meeting_id: request.meeting_id, + include_segments: request.include_segments ?? false, + include_summary: request.include_summary ?? false, + }); + meetingCache.cacheMeeting(meeting); + return meeting; + }, + async stopMeeting(meetingId: string): Promise { + const meeting = await invoke(TauriCommands.STOP_MEETING, { + meeting_id: meetingId, + }); + meetingCache.cacheMeeting(meeting); + clientLog.meetingStopped(meeting.id, meeting.title); + return meeting; + }, + async deleteMeeting(meetingId: string): Promise { + const result = normalizeSuccessResponse( + await invoke(TauriCommands.DELETE_MEETING, { + meeting_id: meetingId, + }) + ); + if (result) { + meetingCache.removeMeeting(meetingId); + clientLog.meetingDeleted(meetingId); + } + return result; + }, + + async startTranscription(meetingId: string): Promise { + try { + await invoke(TauriCommands.START_RECORDING, { meeting_id: meetingId }); + return new TauriTranscriptionStream(meetingId, invoke, listen); + } catch (error) { + const details = extractErrorDetails(error, 'Failed to start recording'); + clientLog.recordingStartFailed( + meetingId, + details.message, + details.grpcStatus, + details.category, + details.retryable + ); + const blocked = recordingBlockedDetails(error); + if (blocked) { + addClientLog({ + level: 'warning', + source: 'system', + message: RECORDING_BLOCKED_PREFIX, + metadata: { + rule_id: blocked.ruleId ?? '', + rule_label: blocked.ruleLabel ?? '', + app_name: blocked.appName ?? '', + }, + }); + } + throw error; + } + }, + + async getStreamState(): Promise { + return invoke(TauriCommands.GET_STREAM_STATE); + }, + + async resetStreamState(): Promise { + const info = await invoke(TauriCommands.RESET_STREAM_STATE); + addClientLog({ + level: 'warning', + source: 'system', + message: `Stream state force-reset from ${info.state}${info.meeting_id ? ` (meeting: ${info.meeting_id})` : ''}`, + metadata: { + previous_state: info.state, + meeting_id: info.meeting_id ?? '', + started_at_secs_ago: String(info.started_at_secs_ago ?? 0), + }, + }); + return info; + }, + + async generateSummary(meetingId: string, forceRegenerate?: boolean): Promise { + let options: SummarizationOptions | undefined; + try { + const prefs = await invoke(TauriCommands.GET_PREFERENCES); + if (prefs?.ai_template) { + options = { + tone: prefs.ai_template.tone, + format: prefs.ai_template.format, + verbosity: prefs.ai_template.verbosity, + }; + } + } catch { + /* Preferences unavailable */ + } + clientLog.summarizing(meetingId); + try { + const summary = await invoke(TauriCommands.GENERATE_SUMMARY, { + meeting_id: meetingId, + force_regenerate: forceRegenerate ?? false, + options, + }); + clientLog.summaryGenerated(meetingId, summary.model_version); + return summary; + } catch (error) { + clientLog.summaryFailed(meetingId, extractErrorMessage(error, 'Summary generation failed')); + throw error; + } + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/observability.ts b/client/src/api/tauri-adapter/sections/observability.ts new file mode 100644 index 0000000..eaf27c8 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/observability.ts @@ -0,0 +1,35 @@ +import type { + ConnectionDiagnostics, + GetPerformanceMetricsRequest, + GetPerformanceMetricsResponse, + GetRecentLogsRequest, + GetRecentLogsResponse, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import type { TauriInvoke } from '../types'; + +export function createObservabilityApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'getRecentLogs' | 'getPerformanceMetrics' | 'runConnectionDiagnostics' +> { + return { + async getRecentLogs(request?: GetRecentLogsRequest): Promise { + return invoke(TauriCommands.GET_RECENT_LOGS, { + limit: request?.limit, + level: request?.level, + source: request?.source, + }); + }, + async getPerformanceMetrics( + request?: GetPerformanceMetricsRequest + ): Promise { + return invoke(TauriCommands.GET_PERFORMANCE_METRICS, { + history_limit: request?.history_limit, + }); + }, + async runConnectionDiagnostics(): Promise { + return invoke(TauriCommands.RUN_CONNECTION_DIAGNOSTICS); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/oidc.ts b/client/src/api/tauri-adapter/sections/oidc.ts new file mode 100644 index 0000000..7bc4217 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/oidc.ts @@ -0,0 +1,76 @@ +import type { + DeleteOidcProviderResponse, + ListOidcPresetsResponse, + ListOidcProvidersResponse, + OidcProviderApi, + RefreshOidcDiscoveryResponse, + RegisterOidcProviderRequest, + UpdateOidcProviderRequest, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import type { TauriInvoke } from '../types'; + +export function createOidcApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'registerOidcProvider' + | 'listOidcProviders' + | 'getOidcProvider' + | 'updateOidcProvider' + | 'deleteOidcProvider' + | 'refreshOidcDiscovery' + | 'testOidcConnection' + | 'listOidcPresets' +> { + return { + async registerOidcProvider(request: RegisterOidcProviderRequest): Promise { + return invoke(TauriCommands.REGISTER_OIDC_PROVIDER, { request }); + }, + + async listOidcProviders( + workspaceId?: string, + enabledOnly?: boolean + ): Promise { + return invoke(TauriCommands.LIST_OIDC_PROVIDERS, { + workspace_id: workspaceId, + enabled_only: enabledOnly ?? false, + }); + }, + + async getOidcProvider(providerId: string): Promise { + return invoke(TauriCommands.GET_OIDC_PROVIDER, { + provider_id: providerId, + }); + }, + + async updateOidcProvider(request: UpdateOidcProviderRequest): Promise { + return invoke(TauriCommands.UPDATE_OIDC_PROVIDER, { request }); + }, + + async deleteOidcProvider(providerId: string): Promise { + return invoke(TauriCommands.DELETE_OIDC_PROVIDER, { + provider_id: providerId, + }); + }, + + async refreshOidcDiscovery( + providerId?: string, + workspaceId?: string + ): Promise { + return invoke(TauriCommands.REFRESH_OIDC_DISCOVERY, { + provider_id: providerId, + workspace_id: workspaceId, + }); + }, + + async testOidcConnection(providerId: string): Promise { + return invoke(TauriCommands.TEST_OIDC_CONNECTION, { + provider_id: providerId, + }); + }, + + async listOidcPresets(): Promise { + return invoke(TauriCommands.LIST_OIDC_PRESETS); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/playback.ts b/client/src/api/tauri-adapter/sections/playback.ts new file mode 100644 index 0000000..fa160b3 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/playback.ts @@ -0,0 +1,27 @@ +import type { PlaybackInfo } from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import type { TauriInvoke } from '../types'; + +export function createPlaybackApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'startPlayback' | 'pausePlayback' | 'stopPlayback' | 'seekPlayback' | 'getPlaybackState' +> { + return { + async startPlayback(meetingId: string, startTime?: number): Promise { + await invoke(TauriCommands.START_PLAYBACK, { meeting_id: meetingId, start_time: startTime }); + }, + async pausePlayback(): Promise { + await invoke(TauriCommands.PAUSE_PLAYBACK); + }, + async stopPlayback(): Promise { + await invoke(TauriCommands.STOP_PLAYBACK); + }, + async seekPlayback(position: number): Promise { + return invoke(TauriCommands.SEEK_PLAYBACK, { position }); + }, + async getPlaybackState(): Promise { + return invoke(TauriCommands.GET_PLAYBACK_STATE); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/preferences.ts b/client/src/api/tauri-adapter/sections/preferences.ts new file mode 100644 index 0000000..cd4e3ae --- /dev/null +++ b/client/src/api/tauri-adapter/sections/preferences.ts @@ -0,0 +1,34 @@ +import type { UserPreferences } from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { addClientLog } from '@/lib/client-logs'; +import type { TauriInvoke } from '../types'; + +export function createPreferencesApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'getPreferences' | 'savePreferences' +> { + return { + async getPreferences(): Promise { + addClientLog({ + level: 'debug', + source: 'api', + message: 'TauriAdapter.getPreferences: calling invoke', + }); + const prefs = await invoke(TauriCommands.GET_PREFERENCES); + addClientLog({ + level: 'debug', + source: 'api', + message: 'TauriAdapter.getPreferences: received', + metadata: { + input: prefs.audio_devices?.input_device_id ?? 'UNDEFINED', + output: prefs.audio_devices?.output_device_id ?? 'UNDEFINED', + }, + }); + return prefs; + }, + async savePreferences(preferences: UserPreferences): Promise { + await invoke(TauriCommands.SAVE_PREFERENCES, { preferences }); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/projects.ts b/client/src/api/tauri-adapter/sections/projects.ts new file mode 100644 index 0000000..98d08c7 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/projects.ts @@ -0,0 +1,142 @@ +import type { + AddProjectMemberRequest, + GetActiveProjectRequest, + GetActiveProjectResponse, + GetProjectBySlugRequest, + GetProjectRequest, + ListProjectMembersRequest, + ListProjectMembersResponse, + ListProjectsRequest, + ListProjectsResponse, + Project, + ProjectMembership, + RemoveProjectMemberRequest, + RemoveProjectMemberResponse, + SetActiveProjectRequest, + UpdateProjectMemberRoleRequest, + UpdateProjectRequest, + CreateProjectRequest, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { normalizeSuccessResponse } from '../../helpers'; +import type { TauriInvoke } from '../types'; +import { normalizeProjectId } from '../utils'; + +export function createProjectApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'createProject' + | 'getProject' + | 'getProjectBySlug' + | 'listProjects' + | 'updateProject' + | 'archiveProject' + | 'restoreProject' + | 'deleteProject' + | 'setActiveProject' + | 'getActiveProject' + | 'addProjectMember' + | 'updateProjectMemberRole' + | 'removeProjectMember' + | 'listProjectMembers' +> { + return { + async createProject(request: CreateProjectRequest): Promise { + return invoke(TauriCommands.CREATE_PROJECT, { + request, + }); + }, + + async getProject(request: GetProjectRequest): Promise { + return invoke(TauriCommands.GET_PROJECT, { + project_id: request.project_id, + }); + }, + + async getProjectBySlug(request: GetProjectBySlugRequest): Promise { + return invoke(TauriCommands.GET_PROJECT_BY_SLUG, { + workspace_id: request.workspace_id, + slug: request.slug, + }); + }, + + async listProjects(request: ListProjectsRequest): Promise { + return invoke(TauriCommands.LIST_PROJECTS, { + workspace_id: request.workspace_id, + include_archived: request.include_archived ?? false, + limit: request.limit, + offset: request.offset, + }); + }, + + async updateProject(request: UpdateProjectRequest): Promise { + return invoke(TauriCommands.UPDATE_PROJECT, { + request, + }); + }, + + async archiveProject(projectId: string): Promise { + return invoke(TauriCommands.ARCHIVE_PROJECT, { + project_id: projectId, + }); + }, + + async restoreProject(projectId: string): Promise { + return invoke(TauriCommands.RESTORE_PROJECT, { + project_id: projectId, + }); + }, + + async deleteProject(projectId: string): Promise { + const response = await invoke<{ success: boolean }>(TauriCommands.DELETE_PROJECT, { + project_id: projectId, + }); + return normalizeSuccessResponse(response); + }, + + async setActiveProject(request: SetActiveProjectRequest): Promise { + await invoke(TauriCommands.SET_ACTIVE_PROJECT, { + workspace_id: request.workspace_id, + project_id: normalizeProjectId(request.project_id), + }); + }, + + async getActiveProject(request: GetActiveProjectRequest): Promise { + return invoke(TauriCommands.GET_ACTIVE_PROJECT, { + workspace_id: request.workspace_id, + }); + }, + + async addProjectMember(request: AddProjectMemberRequest): Promise { + return invoke(TauriCommands.ADD_PROJECT_MEMBER, { + request, + }); + }, + + async updateProjectMemberRole( + request: UpdateProjectMemberRoleRequest + ): Promise { + return invoke(TauriCommands.UPDATE_PROJECT_MEMBER_ROLE, { + request, + }); + }, + + async removeProjectMember( + request: RemoveProjectMemberRequest + ): Promise { + return invoke(TauriCommands.REMOVE_PROJECT_MEMBER, { + request, + }); + }, + + async listProjectMembers( + request: ListProjectMembersRequest + ): Promise { + return invoke(TauriCommands.LIST_PROJECT_MEMBERS, { + project_id: request.project_id, + limit: request.limit, + offset: request.offset, + }); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/summarization.ts b/client/src/api/tauri-adapter/sections/summarization.ts new file mode 100644 index 0000000..0768373 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/summarization.ts @@ -0,0 +1,132 @@ +import type { + ArchiveSummarizationTemplateRequest, + CreateSummarizationTemplateRequest, + GetSummarizationTemplateRequest, + GetSummarizationTemplateResponse, + ListSummarizationTemplateVersionsRequest, + ListSummarizationTemplateVersionsResponse, + ListSummarizationTemplatesRequest, + ListSummarizationTemplatesResponse, + RestoreSummarizationTemplateVersionRequest, + SummarizationTemplate, + SummarizationTemplateMutationResponse, + UpdateSummarizationTemplateRequest, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createSummarizationApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'listSummarizationTemplates' + | 'getSummarizationTemplate' + | 'createSummarizationTemplate' + | 'updateSummarizationTemplate' + | 'archiveSummarizationTemplate' + | 'listSummarizationTemplateVersions' + | 'restoreSummarizationTemplateVersion' + | 'grantCloudConsent' + | 'revokeCloudConsent' + | 'getCloudConsentStatus' +> { + return { + async listSummarizationTemplates( + request: ListSummarizationTemplatesRequest + ): Promise { + return invoke( + TauriCommands.LIST_SUMMARIZATION_TEMPLATES, + { + workspace_id: request.workspace_id, + include_system: request.include_system ?? true, + include_archived: request.include_archived ?? false, + limit: request.limit, + offset: request.offset, + } + ); + }, + + async getSummarizationTemplate( + request: GetSummarizationTemplateRequest + ): Promise { + return invoke(TauriCommands.GET_SUMMARIZATION_TEMPLATE, { + template_id: request.template_id, + include_current_version: request.include_current_version ?? true, + }); + }, + + async createSummarizationTemplate( + request: CreateSummarizationTemplateRequest + ): Promise { + return invoke( + TauriCommands.CREATE_SUMMARIZATION_TEMPLATE, + { + workspace_id: request.workspace_id, + name: request.name, + description: request.description, + content: request.content, + change_note: request.change_note, + } + ); + }, + + async updateSummarizationTemplate( + request: UpdateSummarizationTemplateRequest + ): Promise { + return invoke( + TauriCommands.UPDATE_SUMMARIZATION_TEMPLATE, + { + template_id: request.template_id, + name: request.name, + description: request.description, + content: request.content, + change_note: request.change_note, + } + ); + }, + + async archiveSummarizationTemplate( + request: ArchiveSummarizationTemplateRequest + ): Promise { + return invoke(TauriCommands.ARCHIVE_SUMMARIZATION_TEMPLATE, { + template_id: request.template_id, + }); + }, + + async listSummarizationTemplateVersions( + request: ListSummarizationTemplateVersionsRequest + ): Promise { + return invoke( + TauriCommands.LIST_SUMMARIZATION_TEMPLATE_VERSIONS, + { + template_id: request.template_id, + limit: request.limit, + offset: request.offset, + } + ); + }, + + async restoreSummarizationTemplateVersion( + request: RestoreSummarizationTemplateVersionRequest + ): Promise { + return invoke(TauriCommands.RESTORE_SUMMARIZATION_TEMPLATE_VERSION, { + template_id: request.template_id, + version_id: request.version_id, + }); + }, + + async grantCloudConsent(): Promise { + await invoke(TauriCommands.GRANT_CLOUD_CONSENT); + clientLog.cloudConsentGranted(); + }, + async revokeCloudConsent(): Promise { + await invoke(TauriCommands.REVOKE_CLOUD_CONSENT); + clientLog.cloudConsentRevoked(); + }, + async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> { + return invoke<{ consent_granted: boolean }>(TauriCommands.GET_CLOUD_CONSENT_STATUS).then( + (r) => ({ consentGranted: r.consent_granted }) + ); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/triggers.ts b/client/src/api/tauri-adapter/sections/triggers.ts new file mode 100644 index 0000000..c3fdec9 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/triggers.ts @@ -0,0 +1,38 @@ +import type { Meeting, TriggerStatus } from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createTriggerApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + | 'setTriggerEnabled' + | 'snoozeTriggers' + | 'resetSnooze' + | 'getTriggerStatus' + | 'dismissTrigger' + | 'acceptTrigger' +> { + return { + async setTriggerEnabled(enabled: boolean): Promise { + await invoke(TauriCommands.SET_TRIGGER_ENABLED, { enabled }); + }, + async snoozeTriggers(minutes?: number): Promise { + await invoke(TauriCommands.SNOOZE_TRIGGERS, { minutes }); + clientLog.triggersSnoozed(minutes); + }, + async resetSnooze(): Promise { + await invoke(TauriCommands.RESET_SNOOZE); + clientLog.triggerSnoozeCleared(); + }, + async getTriggerStatus(): Promise { + return invoke(TauriCommands.GET_TRIGGER_STATUS); + }, + async dismissTrigger(): Promise { + await invoke(TauriCommands.DISMISS_TRIGGER); + }, + async acceptTrigger(title?: string): Promise { + return invoke(TauriCommands.ACCEPT_TRIGGER, { title }); + }, + }; +} diff --git a/client/src/api/tauri-adapter/sections/webhooks.ts b/client/src/api/tauri-adapter/sections/webhooks.ts new file mode 100644 index 0000000..6ae41b9 --- /dev/null +++ b/client/src/api/tauri-adapter/sections/webhooks.ts @@ -0,0 +1,51 @@ +import type { + DeleteWebhookResponse, + GetWebhookDeliveriesResponse, + ListWebhooksResponse, + RegisteredWebhook, + RegisterWebhookRequest, + UpdateWebhookRequest, +} from '../../types'; +import type { NoteFlowAPI } from '../../interface'; +import { TauriCommands } from '../../tauri-constants'; +import { clientLog } from '@/lib/client-log-events'; +import type { TauriInvoke } from '../types'; + +export function createWebhookApi(invoke: TauriInvoke): Pick< + NoteFlowAPI, + 'registerWebhook' | 'listWebhooks' | 'updateWebhook' | 'deleteWebhook' | 'getWebhookDeliveries' +> { + return { + async registerWebhook(r: RegisterWebhookRequest): Promise { + const webhook = await invoke(TauriCommands.REGISTER_WEBHOOK, { + request: r, + }); + clientLog.webhookRegistered(webhook.id, webhook.name); + return webhook; + }, + async listWebhooks(enabledOnly?: boolean): Promise { + return invoke(TauriCommands.LIST_WEBHOOKS, { + enabled_only: enabledOnly ?? false, + }); + }, + async updateWebhook(r: UpdateWebhookRequest): Promise { + return invoke(TauriCommands.UPDATE_WEBHOOK, { request: r }); + }, + async deleteWebhook(webhookId: string): Promise { + const response = await invoke(TauriCommands.DELETE_WEBHOOK, { + webhook_id: webhookId, + }); + clientLog.webhookDeleted(webhookId); + return response; + }, + async getWebhookDeliveries( + webhookId: string, + limit?: number + ): Promise { + return invoke(TauriCommands.GET_WEBHOOK_DELIVERIES, { + webhook_id: webhookId, + limit: limit ?? 50, + }); + }, + }; +} diff --git a/client/src/api/tauri-adapter/stream.ts b/client/src/api/tauri-adapter/stream.ts new file mode 100644 index 0000000..7cbef6e --- /dev/null +++ b/client/src/api/tauri-adapter/stream.ts @@ -0,0 +1,265 @@ +import type { TranscriptUpdate } from '../types'; +import { Timing } from '../constants'; +import { TauriCommands, TauriEvents } from '../tauri-constants'; +import { extractErrorMessage } from '../helpers'; +import { addClientLog } from '@/lib/client-logs'; +import { errorLog } from '@/lib/debug'; +import { StreamingQueue } from '@/lib/async-utils'; +import type { AudioChunk } from '../types'; +import type { CongestionCallback, StreamErrorCallback, TauriInvoke, TauriListen } from './types'; + +const logError = errorLog('TauriTranscriptionStream'); + +/** Consecutive failure threshold before emitting stream error. */ +export const CONSECUTIVE_FAILURE_THRESHOLD = 3; + +/** Threshold in milliseconds before showing buffering indicator (2 seconds). */ +export const CONGESTION_DISPLAY_THRESHOLD_MS = Timing.TWO_SECONDS_MS; + +/** Real-time transcription stream using Tauri events. */ +export class TauriTranscriptionStream { + private unlistenFn: (() => void) | null = null; + private healthUnlistenFn: (() => void) | null = null; + private healthListenerPending = false; + private errorCallback: StreamErrorCallback | null = null; + private congestionCallback: CongestionCallback | null = null; + + /** Latest ack_sequence received from server (for debugging/monitoring). */ + private lastAckedSequence = 0; + + /** Timestamp when congestion started (null if not congested). */ + private congestionStartTime: number | null = null; + + /** Whether buffering indicator is currently shown. */ + private isShowingBuffering = false; + + /** Whether the stream has been closed (prevents late listeners). */ + private isClosed = false; + + /** Queue for ordered, backpressure-aware chunk transmission. */ + private readonly sendQueue: StreamingQueue; + + constructor( + private meetingId: string, + private invoke: TauriInvoke, + private listen: TauriListen + ) { + this.sendQueue = new StreamingQueue({ + label: `audio-stream-${meetingId}`, + maxDepth: 50, // ~5 seconds of audio at 100ms chunks + failureThreshold: CONSECUTIVE_FAILURE_THRESHOLD, + onThresholdReached: (failures) => { + if (this.errorCallback) { + this.errorCallback({ + code: 'stream_send_failed', + message: `Audio streaming interrupted after ${failures} consecutive failures`, + }); + } + }, + onOverflow: () => { + // Queue full = severe backpressure, notify via congestion callback + if (this.congestionCallback) { + this.congestionCallback({ isBuffering: true, duration: 0 }); + } + }, + }); + } + + /** Get the last acknowledged chunk sequence number. */ + getLastAckedSequence(): number { + return this.lastAckedSequence; + } + + /** Get current queue depth (for monitoring). */ + getQueueDepth(): number { + return this.sendQueue.currentDepth; + } + + /** + * Send an audio chunk to the transcription service. + * + * Chunks are queued and sent in order with backpressure protection. + * Returns false if the queue is full (severe backpressure). + */ + send(chunk: AudioChunk): boolean { + if (this.isClosed) { + return false; + } + + const args: Record = { + meeting_id: chunk.meeting_id, + audio_data: Array.from(chunk.audio_data), + timestamp: chunk.timestamp, + }; + if (typeof chunk.sample_rate === 'number') { + args.sample_rate = chunk.sample_rate; + } + if (typeof chunk.channels === 'number') { + args.channels = chunk.channels; + } + + return this.sendQueue.enqueueWithSuccessReset(async () => { + await this.invoke(TauriCommands.SEND_AUDIO_CHUNK, args); + }); + } + + async onUpdate(callback: (update: TranscriptUpdate) => void): Promise { + // Clean up any existing listener to prevent memory leaks + // (calling onUpdate() multiple times should replace, not accumulate listeners) + if (this.unlistenFn) { + this.unlistenFn(); + this.unlistenFn = null; + } + + const unlisten = await this.listen(TauriEvents.TRANSCRIPT_UPDATE, (event) => { + if (this.isClosed) { + return; + } + if (event.payload.meeting_id === this.meetingId) { + // Track latest ack_sequence for monitoring + if ( + typeof event.payload.ack_sequence === 'number' && + event.payload.ack_sequence > this.lastAckedSequence + ) { + this.lastAckedSequence = event.payload.ack_sequence; + } + callback(event.payload); + } + }); + if (this.isClosed) { + unlisten(); + return; + } + this.unlistenFn = unlisten; + } + + /** Register callback for stream errors (connection failures, etc.). */ + onError(callback: StreamErrorCallback): void { + this.errorCallback = callback; + } + + /** Register callback for congestion state updates (buffering indicator). */ + onCongestion(callback: CongestionCallback): void { + this.congestionCallback = callback; + // Start listening for stream_health events + this.startHealthListener(); + } + + /** Start listening for stream_health events from the Rust backend. */ + private startHealthListener(): void { + if (this.isClosed) { + return; + } + if (this.healthUnlistenFn || this.healthListenerPending) { + return; + } // Already listening or setup in progress + + this.healthListenerPending = true; + + this.listen<{ + meeting_id: string; + is_congested: boolean; + processing_delay_ms: number; + queue_depth: number; + congested_duration_ms: number; + }>(TauriEvents.STREAM_HEALTH, (event) => { + if (this.isClosed) { + return; + } + if (event.payload.meeting_id !== this.meetingId) { + return; + } + + const { is_congested } = event.payload; + + if (is_congested) { + // Start tracking congestion if not already + this.congestionStartTime ??= Date.now(); + const duration = Date.now() - this.congestionStartTime; + + // Only show buffering after threshold is exceeded + if (duration >= CONGESTION_DISPLAY_THRESHOLD_MS && !this.isShowingBuffering) { + this.isShowingBuffering = true; + this.congestionCallback?.({ isBuffering: true, duration }); + } else if (this.isShowingBuffering) { + // Update duration while showing + this.congestionCallback?.({ isBuffering: true, duration }); + } + } else { + // Congestion cleared + if (this.isShowingBuffering) { + this.isShowingBuffering = false; + this.congestionCallback?.({ isBuffering: false, duration: 0 }); + } + this.congestionStartTime = null; + } + }) + .then((unlisten) => { + if (this.isClosed) { + unlisten(); + this.healthListenerPending = false; + return; + } + this.healthUnlistenFn = unlisten; + this.healthListenerPending = false; + }) + .catch(() => { + // Stream health listener failed - non-critical, monitoring degraded + this.healthListenerPending = false; + }); + } + + /** + * Close the stream and stop recording. + * + * Drains the send queue first to ensure all pending chunks are transmitted, + * then stops recording on the backend. + * + * Returns a Promise that resolves when the backend has confirmed the + * recording has stopped. The Promise rejects if stopping fails. + */ + async close(): Promise { + this.isClosed = true; + + // Drain the send queue to ensure all pending chunks are transmitted + try { + await this.sendQueue.drain(); + } catch { + // Queue drain failed - continue with cleanup anyway + } + + if (this.unlistenFn) { + this.unlistenFn(); + this.unlistenFn = null; + } + if (this.healthUnlistenFn) { + this.healthUnlistenFn(); + this.healthUnlistenFn = null; + } + // Reset congestion state + this.congestionStartTime = null; + this.isShowingBuffering = false; + + try { + await this.invoke(TauriCommands.STOP_RECORDING, { meeting_id: this.meetingId }); + } catch (err: unknown) { + const message = extractErrorMessage(err, 'Failed to stop recording'); + logError('stop_recording failed', message); + addClientLog({ + level: 'error', + source: 'api', + message: 'Tauri stream stop_recording failed', + details: message, + metadata: { context: 'tauri_stream_stop', meeting_id: this.meetingId }, + }); + // Emit error so UI can show notification + if (this.errorCallback) { + this.errorCallback({ + code: 'stream_close_failed', + message: `Failed to stop recording: ${message}`, + }); + } + throw err; // Re-throw so callers can handle if they await + } + } +} diff --git a/client/src/api/tauri-adapter/types.ts b/client/src/api/tauri-adapter/types.ts new file mode 100644 index 0000000..432ab19 --- /dev/null +++ b/client/src/api/tauri-adapter/types.ts @@ -0,0 +1,21 @@ +/** Type-safe wrapper for Tauri's invoke function. */ +export type TauriInvoke = (cmd: string, args?: Record) => Promise; +/** Type-safe wrapper for Tauri's event system. */ +export type TauriListen = ( + event: string, + handler: (event: { payload: T }) => void +) => Promise<() => void>; + +/** Error callback type for stream errors. */ +export type StreamErrorCallback = (error: { code: string; message: string }) => void; + +/** Congestion state for UI feedback. */ +export interface CongestionState { + /** Whether the stream is currently showing congestion to the user. */ + isBuffering: boolean; + /** Duration of congestion in milliseconds. */ + duration: number; +} + +/** Congestion callback type for stream health updates. */ +export type CongestionCallback = (state: CongestionState) => void; diff --git a/client/src/api/tauri-adapter/utils.ts b/client/src/api/tauri-adapter/utils.ts new file mode 100644 index 0000000..2e353c1 --- /dev/null +++ b/client/src/api/tauri-adapter/utils.ts @@ -0,0 +1,56 @@ +import { IdentityDefaults } from '../constants'; + +const { DEFAULT_PROJECT_ID } = IdentityDefaults; + +export const RECORDING_BLOCKED_PREFIX = 'Recording blocked by app policy'; + +export function normalizeProjectId(projectId?: string): string | undefined { + const trimmed = projectId?.trim(); + if (!trimmed || trimmed === DEFAULT_PROJECT_ID) { + return undefined; + } + return trimmed; +} + +export function normalizeProjectIds(projectIds: string[]): string[] { + return projectIds + .map((projectId) => projectId.trim()) + .filter((projectId) => projectId && projectId !== DEFAULT_PROJECT_ID); +} + +export function recordingBlockedDetails(error: unknown): { + ruleId?: string; + ruleLabel?: string; + appName?: string; +} | null { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : JSON.stringify(error); + + if (!message.includes(RECORDING_BLOCKED_PREFIX)) { + return null; + } + + const details = message.split(RECORDING_BLOCKED_PREFIX)[1] ?? ''; + const cleaned = details.replace(/^\s*:\s*/, ''); + const parts = cleaned + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + const extracted: { ruleId?: string; ruleLabel?: string; appName?: string } = {}; + for (const part of parts) { + if (part.startsWith('rule_id=')) { + extracted.ruleId = part.replace('rule_id=', '').trim(); + } else if (part.startsWith('rule_label=')) { + extracted.ruleLabel = part.replace('rule_label=', '').trim(); + } else if (part.startsWith('app_name=')) { + extracted.appName = part.replace('app_name=', '').trim(); + } + } + + return extracted; +}