Merge pull request #2 from vasceannie/codex/refactor-tauri-adapter-files-into-structure

refactor(api): split Tauri adapter into modular package
This commit is contained in:
2026-01-18 21:40:56 -05:00
committed by GitHub
33 changed files with 2766 additions and 2284 deletions

View File

@@ -1,813 +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<string, unknown>) => Promise<unknown>;
type ListenMock = (
event: string,
handler: (event: { payload: unknown }) => void
) => Promise<() => void>;
function createMocks() {
const invoke = vi.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>();
const listen = vi
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
.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<string, unknown>;
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);
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);
const call = invoke.mock.calls.find((item) => item[0] === 'send_audio_chunk');
expect(call).toBeDefined();
const args = call?.[1] as Record<string, unknown>;
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<Parameters<InvokeMock>, ReturnType<InvokeMock>>()
.mockResolvedValue(undefined);
const listen = vi
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
.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<Parameters<InvokeMock>, ReturnType<InvokeMock>>()
.mockResolvedValue(undefined);
const listen = vi
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
.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(() => {});
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<Parameters<InvokeMock>, ReturnType<InvokeMock>>()
.mockResolvedValue(undefined);
const listen = vi
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
.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);
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');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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' });
});
});

View File

@@ -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');
});
});

View File

@@ -0,0 +1,348 @@
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 with payload assertions', 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');
expect(invoke).toHaveBeenCalledWith('export_transcript', {
meeting_id: 'm1',
format: 1,
});
expect(invoke).toHaveBeenCalledWith('set_trigger_enabled', { enabled: true });
expect(invoke).toHaveBeenCalledWith('extract_entities', {
meeting_id: 'm1',
force_refresh: true,
});
expect(invoke).toHaveBeenCalledWith('register_webhook', {
request: {
workspace_id: 'w1',
name: 'Webhook',
url: 'https://example.com',
events: ['meeting.completed'],
},
});
expect(invoke).toHaveBeenCalledWith('list_calendar_events', {
hours_ahead: 2,
limit: 5,
provider: 'google',
});
});
});

View File

@@ -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<string, unknown>) => Promise<unknown>;
export type ListenMock = (
event: string,
handler: (event: { payload: unknown }) => void
) => Promise<() => void>;
export function createMocks() {
const invoke = vi.fn<Parameters<InvokeMock>, ReturnType<InvokeMock>>();
const listen = vi
.fn<Parameters<ListenMock>, ReturnType<ListenMock>>()
.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<string, unknown>;
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,
};
}

View File

@@ -0,0 +1,253 @@
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: chunk.audio_data,
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<string, unknown>;
expect(args).toMatchObject({
meeting_id: 'm2',
timestamp: 1.23,
});
const audioData = args.audio_data as Float32Array | undefined;
expect(audioData).toBeInstanceOf(Float32Array);
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<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
.mockResolvedValue(undefined);
const listen = vi
.fn<Parameters<TauriListen>, ReturnType<TauriListen>>()
.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<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
.mockResolvedValue(undefined);
const listen = vi
.fn<Parameters<TauriListen>, ReturnType<TauriListen>>()
.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(() => {});
await stream.close();
expect(unlisten).toHaveBeenCalled();
expect(invoke).toHaveBeenCalledWith('stop_recording', { meeting_id: 'm1' });
});
it('propagates errors when closing a transcription stream fails', async () => {
const { invoke, listen } = createInvokeListenMocks();
const unlisten = vi.fn();
listen.mockResolvedValueOnce(unlisten);
invoke.mockResolvedValueOnce(undefined);
const stopError = new Error('STOP_RECORDING failed');
invoke.mockRejectedValueOnce(stopError);
const api: NoteFlowAPI = createTauriAPI(invoke as TauriInvoke, listen as TauriListen);
const stream: unknown = await api.startTranscription('m1');
assertTranscriptionStream(stream);
const onError = vi.fn();
stream.onError(onError);
await stream.onUpdate(() => {});
await expect(stream.close()).rejects.toBe(stopError);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
code: 'stream_close_failed',
message: expect.stringContaining('Failed to stop recording'),
})
);
expect(unlisten).toHaveBeenCalledTimes(1);
});
it('cleans up pending transcript listener when closed before listen resolves', async () => {
let capturedHandler: ((event: { payload: TranscriptUpdate }) => void) | null = null;
let resolveListen: ((fn: () => void) => void) | null = null;
const unlisten = vi.fn();
const invoke = vi
.fn<Parameters<TauriInvoke>, ReturnType<TauriInvoke>>()
.mockResolvedValue(undefined);
const listen = vi
.fn<Parameters<TauriListen>, ReturnType<TauriListen>>()
.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' });
});
});

View File

@@ -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),
};
}

View File

@@ -0,0 +1,42 @@
import type { NoteFlowAPI } from '../interface';
import { extractErrorMessage } from '../helpers';
import { createTauriAPI } from './api';
import { addClientLog } from '@/lib/client-logs';
/** Check if running in a Tauri environment (synchronous hint). */
export function isTauriEnvironment(): boolean {
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<NoteFlowAPI> {
const [core, event] = await Promise.all([
import('@tauri-apps/api/core'),
import('@tauri-apps/api/event'),
]).catch((error) => {
addClientLog({
level: 'debug',
source: 'api',
message: 'Tauri adapter initialization: import failed',
details: extractErrorMessage(error, 'unknown error'),
});
throw new Error('Not running in Tauri environment.');
});
try {
return createTauriAPI(core.invoke, event.listen);
} catch (error) {
addClientLog({
level: 'error',
source: 'api',
message: 'Tauri adapter initialization failed',
details: extractErrorMessage(error, 'unknown error'),
});
throw new Error('Failed to initialize Tauri API.');
}
}

View File

@@ -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';

View File

@@ -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<Annotation[]> {
return normalizeAnnotationList(
await invoke<Annotation[] | { annotations: Annotation[] }>(TauriCommands.LIST_ANNOTATIONS, {
meeting_id: meetingId,
start_time: startTime ?? 0,
end_time: endTime ?? 0,
})
);
},
async addAnnotation(request: AddAnnotationRequest): Promise<Annotation> {
return invoke<Annotation>(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<Annotation> {
return invoke<Annotation>(TauriCommands.GET_ANNOTATION, { annotation_id: annotationId });
},
async updateAnnotation(request: UpdateAnnotationRequest): Promise<Annotation> {
return invoke<Annotation>(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<boolean> {
return normalizeSuccessResponse(
await invoke<boolean | { success: boolean }>(TauriCommands.DELETE_ANNOTATION, {
annotation_id: annotationId,
})
);
},
};
}

View File

@@ -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<ListInstalledAppsResponse> {
return invoke<ListInstalledAppsResponse>(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<void> {
await invoke(TauriCommands.INVALIDATE_APP_CACHE);
},
};
}

View File

@@ -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<ASRConfiguration> {
return invoke<ASRConfiguration>(TauriCommands.GET_ASR_CONFIGURATION);
},
async updateAsrConfiguration(
request: UpdateASRConfigurationRequest
): Promise<UpdateASRConfigurationResult> {
return invoke<UpdateASRConfigurationResult>(TauriCommands.UPDATE_ASR_CONFIGURATION, {
request,
});
},
async getAsrJobStatus(jobId: string): Promise<ASRConfigurationJobStatus> {
return invoke<ASRConfigurationJobStatus>(TauriCommands.GET_ASR_JOB_STATUS, {
job_id: jobId,
});
},
async getStreamingConfiguration(): Promise<StreamingConfiguration> {
return invoke<StreamingConfiguration>(TauriCommands.GET_STREAMING_CONFIGURATION);
},
async updateStreamingConfiguration(
request: UpdateStreamingConfigurationRequest
): Promise<StreamingConfiguration> {
return invoke<StreamingConfiguration>(TauriCommands.UPDATE_STREAMING_CONFIGURATION, {
request,
});
},
async setHuggingFaceToken(
request: SetHuggingFaceTokenRequest
): Promise<SetHuggingFaceTokenResult> {
return invoke<SetHuggingFaceTokenResult>(TauriCommands.SET_HUGGINGFACE_TOKEN, {
request,
});
},
async getHuggingFaceTokenStatus(): Promise<HuggingFaceTokenStatus> {
return invoke<HuggingFaceTokenStatus>(TauriCommands.GET_HUGGINGFACE_TOKEN_STATUS);
},
async deleteHuggingFaceToken(): Promise<boolean> {
return invoke<boolean>(TauriCommands.DELETE_HUGGINGFACE_TOKEN);
},
async validateHuggingFaceToken(): Promise<ValidateHuggingFaceTokenResult> {
return invoke<ValidateHuggingFaceTokenResult>(TauriCommands.VALIDATE_HUGGINGFACE_TOKEN);
},
};
}

View File

@@ -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<AudioDeviceInfo[]> {
return invoke<AudioDeviceInfo[]>(TauriCommands.LIST_AUDIO_DEVICES);
},
async getDefaultAudioDevice(isInput: boolean): Promise<AudioDeviceInfo | null> {
return invoke<AudioDeviceInfo | null>(TauriCommands.GET_DEFAULT_AUDIO_DEVICE, {
is_input: isInput,
});
},
async selectAudioDevice(deviceId: string, isInput: boolean): Promise<void> {
await invoke(TauriCommands.SELECT_AUDIO_DEVICE, { device_id: deviceId, is_input: isInput });
},
// Dual capture (system audio)
async listLoopbackDevices(): Promise<AudioDeviceInfo[]> {
return invoke<AudioDeviceInfo[]>(TauriCommands.LIST_LOOPBACK_DEVICES);
},
async setSystemAudioDevice(deviceId: string | null): Promise<void> {
await invoke(TauriCommands.SET_SYSTEM_AUDIO_DEVICE, { device_id: deviceId });
},
async setDualCaptureEnabled(enabled: boolean): Promise<void> {
await invoke(TauriCommands.SET_DUAL_CAPTURE_ENABLED, { enabled });
},
async setAudioMixLevels(micGain: number, systemGain: number): Promise<void> {
await invoke(TauriCommands.SET_AUDIO_MIX_LEVELS, {
mic_gain: micGain,
system_gain: systemGain,
});
},
async getDualCaptureConfig(): Promise<DualCaptureConfigInfo> {
return invoke<DualCaptureConfigInfo>(TauriCommands.GET_DUAL_CAPTURE_CONFIG);
},
async checkTestEnvironment(): Promise<TestEnvironmentInfo> {
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<TestAudioResult> {
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<TestAudioResult> {
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,
};
},
};
}

View File

@@ -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<ListCalendarEventsResponse> {
return invoke<ListCalendarEventsResponse>(TauriCommands.LIST_CALENDAR_EVENTS, {
hours_ahead: hoursAhead,
limit,
provider,
});
},
async getCalendarProviders(): Promise<GetCalendarProvidersResponse> {
return invoke<GetCalendarProvidersResponse>(TauriCommands.GET_CALENDAR_PROVIDERS);
},
async initiateCalendarAuth(
provider: string,
redirectUri?: string
): Promise<InitiateCalendarAuthResponse> {
return invoke<InitiateCalendarAuthResponse>(TauriCommands.INITIATE_OAUTH, {
provider,
redirect_uri: redirectUri,
});
},
async completeCalendarAuth(
provider: string,
code: string,
state: string
): Promise<CompleteCalendarAuthResponse> {
const response = await invoke<CompleteCalendarAuthResponse>(TauriCommands.COMPLETE_OAUTH, {
provider,
code,
state,
});
clientLog.calendarConnected(provider);
return response;
},
async getOAuthConnectionStatus(provider: string): Promise<GetOAuthConnectionStatusResponse> {
return invoke<GetOAuthConnectionStatusResponse>(TauriCommands.GET_OAUTH_CONNECTION_STATUS, {
provider,
});
},
async getOAuthClientConfig(
request: GetOAuthClientConfigRequest
): Promise<GetOAuthClientConfigResponse> {
return invoke<GetOAuthClientConfigResponse>(TauriCommands.GET_OAUTH_CLIENT_CONFIG, {
provider: request.provider,
workspace_id: request.workspace_id,
integration_type: request.integration_type,
});
},
async setOAuthClientConfig(
request: SetOAuthClientConfigRequest
): Promise<SetOAuthClientConfigResponse> {
return invoke<SetOAuthClientConfigResponse>(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<DisconnectOAuthResponse> {
const response = await invoke<DisconnectOAuthResponse>(TauriCommands.DISCONNECT_OAUTH, {
provider,
});
clientLog.calendarDisconnected(provider);
return response;
},
};
}

View File

@@ -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<ServerInfo> {
return invoke<ServerInfo>(TauriCommands.GET_SERVER_INFO);
},
async connect(serverUrl?: string): Promise<ServerInfo> {
try {
const info = await invoke<ServerInfo>(TauriCommands.CONNECT, { server_url: serverUrl });
clientLog.connected(serverUrl);
return info;
} catch (error) {
clientLog.connectionFailed(extractErrorMessage(error, 'Connection failed'));
throw error;
}
},
async disconnect(): Promise<void> {
await invoke(TauriCommands.DISCONNECT);
clientLog.disconnected();
},
async isConnected(): Promise<boolean> {
return invoke<boolean>(TauriCommands.IS_CONNECTED);
},
async getEffectiveServerUrl(): Promise<EffectiveServerUrl> {
return invoke<EffectiveServerUrl>(TauriCommands.GET_EFFECTIVE_SERVER_URL);
},
async getCurrentUser(): Promise<GetCurrentUserResponse> {
return invoke<GetCurrentUserResponse>(TauriCommands.GET_CURRENT_USER);
},
async listWorkspaces(): Promise<ListWorkspacesResponse> {
return invoke<ListWorkspacesResponse>(TauriCommands.LIST_WORKSPACES);
},
async switchWorkspace(workspaceId: string): Promise<SwitchWorkspaceResponse> {
return invoke<SwitchWorkspaceResponse>(TauriCommands.SWITCH_WORKSPACE, {
workspace_id: workspaceId,
});
},
async getWorkspaceSettings(
request: GetWorkspaceSettingsRequest
): Promise<GetWorkspaceSettingsResponse> {
return invoke<GetWorkspaceSettingsResponse>(TauriCommands.GET_WORKSPACE_SETTINGS, {
workspace_id: request.workspace_id,
});
},
async updateWorkspaceSettings(
request: UpdateWorkspaceSettingsRequest
): Promise<GetWorkspaceSettingsResponse> {
return invoke<GetWorkspaceSettingsResponse>(TauriCommands.UPDATE_WORKSPACE_SETTINGS, {
workspace_id: request.workspace_id,
settings: request.settings,
});
},
async initiateAuthLogin(
provider: string,
redirectUri?: string
): Promise<InitiateAuthLoginResponse> {
return invoke<InitiateAuthLoginResponse>(TauriCommands.INITIATE_AUTH_LOGIN, {
provider,
redirect_uri: redirectUri,
});
},
async completeAuthLogin(
provider: string,
code: string,
state: string
): Promise<CompleteAuthLoginResponse> {
const response = await invoke<CompleteAuthLoginResponse>(TauriCommands.COMPLETE_AUTH_LOGIN, {
provider,
code,
state,
});
clientLog.loginCompleted(provider);
return response;
},
async logout(provider?: string): Promise<LogoutResponse> {
const response = await invoke<LogoutResponse>(TauriCommands.LOGOUT, {
provider,
});
clientLog.loggedOut(provider);
return response;
},
};
}

View File

@@ -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<DiarizationJobStatus> {
const status = await invoke<DiarizationJobStatus>(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<DiarizationJobStatus> {
return invoke<DiarizationJobStatus>(TauriCommands.GET_DIARIZATION_STATUS, { job_id: jobId });
},
async renameSpeaker(
meetingId: string,
oldSpeakerId: string,
newName: string
): Promise<boolean> {
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<CancelDiarizationResult> {
return invoke<CancelDiarizationResult>(TauriCommands.CANCEL_DIARIZATION, { job_id: jobId });
},
async getActiveDiarizationJobs(): Promise<DiarizationJobStatus[]> {
return invoke<DiarizationJobStatus[]>(TauriCommands.GET_ACTIVE_DIARIZATION_JOBS);
},
};
}

View File

@@ -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<ExtractEntitiesResponse> {
const response = await invoke<ExtractEntitiesResponse>(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<ExtractedEntity> {
return invoke<ExtractedEntity>(TauriCommands.UPDATE_ENTITY, {
meeting_id: meetingId,
entity_id: entityId,
text,
category,
});
},
async deleteEntity(meetingId: string, entityId: string): Promise<boolean> {
return invoke<boolean>(TauriCommands.DELETE_ENTITY, {
meeting_id: meetingId,
entity_id: entityId,
});
},
};
}

View File

@@ -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<ExportResult> {
clientLog.exportStarted(meetingId, format);
try {
const result = await invoke<ExportResult>(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<boolean> {
return invoke<boolean>(TauriCommands.SAVE_EXPORT_FILE, {
content,
default_name: defaultName,
extension,
});
},
};
}

View File

@@ -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<StartIntegrationSyncResponse> {
return invoke<StartIntegrationSyncResponse>(TauriCommands.START_INTEGRATION_SYNC, {
integration_id: integrationId,
});
},
async getSyncStatus(syncRunId: string): Promise<GetSyncStatusResponse> {
return invoke<GetSyncStatusResponse>(TauriCommands.GET_SYNC_STATUS, {
sync_run_id: syncRunId,
});
},
async listSyncHistory(
integrationId: string,
limit?: number,
offset?: number
): Promise<ListSyncHistoryResponse> {
return invoke<ListSyncHistoryResponse>(TauriCommands.LIST_SYNC_HISTORY, {
integration_id: integrationId,
limit,
offset,
});
},
async getUserIntegrations(): Promise<GetUserIntegrationsResponse> {
return invoke<GetUserIntegrationsResponse>(TauriCommands.GET_USER_INTEGRATIONS);
},
};
}

View File

@@ -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<Meeting> {
const meeting = await invoke<Meeting>(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<ListMeetingsResponse> {
const response = await invoke<ListMeetingsResponse>(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<Meeting> {
const meeting = await invoke<Meeting>(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<Meeting> {
const meeting = await invoke<Meeting>(TauriCommands.STOP_MEETING, {
meeting_id: meetingId,
});
meetingCache.cacheMeeting(meeting);
clientLog.meetingStopped(meeting.id, meeting.title);
return meeting;
},
async deleteMeeting(meetingId: string): Promise<boolean> {
const result = normalizeSuccessResponse(
await invoke<boolean | { success: boolean }>(TauriCommands.DELETE_MEETING, {
meeting_id: meetingId,
})
);
if (result) {
meetingCache.removeMeeting(meetingId);
clientLog.meetingDeleted(meetingId);
}
return result;
},
async startTranscription(meetingId: string): Promise<TranscriptionStream> {
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<StreamStateInfo> {
return invoke<StreamStateInfo>(TauriCommands.GET_STREAM_STATE);
},
async resetStreamState(): Promise<StreamStateInfo> {
const info = await invoke<StreamStateInfo>(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<Summary> {
let options: SummarizationOptions | undefined;
try {
const prefs = await invoke<UserPreferences>(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<Summary>(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;
}
},
};
}

View File

@@ -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<GetRecentLogsResponse> {
return invoke<GetRecentLogsResponse>(TauriCommands.GET_RECENT_LOGS, {
limit: request?.limit,
level: request?.level,
source: request?.source,
});
},
async getPerformanceMetrics(
request?: GetPerformanceMetricsRequest
): Promise<GetPerformanceMetricsResponse> {
return invoke<GetPerformanceMetricsResponse>(TauriCommands.GET_PERFORMANCE_METRICS, {
history_limit: request?.history_limit,
});
},
async runConnectionDiagnostics(): Promise<ConnectionDiagnostics> {
return invoke<ConnectionDiagnostics>(TauriCommands.RUN_CONNECTION_DIAGNOSTICS);
},
};
}

View File

@@ -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<OidcProviderApi> {
return invoke<OidcProviderApi>(TauriCommands.REGISTER_OIDC_PROVIDER, { request });
},
async listOidcProviders(
workspaceId?: string,
enabledOnly?: boolean
): Promise<ListOidcProvidersResponse> {
return invoke<ListOidcProvidersResponse>(TauriCommands.LIST_OIDC_PROVIDERS, {
workspace_id: workspaceId,
enabled_only: enabledOnly ?? false,
});
},
async getOidcProvider(providerId: string): Promise<OidcProviderApi> {
return invoke<OidcProviderApi>(TauriCommands.GET_OIDC_PROVIDER, {
provider_id: providerId,
});
},
async updateOidcProvider(request: UpdateOidcProviderRequest): Promise<OidcProviderApi> {
return invoke<OidcProviderApi>(TauriCommands.UPDATE_OIDC_PROVIDER, { request });
},
async deleteOidcProvider(providerId: string): Promise<DeleteOidcProviderResponse> {
return invoke<DeleteOidcProviderResponse>(TauriCommands.DELETE_OIDC_PROVIDER, {
provider_id: providerId,
});
},
async refreshOidcDiscovery(
providerId?: string,
workspaceId?: string
): Promise<RefreshOidcDiscoveryResponse> {
return invoke<RefreshOidcDiscoveryResponse>(TauriCommands.REFRESH_OIDC_DISCOVERY, {
provider_id: providerId,
workspace_id: workspaceId,
});
},
async testOidcConnection(providerId: string): Promise<RefreshOidcDiscoveryResponse> {
return invoke<RefreshOidcDiscoveryResponse>(TauriCommands.TEST_OIDC_CONNECTION, {
provider_id: providerId,
});
},
async listOidcPresets(): Promise<ListOidcPresetsResponse> {
return invoke<ListOidcPresetsResponse>(TauriCommands.LIST_OIDC_PRESETS);
},
};
}

View File

@@ -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<void> {
await invoke(TauriCommands.START_PLAYBACK, { meeting_id: meetingId, start_time: startTime });
},
async pausePlayback(): Promise<void> {
await invoke(TauriCommands.PAUSE_PLAYBACK);
},
async stopPlayback(): Promise<void> {
await invoke(TauriCommands.STOP_PLAYBACK);
},
async seekPlayback(position: number): Promise<PlaybackInfo> {
return invoke<PlaybackInfo>(TauriCommands.SEEK_PLAYBACK, { position });
},
async getPlaybackState(): Promise<PlaybackInfo> {
return invoke<PlaybackInfo>(TauriCommands.GET_PLAYBACK_STATE);
},
};
}

View File

@@ -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<UserPreferences> {
addClientLog({
level: 'debug',
source: 'api',
message: 'TauriAdapter.getPreferences: calling invoke',
});
const prefs = await invoke<UserPreferences>(TauriCommands.GET_PREFERENCES);
addClientLog({
level: 'debug',
source: 'api',
message: 'TauriAdapter.getPreferences: received',
metadata: {
input: prefs.audio_devices?.input_device_id ? 'SET' : 'UNSET',
output: prefs.audio_devices?.output_device_id ? 'SET' : 'UNSET',
},
});
return prefs;
},
async savePreferences(preferences: UserPreferences): Promise<void> {
await invoke(TauriCommands.SAVE_PREFERENCES, { preferences });
},
};
}

View File

@@ -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<Project> {
return invoke<Project>(TauriCommands.CREATE_PROJECT, {
request,
});
},
async getProject(request: GetProjectRequest): Promise<Project> {
return invoke<Project>(TauriCommands.GET_PROJECT, {
project_id: request.project_id,
});
},
async getProjectBySlug(request: GetProjectBySlugRequest): Promise<Project> {
return invoke<Project>(TauriCommands.GET_PROJECT_BY_SLUG, {
workspace_id: request.workspace_id,
slug: request.slug,
});
},
async listProjects(request: ListProjectsRequest): Promise<ListProjectsResponse> {
return invoke<ListProjectsResponse>(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<Project> {
return invoke<Project>(TauriCommands.UPDATE_PROJECT, {
request,
});
},
async archiveProject(projectId: string): Promise<Project> {
return invoke<Project>(TauriCommands.ARCHIVE_PROJECT, {
project_id: projectId,
});
},
async restoreProject(projectId: string): Promise<Project> {
return invoke<Project>(TauriCommands.RESTORE_PROJECT, {
project_id: projectId,
});
},
async deleteProject(projectId: string): Promise<boolean> {
const response = await invoke<{ success: boolean }>(TauriCommands.DELETE_PROJECT, {
project_id: projectId,
});
return normalizeSuccessResponse(response);
},
async setActiveProject(request: SetActiveProjectRequest): Promise<void> {
await invoke(TauriCommands.SET_ACTIVE_PROJECT, {
workspace_id: request.workspace_id,
project_id: normalizeProjectId(request.project_id),
});
},
async getActiveProject(request: GetActiveProjectRequest): Promise<GetActiveProjectResponse> {
return invoke<GetActiveProjectResponse>(TauriCommands.GET_ACTIVE_PROJECT, {
workspace_id: request.workspace_id,
});
},
async addProjectMember(request: AddProjectMemberRequest): Promise<ProjectMembership> {
return invoke<ProjectMembership>(TauriCommands.ADD_PROJECT_MEMBER, {
request,
});
},
async updateProjectMemberRole(
request: UpdateProjectMemberRoleRequest
): Promise<ProjectMembership> {
return invoke<ProjectMembership>(TauriCommands.UPDATE_PROJECT_MEMBER_ROLE, {
request,
});
},
async removeProjectMember(
request: RemoveProjectMemberRequest
): Promise<RemoveProjectMemberResponse> {
return invoke<RemoveProjectMemberResponse>(TauriCommands.REMOVE_PROJECT_MEMBER, {
request,
});
},
async listProjectMembers(
request: ListProjectMembersRequest
): Promise<ListProjectMembersResponse> {
return invoke<ListProjectMembersResponse>(TauriCommands.LIST_PROJECT_MEMBERS, {
project_id: request.project_id,
limit: request.limit,
offset: request.offset,
});
},
};
}

View File

@@ -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<ListSummarizationTemplatesResponse> {
return invoke<ListSummarizationTemplatesResponse>(
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<GetSummarizationTemplateResponse> {
return invoke<GetSummarizationTemplateResponse>(TauriCommands.GET_SUMMARIZATION_TEMPLATE, {
template_id: request.template_id,
include_current_version: request.include_current_version ?? true,
});
},
async createSummarizationTemplate(
request: CreateSummarizationTemplateRequest
): Promise<SummarizationTemplateMutationResponse> {
return invoke<SummarizationTemplateMutationResponse>(
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<SummarizationTemplateMutationResponse> {
return invoke<SummarizationTemplateMutationResponse>(
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<SummarizationTemplate> {
return invoke<SummarizationTemplate>(TauriCommands.ARCHIVE_SUMMARIZATION_TEMPLATE, {
template_id: request.template_id,
});
},
async listSummarizationTemplateVersions(
request: ListSummarizationTemplateVersionsRequest
): Promise<ListSummarizationTemplateVersionsResponse> {
return invoke<ListSummarizationTemplateVersionsResponse>(
TauriCommands.LIST_SUMMARIZATION_TEMPLATE_VERSIONS,
{
template_id: request.template_id,
limit: request.limit,
offset: request.offset,
}
);
},
async restoreSummarizationTemplateVersion(
request: RestoreSummarizationTemplateVersionRequest
): Promise<SummarizationTemplate> {
return invoke<SummarizationTemplate>(TauriCommands.RESTORE_SUMMARIZATION_TEMPLATE_VERSION, {
template_id: request.template_id,
version_id: request.version_id,
});
},
async grantCloudConsent(): Promise<void> {
await invoke(TauriCommands.GRANT_CLOUD_CONSENT);
clientLog.cloudConsentGranted();
},
async revokeCloudConsent(): Promise<void> {
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 })
);
},
};
}

View File

@@ -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<void> {
await invoke(TauriCommands.SET_TRIGGER_ENABLED, { enabled });
},
async snoozeTriggers(minutes?: number): Promise<void> {
await invoke(TauriCommands.SNOOZE_TRIGGERS, { minutes });
clientLog.triggersSnoozed(minutes);
},
async resetSnooze(): Promise<void> {
await invoke(TauriCommands.RESET_SNOOZE);
clientLog.triggerSnoozeCleared();
},
async getTriggerStatus(): Promise<TriggerStatus> {
return invoke<TriggerStatus>(TauriCommands.GET_TRIGGER_STATUS);
},
async dismissTrigger(): Promise<void> {
await invoke(TauriCommands.DISMISS_TRIGGER);
},
async acceptTrigger(title?: string): Promise<Meeting> {
return invoke<Meeting>(TauriCommands.ACCEPT_TRIGGER, { title });
},
};
}

View File

@@ -0,0 +1,76 @@
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';
function sanitizeWebhookRequest<T extends {
url?: string;
name?: string;
secret?: string;
events?: unknown[];
}>(request: T): T {
const url = request.url?.trim();
if (request.url !== undefined && !url) {
throw new Error('Webhook URL is required.');
}
if (request.events !== undefined && request.events.length === 0) {
throw new Error('Webhook events are required.');
}
const name = request.name?.trim();
const secret = request.secret?.trim();
return {
...request,
...(url ? { url } : {}),
...(name ? { name } : {}),
...(secret ? { secret } : {}),
};
}
export function createWebhookApi(invoke: TauriInvoke): Pick<
NoteFlowAPI,
'registerWebhook' | 'listWebhooks' | 'updateWebhook' | 'deleteWebhook' | 'getWebhookDeliveries'
> {
return {
async registerWebhook(r: RegisterWebhookRequest): Promise<RegisteredWebhook> {
const request = sanitizeWebhookRequest(r);
const webhook = await invoke<RegisteredWebhook>(TauriCommands.REGISTER_WEBHOOK, {
request,
});
clientLog.webhookRegistered(webhook.id, webhook.name);
return webhook;
},
async listWebhooks(enabledOnly?: boolean): Promise<ListWebhooksResponse> {
return invoke<ListWebhooksResponse>(TauriCommands.LIST_WEBHOOKS, {
enabled_only: enabledOnly ?? false,
});
},
async updateWebhook(r: UpdateWebhookRequest): Promise<RegisteredWebhook> {
const request = sanitizeWebhookRequest(r);
return invoke<RegisteredWebhook>(TauriCommands.UPDATE_WEBHOOK, { request });
},
async deleteWebhook(webhookId: string): Promise<DeleteWebhookResponse> {
const response = await invoke<DeleteWebhookResponse>(TauriCommands.DELETE_WEBHOOK, {
webhook_id: webhookId,
});
clientLog.webhookDeleted(webhookId);
return response;
},
async getWebhookDeliveries(
webhookId: string,
limit?: number
): Promise<GetWebhookDeliveriesResponse> {
return invoke<GetWebhookDeliveriesResponse>(TauriCommands.GET_WEBHOOK_DELIVERIES, {
webhook_id: webhookId,
limit: limit ?? 50,
});
},
};
}

View File

@@ -0,0 +1,273 @@
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;
private readonly drainTimeoutMs = 5000;
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<string, unknown> = {
meeting_id: chunk.meeting_id,
audio_data: 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<void> {
// 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<TranscriptUpdate>(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<void> {
this.isClosed = true;
// Drain the send queue to ensure all pending chunks are transmitted
try {
const drainPromise = this.sendQueue.drain();
const timeoutPromise = new Promise<void>((_, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Queue drain timeout'));
}, this.drainTimeoutMs);
drainPromise.finally(() => clearTimeout(timeoutId));
});
await Promise.race([drainPromise, timeoutPromise]);
} catch {
// Queue drain failed or timed out - 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
}
}
}

View File

@@ -0,0 +1,21 @@
/** Type-safe wrapper for Tauri's invoke function. */
export type TauriInvoke = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
/** Type-safe wrapper for Tauri's event system. */
export type TauriListen = <T>(
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;

View File

@@ -0,0 +1,62 @@
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 {
let message: string;
if (error instanceof Error) {
message = error.message;
} else if (typeof error === 'string') {
message = error;
} else {
try {
message = JSON.stringify(error);
} catch {
message = String(error);
}
}
if (!message.includes(RECORDING_BLOCKED_PREFIX)) {
return null;
}
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;
}

View File

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