generated from Labyricorn/labyricorn-project-template
Initial commit (forked from jamiepine/voicebox)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import App from '@/App';
|
||||
// Import CSS from app directory using alias so Tailwind can scan the source files
|
||||
import '@/index.css';
|
||||
import { PlatformProvider } from '@/platform/PlatformContext';
|
||||
import { tauriPlatform } from './platform';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PlatformProvider platform={tauriPlatform}>
|
||||
<App />
|
||||
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
|
||||
</PlatformProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { PlatformAudio, AudioDevice } from '@/platform/types';
|
||||
|
||||
export const tauriAudio: PlatformAudio = {
|
||||
async isSystemAudioSupported(): Promise<boolean> {
|
||||
return await invoke<boolean>('is_system_audio_supported');
|
||||
},
|
||||
|
||||
async startSystemAudioCapture(maxDurationSecs: number): Promise<void> {
|
||||
await invoke('start_system_audio_capture', {
|
||||
maxDurationSecs,
|
||||
});
|
||||
},
|
||||
|
||||
async stopSystemAudioCapture(): Promise<Blob> {
|
||||
const base64Data = await invoke<string>('stop_system_audio_capture');
|
||||
|
||||
// Convert base64 to Blob
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new Blob([bytes], { type: 'audio/wav' });
|
||||
},
|
||||
|
||||
async listOutputDevices(): Promise<AudioDevice[]> {
|
||||
return await invoke<AudioDevice[]>('list_audio_output_devices');
|
||||
},
|
||||
|
||||
async playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise<void> {
|
||||
await invoke('play_audio_to_devices', {
|
||||
audioData: Array.from(audioData),
|
||||
deviceIds,
|
||||
});
|
||||
},
|
||||
|
||||
stopPlayback(): void {
|
||||
invoke('stop_audio_playback').catch((error) => {
|
||||
console.error('Failed to stop audio playback:', error);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FileFilter, PlatformFilesystem } from '@/platform/types';
|
||||
|
||||
export const tauriFilesystem: PlatformFilesystem = {
|
||||
async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
|
||||
const { save } = await import('@tauri-apps/plugin-dialog');
|
||||
const { writeFile } = await import('@tauri-apps/plugin-fs');
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: filename,
|
||||
filters: filters || [],
|
||||
});
|
||||
|
||||
if (!filePath) return; // User cancelled the dialog
|
||||
|
||||
const resolvedPath =
|
||||
typeof filePath === 'string' ? filePath : (filePath as { path: string }).path;
|
||||
|
||||
if (!resolvedPath) {
|
||||
throw new Error('Failed to resolve save path from dialog');
|
||||
}
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
|
||||
},
|
||||
|
||||
async openPath(path: string) {
|
||||
const { open } = await import('@tauri-apps/plugin-shell');
|
||||
await open(path);
|
||||
},
|
||||
|
||||
async pickDirectory(title: string) {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({ directory: true, title });
|
||||
if (!selected) return null;
|
||||
const dir = typeof selected === 'string' ? selected : (selected as { path: string }).path;
|
||||
return dir || null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Platform } from '@/platform/types';
|
||||
import { tauriFilesystem } from './filesystem';
|
||||
import { tauriUpdater } from './updater';
|
||||
import { tauriAudio } from './audio';
|
||||
import { tauriLifecycle } from './lifecycle';
|
||||
import { tauriMetadata } from './metadata';
|
||||
|
||||
export const tauriPlatform: Platform = {
|
||||
filesystem: tauriFilesystem,
|
||||
updater: tauriUpdater,
|
||||
audio: tauriAudio,
|
||||
lifecycle: tauriLifecycle,
|
||||
metadata: tauriMetadata,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import type { PlatformLifecycle, ServerLogEntry } from '@/platform/types';
|
||||
|
||||
class TauriLifecycle implements PlatformLifecycle {
|
||||
onServerReady?: () => void;
|
||||
|
||||
async startServer(remote = false, modelsDir?: string | null): Promise<string> {
|
||||
try {
|
||||
const result = await invoke<string>('start_server', {
|
||||
remote,
|
||||
modelsDir: modelsDir ?? undefined,
|
||||
});
|
||||
console.log('Server started:', result);
|
||||
this.onServerReady?.();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stopServer(): Promise<void> {
|
||||
try {
|
||||
await invoke('stop_server');
|
||||
console.log('Server stopped');
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async restartServer(modelsDir?: string | null): Promise<string> {
|
||||
try {
|
||||
const result = await invoke<string>('restart_server', {
|
||||
modelsDir: modelsDir ?? undefined,
|
||||
});
|
||||
console.log('Server restarted:', result);
|
||||
this.onServerReady?.();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to restart server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setKeepServerRunning(keepRunning: boolean): Promise<void> {
|
||||
try {
|
||||
await invoke('set_keep_server_running', { keepRunning });
|
||||
} catch (error) {
|
||||
console.error('Failed to set keep server running setting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async setBackendOverride(backend?: string | null): Promise<void> {
|
||||
try {
|
||||
await invoke('set_backend_override', { backend: backend ?? undefined });
|
||||
} catch (error) {
|
||||
console.error('Failed to set backend override:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setupWindowCloseHandler(): Promise<void> {
|
||||
try {
|
||||
// Listen for window close request from Rust
|
||||
await listen<null>('window-close-requested', async () => {
|
||||
// Import store here to avoid circular dependency
|
||||
const { useServerStore } = await import('@/stores/serverStore');
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
|
||||
// Check if server was started by this app instance
|
||||
// @ts-expect-error - accessing module-level variable from another module
|
||||
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
||||
|
||||
console.log(
|
||||
'[lifecycle] window-close-requested: keepRunning=%s, serverStartedByApp=%s',
|
||||
keepRunning,
|
||||
serverStartedByApp,
|
||||
);
|
||||
|
||||
if (!keepRunning && serverStartedByApp) {
|
||||
// Stop server before closing (only if we started it)
|
||||
try {
|
||||
await this.stopServer();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server on close:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event back to Rust to allow close
|
||||
await emit('window-close-allowed');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
void listen<ServerLogEntry>('server-log', (event) => {
|
||||
callback(event.payload);
|
||||
})
|
||||
.then((fn) => {
|
||||
if (disposed) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
unlisten = fn;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to subscribe to server logs:', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const tauriLifecycle = new TauriLifecycle();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { PlatformMetadata } from '@/platform/types';
|
||||
|
||||
export const tauriMetadata: PlatformMetadata = {
|
||||
async getVersion(): Promise<string> {
|
||||
try {
|
||||
return await getVersion();
|
||||
} catch (error) {
|
||||
console.error('Failed to get version:', error);
|
||||
return '0.1.0';
|
||||
}
|
||||
},
|
||||
isTauri: true,
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { check, type Update } from '@tauri-apps/plugin-updater';
|
||||
import type { PlatformUpdater, UpdateStatus } from '@/platform/types';
|
||||
|
||||
// Check if we're on Windows (NSIS installer handles restart automatically)
|
||||
const isWindows = () => {
|
||||
return navigator.userAgent.includes('Windows');
|
||||
};
|
||||
|
||||
class TauriUpdater implements PlatformUpdater {
|
||||
private status: UpdateStatus = {
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
};
|
||||
|
||||
private update: Update | null = null;
|
||||
private subscribers: Set<(status: UpdateStatus) => void> = new Set();
|
||||
|
||||
private notifySubscribers() {
|
||||
this.subscribers.forEach((callback) => callback(this.status));
|
||||
}
|
||||
|
||||
subscribe(callback: (status: UpdateStatus) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
// Immediately call with current status
|
||||
callback(this.status);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
getStatus(): UpdateStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<void> {
|
||||
try {
|
||||
this.status = { ...this.status, checking: true, error: undefined };
|
||||
this.notifySubscribers();
|
||||
|
||||
const foundUpdate = await check();
|
||||
|
||||
if (foundUpdate?.available) {
|
||||
this.update = foundUpdate;
|
||||
this.status = {
|
||||
checking: false,
|
||||
available: true,
|
||||
version: foundUpdate.version,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
};
|
||||
} else {
|
||||
this.status = {
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
};
|
||||
}
|
||||
this.notifySubscribers();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Tauri updater throws on 404 / no published release / network errors.
|
||||
// Treat "no update available" style errors as up-to-date, not failures.
|
||||
const isNoUpdate = /404|not found|no update|up.to.date/i.test(message);
|
||||
this.status = {
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
error: isNoUpdate ? undefined : message,
|
||||
};
|
||||
this.notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAndInstall(): Promise<void> {
|
||||
if (!this.update) return;
|
||||
|
||||
try {
|
||||
this.status = { ...this.status, downloading: true, error: undefined };
|
||||
this.notifySubscribers();
|
||||
|
||||
let downloadedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
await this.update.download((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
totalBytes = event.data.contentLength || 0;
|
||||
downloadedBytes = 0;
|
||||
this.status = {
|
||||
...this.status,
|
||||
downloading: true,
|
||||
totalBytes,
|
||||
downloadedBytes: 0,
|
||||
downloadProgress: 0,
|
||||
};
|
||||
this.notifySubscribers();
|
||||
break;
|
||||
case 'Progress': {
|
||||
downloadedBytes += event.data.chunkLength;
|
||||
const progress =
|
||||
totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
|
||||
this.status = {
|
||||
...this.status,
|
||||
downloadedBytes,
|
||||
downloadProgress: progress,
|
||||
};
|
||||
this.notifySubscribers();
|
||||
break;
|
||||
}
|
||||
case 'Finished':
|
||||
this.status = {
|
||||
...this.status,
|
||||
downloading: false,
|
||||
readyToInstall: true,
|
||||
downloadProgress: 100,
|
||||
};
|
||||
this.notifySubscribers();
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.status = {
|
||||
...this.status,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
downloadProgress: undefined,
|
||||
downloadedBytes: undefined,
|
||||
totalBytes: undefined,
|
||||
error: error instanceof Error ? error.message : 'Failed to download update',
|
||||
};
|
||||
this.notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
async restartAndInstall(): Promise<void> {
|
||||
if (!this.update) return;
|
||||
|
||||
try {
|
||||
this.status = { ...this.status, installing: true, error: undefined };
|
||||
this.notifySubscribers();
|
||||
|
||||
await this.update.install();
|
||||
|
||||
// On Windows with NSIS, the installer handles the restart automatically.
|
||||
// On macOS/Linux, we need to manually relaunch.
|
||||
if (!isWindows()) {
|
||||
await relaunch();
|
||||
}
|
||||
} catch (error) {
|
||||
this.status = {
|
||||
...this.status,
|
||||
installing: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||
};
|
||||
this.notifySubscribers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tauriUpdater = new TauriUpdater();
|
||||
Reference in New Issue
Block a user