diff --git a/app/src/App.tsx b/app/src/App.tsx
index dfcf9fd8..7a859159 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -4,15 +4,10 @@ import voiceboxLogo from '@/assets/voicebox-logo.png';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
-import {
- isTauri,
- setKeepServerRunning,
- setupWindowCloseHandler,
- startServer,
-} from '@/lib/tauri';
import { cn } from '@/lib/utils/cn';
import { router } from '@/router';
import { useServerStore } from '@/stores/serverStore';
+import { usePlatform } from '@/platform/PlatformContext';
const LOADING_MESSAGES = [
'Warming up tensors...',
@@ -38,29 +33,38 @@ const LOADING_MESSAGES = [
];
function App() {
+ const platform = usePlatform();
const [serverReady, setServerReady] = useState(false);
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const serverStartingRef = useRef(false);
// Sync stored setting to Rust on startup
useEffect(() => {
- if (isTauri()) {
+ if (platform.metadata.isTauri) {
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
- setKeepServerRunning(keepRunning).catch((error) => {
+ platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
console.error('Failed to sync initial setting to Rust:', error);
});
}
- }, []);
+ }, [platform]);
+
+ // Setup lifecycle callbacks
+ useEffect(() => {
+ platform.lifecycle.onServerReady = () => {
+ setServerReady(true);
+ };
+ }, [platform]);
// Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => {
- if (!isTauri()) {
+ if (!platform.metadata.isTauri) {
+ setServerReady(true); // Web assumes server is running
return;
}
// Setup window close handler to check setting and stop server if needed
// This works in both dev and prod, but will only stop server if it was started by the app
- setupWindowCloseHandler().catch((error) => {
+ platform.lifecycle.setupWindowCloseHandler().catch((error) => {
console.error('Failed to setup window close handler:', error);
});
@@ -83,18 +87,21 @@ function App() {
serverStartingRef.current = true;
console.log('Production mode: Starting bundled server...');
- startServer(false)
+ platform.lifecycle
+ .startServer(false)
.then((serverUrl) => {
console.log('Server is ready at:', serverUrl);
// Update the server URL in the store with the dynamically assigned port
useServerStore.getState().setServerUrl(serverUrl);
setServerReady(true);
// Mark that we started the server (so we know to stop it on close)
+ // @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = true;
})
.catch((error) => {
console.error('Failed to auto-start server:', error);
serverStartingRef.current = false;
+ // @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
});
@@ -104,11 +111,11 @@ function App() {
// Window close event handles server shutdown based on setting
serverStartingRef.current = false;
};
- }, []);
+ }, [platform]);
// Cycle through loading messages every 3 seconds
useEffect(() => {
- if (!isTauri() || serverReady) {
+ if (!platform.metadata.isTauri || serverReady) {
return;
}
@@ -117,10 +124,10 @@ function App() {
}, 3000);
return () => clearInterval(interval);
- }, [serverReady]);
+ }, [serverReady, platform.metadata.isTauri]);
// Show loading screen while server is starting in Tauri
- if (isTauri() && !serverReady) {
+ if (platform.metadata.isTauri && !serverReady) {
return (
{
- if (!isTauri() || !profileChannels || !channels) {
+ if (!platform.metadata.isTauri || !profileChannels || !channels) {
return false;
}
@@ -195,7 +195,7 @@ export function AudioPlayer() {
let runtimeProfileChannels = null;
let runtimeChannels = null;
- if (isTauri() && currentProfileId) {
+ if (platform.metadata.isTauri && currentProfileId) {
try {
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
debug.log('Runtime profileChannels:', runtimeProfileChannels);
@@ -210,7 +210,7 @@ export function AudioPlayer() {
}
debug.log('Auto-play check:', {
- isTauri: isTauri(),
+ isTauri: platform.metadata.isTauri,
currentAudioUrl,
currentProfileId,
hasProfileChannels: !!runtimeProfileChannels,
@@ -218,7 +218,7 @@ export function AudioPlayer() {
});
if (
- isTauri() &&
+ platform.metadata.isTauri &&
currentAudioUrl &&
currentProfileId &&
runtimeProfileChannels &&
@@ -229,7 +229,7 @@ export function AudioPlayer() {
// Stop any existing native playback first
if (isUsingNativePlaybackRef.current) {
try {
- await invoke('stop_audio_playback');
+ platform.audio.stopPlayback();
debug.log('Stopped existing native playback before starting new one');
} catch (error) {
debug.error('Failed to stop existing playback:', error);
@@ -279,11 +279,8 @@ export function AudioPlayer() {
// Play via native audio
debug.log('Invoking play_audio_to_devices...');
try {
- const result = await invoke('play_audio_to_devices', {
- audioData: Array.from(audioData),
- deviceIds: deviceIds,
- });
- debug.log('play_audio_to_devices completed successfully, result:', result);
+ await platform.audio.playToDevices(audioData, deviceIds);
+ debug.log('play_audio_to_devices completed successfully');
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
@@ -516,15 +513,13 @@ export function AudioPlayer() {
}
// Stop native playback if it was active
- if (isUsingNativePlaybackRef.current && isTauri()) {
- (async () => {
- try {
- await invoke('stop_audio_playback');
- debug.log('Stopped native audio playback');
- } catch (error) {
- debug.error('Failed to stop native playback:', error);
- }
- })();
+ if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
+ try {
+ platform.audio.stopPlayback();
+ debug.log('Stopped native audio playback');
+ } catch (error) {
+ debug.error('Failed to stop native playback:', error);
+ }
}
// Reset native playback flag when loading new audio
@@ -711,7 +706,7 @@ export function AudioPlayer() {
if (isPlaying) {
// Pause: stop native playback and pause WaveSurfer visualization
try {
- await invoke('stop_audio_playback');
+ platform.audio.stopPlayback();
debug.log('Stopped native audio playback');
} catch (error) {
debug.error('Failed to stop native playback:', error);
@@ -724,7 +719,7 @@ export function AudioPlayer() {
try {
// Stop any existing native playback first
try {
- await invoke('stop_audio_playback');
+ platform.audio.stopPlayback();
} catch (_error) {
// Ignore errors when stopping (might not be playing)
debug.log('No existing playback to stop');
@@ -742,10 +737,7 @@ export function AudioPlayer() {
const audioData = new Uint8Array(await response.arrayBuffer());
// Play via native audio
- await invoke('play_audio_to_devices', {
- audioData: Array.from(audioData),
- deviceIds: deviceIds,
- });
+ await platform.audio.playToDevices(audioData, deviceIds);
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
@@ -806,10 +798,12 @@ export function AudioPlayer() {
const handleClose = () => {
// Stop any native playback
- if (isUsingNativePlaybackRef.current && isTauri()) {
- invoke('stop_audio_playback').catch((error) => {
+ if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
+ try {
+ platform.audio.stopPlayback();
+ } catch (error) {
debug.error('Failed to stop native playback:', error);
- });
+ }
}
// Stop WaveSurfer
if (wavesurferRef.current) {
diff --git a/app/src/components/AudioTab/AudioTab.tsx b/app/src/components/AudioTab/AudioTab.tsx
index 9d802467..f76e99d7 100644
--- a/app/src/components/AudioTab/AudioTab.tsx
+++ b/app/src/components/AudioTab/AudioTab.tsx
@@ -1,5 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { invoke } from '@tauri-apps/api/core';
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
@@ -23,9 +22,9 @@ import {
} from '@/components/ui/select';
import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
-import { isTauri } from '@/lib/tauri';
import { cn } from '@/lib/utils/cn';
import { usePlayerStore } from '@/stores/playerStore';
+import { usePlatform } from '@/platform/PlatformContext';
interface AudioDevice {
id: string;
@@ -34,6 +33,7 @@ interface AudioDevice {
}
export function AudioTab() {
+ const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState
(null);
const [selectedChannelId, setSelectedChannelId] = useState(null);
@@ -49,18 +49,17 @@ export function AudioTab() {
const { data: devices, isLoading: devicesLoading } = useQuery({
queryKey: ['audio-devices'],
queryFn: async () => {
- if (!isTauri()) {
+ if (!platform.metadata.isTauri) {
return [];
}
try {
- const result = await invoke('list_audio_output_devices');
- return result;
+ return await platform.audio.listOutputDevices();
} catch (error) {
console.error('Failed to list audio devices:', error);
return [];
}
},
- enabled: isTauri(),
+ enabled: platform.metadata.isTauri,
});
const { data: profiles } = useQuery({
@@ -342,7 +341,7 @@ export function AudioTab() {
- {isTauri() ? 'No audio devices found' : 'Audio device selection requires Tauri'}
+ {platform.metadata.isTauri ? 'No audio devices found' : 'Audio device selection requires Tauri'}
)}
diff --git a/app/src/components/ServerSettings/ConnectionForm.tsx b/app/src/components/ServerSettings/ConnectionForm.tsx
index 84cf9dac..9e25a52d 100644
--- a/app/src/components/ServerSettings/ConnectionForm.tsx
+++ b/app/src/components/ServerSettings/ConnectionForm.tsx
@@ -17,7 +17,7 @@ import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { useToast } from '@/components/ui/use-toast';
import { useServerStore } from '@/stores/serverStore';
-import { setKeepServerRunning } from '@/lib/tauri';
+import { usePlatform } from '@/platform/PlatformContext';
const connectionSchema = z.object({
serverUrl: z.string().url('Please enter a valid URL'),
@@ -26,6 +26,7 @@ const connectionSchema = z.object({
type ConnectionFormValues = z.infer;
export function ConnectionForm() {
+ const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const setServerUrl = useServerStore((state) => state.setServerUrl);
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
@@ -89,7 +90,7 @@ export function ConnectionForm() {
checked={keepServerRunningOnClose}
onCheckedChange={(checked: boolean) => {
setKeepServerRunningOnClose(checked);
- setKeepServerRunning(checked).catch((error) => {
+ platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
console.error('Failed to sync setting to Rust:', error);
});
toast({
diff --git a/app/src/components/ServerSettings/UpdateStatus.tsx b/app/src/components/ServerSettings/UpdateStatus.tsx
index d01782be..f0a2e9fc 100644
--- a/app/src/components/ServerSettings/UpdateStatus.tsx
+++ b/app/src/components/ServerSettings/UpdateStatus.tsx
@@ -1,4 +1,3 @@
-import { getVersion } from '@tauri-apps/api/app';
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Badge } from '@/components/ui/badge';
@@ -6,16 +5,18 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
+import { usePlatform } from '@/platform/PlatformContext';
export function UpdateStatus() {
+ const platform = usePlatform();
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
const [currentVersion, setCurrentVersion] = useState('');
useEffect(() => {
- getVersion()
+ platform.metadata.getVersion()
.then(setCurrentVersion)
.catch(() => setCurrentVersion('0.1.0'));
- }, []);
+ }, [platform]);
return (
diff --git a/app/src/components/ServerTab/ServerTab.tsx b/app/src/components/ServerTab/ServerTab.tsx
index 6534e3c4..abf91ac2 100644
--- a/app/src/components/ServerTab/ServerTab.tsx
+++ b/app/src/components/ServerTab/ServerTab.tsx
@@ -1,16 +1,17 @@
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
-import { isTauri } from '@/lib/tauri';
+import { usePlatform } from '@/platform/PlatformContext';
export function ServerTab() {
+ const platform = usePlatform();
return (
- {isTauri() &&
}
+ {platform.metadata.isTauri &&
}
Created by{' '}
state.profileDialogOpen);
const setOpen = useUIStore((state) => state.setProfileDialogOpen);
const editingProfileId = useUIStore((state) => state.editingProfileId);
@@ -664,7 +665,7 @@ export function ProfileForm() {
}}
>
@@ -674,7 +675,7 @@ export function ProfileForm() {
Record
- {isTauri() && isSystemAudioSupported && (
+ {platform.metadata.isTauri && isSystemAudioSupported && (
System Audio
@@ -726,7 +727,7 @@ export function ProfileForm() {
/>
- {isTauri() && isSystemAudioSupported && (
+ {platform.metadata.isTauri && isSystemAudioSupported && (
setMode(v as 'upload' | 'record' | 'system')}>
@@ -242,7 +243,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
Record
- {isTauri() && isSystemAudioSupported && (
+ {platform.metadata.isTauri && isSystemAudioSupported && (
System Audio
@@ -289,7 +290,7 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
/>
- {isTauri() && isSystemAudioSupported && (
+ {platform.metadata.isTauri && isSystemAudioSupported && (
{
- return navigator.userAgent.includes('Windows');
-};
-
-const isTauri = () => {
- return '__TAURI_INTERNALS__' in window;
-};
+// Re-export UpdateStatus for backwards compatibility
+export type { UpdateStatus };
export function useAutoUpdater(checkOnMount = false) {
- const [status, setStatus] = useState({
- checking: false,
- available: false,
- downloading: false,
- installing: false,
- readyToInstall: false,
- });
+ const platform = usePlatform();
+ const [status, setStatus] = useState(
+ platform.updater.getStatus(),
+ );
- const [update, setUpdate] = useState(null);
+ // Subscribe to updater status changes
+ useEffect(() => {
+ const unsubscribe = platform.updater.subscribe((newStatus) => {
+ setStatus(newStatus);
+ });
+ return unsubscribe;
+ }, [platform]);
const checkForUpdates = useCallback(async () => {
- if (!isTauri()) {
- return;
- }
+ await platform.updater.checkForUpdates();
+ }, [platform]);
- try {
- setStatus((prev) => ({ ...prev, checking: true, error: undefined }));
+ const downloadAndInstall = useCallback(async () => {
+ await platform.updater.downloadAndInstall();
+ }, [platform]);
- const foundUpdate = await check();
-
- if (foundUpdate?.available) {
- setUpdate(foundUpdate);
- setStatus({
- checking: false,
- available: true,
- version: foundUpdate.version,
- downloading: false,
- installing: false,
- readyToInstall: false,
- });
- } else {
- setStatus({
- checking: false,
- available: false,
- downloading: false,
- installing: false,
- readyToInstall: false,
- });
- }
- } catch (error) {
- setStatus({
- checking: false,
- available: false,
- downloading: false,
- installing: false,
- readyToInstall: false,
- error: error instanceof Error ? error.message : 'Failed to check for updates',
- });
- }
- }, []);
-
- // Download the update (but don't install yet)
- const downloadAndInstall = async () => {
- if (!update || !isTauri()) return;
-
- try {
- setStatus((prev) => ({ ...prev, downloading: true, error: undefined }));
-
- let downloadedBytes = 0;
- let totalBytes = 0;
-
- // Just download the update
- await update.download((event) => {
- switch (event.event) {
- case 'Started':
- totalBytes = event.data.contentLength || 0;
- downloadedBytes = 0;
- setStatus((prev) => ({
- ...prev,
- downloading: true,
- totalBytes,
- downloadedBytes: 0,
- downloadProgress: 0,
- }));
- break;
- case 'Progress': {
- downloadedBytes += event.data.chunkLength;
- const progress =
- totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : undefined;
- setStatus((prev) => ({
- ...prev,
- downloadedBytes,
- downloadProgress: progress,
- }));
- break;
- }
- case 'Finished':
- setStatus((prev) => ({
- ...prev,
- downloading: false,
- readyToInstall: true,
- downloadProgress: 100,
- }));
- break;
- }
- });
- } catch (error) {
- setStatus((prev) => ({
- ...prev,
- downloading: false,
- installing: false,
- readyToInstall: false,
- downloadProgress: undefined,
- downloadedBytes: undefined,
- totalBytes: undefined,
- error: error instanceof Error ? error.message : 'Failed to download update',
- }));
- }
- };
-
- // Install the downloaded update and restart the app
- const restartAndInstall = async () => {
- if (!update || !isTauri()) return;
-
- try {
- setStatus((prev) => ({ ...prev, installing: true, error: undefined }));
-
- // Install the update
- await update.install();
-
- // On Windows with NSIS, the installer handles the restart automatically.
- // The process will be killed by the NSIS installer, so we won't reach here.
- // On macOS/Linux, we need to manually relaunch.
- if (!isWindows()) {
- await relaunch();
- }
- // If we're on Windows and somehow still running, the NSIS installer
- // should have already handled everything. Just wait for the process to end.
- } catch (error) {
- setStatus((prev) => ({
- ...prev,
- installing: false,
- error: error instanceof Error ? error.message : 'Failed to install update',
- }));
- }
- };
+ const restartAndInstall = useCallback(async () => {
+ await platform.updater.restartAndInstall();
+ }, [platform]);
useEffect(() => {
- if (checkOnMount && isTauri()) {
+ if (checkOnMount && platform.metadata.isTauri) {
checkForUpdates();
}
- }, [checkOnMount, checkForUpdates]);
+ }, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
return {
status,
diff --git a/app/src/lib/hooks/useAudioRecording.ts b/app/src/lib/hooks/useAudioRecording.ts
index 8e2160f3..2916937c 100644
--- a/app/src/lib/hooks/useAudioRecording.ts
+++ b/app/src/lib/hooks/useAudioRecording.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { isTauri } from '@/lib/tauri';
+import { usePlatform } from '@/platform/PlatformContext';
import { convertToWav } from '@/lib/utils/audio';
interface UseAudioRecordingOptions {
@@ -11,6 +11,7 @@ export function useAudioRecording({
maxDurationSeconds = 29,
onRecordingComplete,
}: UseAudioRecordingOptions = {}) {
+ const platform = usePlatform();
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const [error, setError] = useState(null);
@@ -40,15 +41,14 @@ export function useAudioRecording({
await new Promise((resolve) => setTimeout(resolve, 100));
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- const isTauriEnv = isTauri();
console.error('MediaDevices check:', {
hasNavigator: typeof navigator !== 'undefined',
hasMediaDevices: !!navigator?.mediaDevices,
hasGetUserMedia: !!navigator?.mediaDevices?.getUserMedia,
- isTauri: isTauriEnv,
+ isTauri: platform.metadata.isTauri,
});
- const errorMsg = isTauriEnv
+ const errorMsg = platform.metadata.isTauri
? 'Microphone access is not available. Please ensure:\n1. The app has microphone permissions in System Settings (macOS: System Settings > Privacy & Security > Microphone)\n2. You restart the app after granting permissions\n3. You are using Tauri v2 with a webview that supports getUserMedia'
: 'Microphone access is not available. Please ensure you are using a secure context (HTTPS or localhost) and that your browser has microphone permissions enabled.';
setError(errorMsg);
diff --git a/app/src/lib/hooks/useHistory.ts b/app/src/lib/hooks/useHistory.ts
index b664b26b..ec0c20cc 100644
--- a/app/src/lib/hooks/useHistory.ts
+++ b/app/src/lib/hooks/useHistory.ts
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { HistoryQuery } from '@/lib/api/types';
-import { isTauri } from '@/lib/tauri';
+import { usePlatform } from '@/platform/PlatformContext';
export function useHistory(query?: HistoryQuery) {
return useQuery({
@@ -30,116 +30,52 @@ export function useDeleteGeneration() {
}
export function useExportGeneration() {
+ const platform = usePlatform();
+
return useMutation({
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGeneration(generationId);
-
+
// Create safe filename from text
- const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
+ const safeText = text
+ .substring(0, 30)
+ .replace(/[^a-z0-9]/gi, '-')
+ .toLowerCase();
const filename = `generation-${safeText}.voicebox.zip`;
-
- if (isTauri()) {
- // Use Tauri's native save dialog
- try {
- const { save } = await import('@tauri-apps/plugin-dialog');
- const filePath = await save({
- defaultPath: filename,
- filters: [
- {
- name: 'Voicebox Generation',
- extensions: ['voicebox.zip', 'zip'],
- },
- ],
- });
-
- if (filePath) {
- // Write file using Tauri's filesystem API
- const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
- const arrayBuffer = await blob.arrayBuffer();
- await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
- }
- } catch (error) {
- console.error('Failed to use Tauri dialog, falling back to browser download:', error);
- // Fall back to browser download if Tauri dialog fails
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
- } else {
- // Browser: trigger download
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
-
+
+ await platform.filesystem.saveFile(filename, blob, [
+ {
+ name: 'Voicebox Generation',
+ extensions: ['zip'],
+ },
+ ]);
+
return blob;
},
});
}
export function useExportGenerationAudio() {
+ const platform = usePlatform();
+
return useMutation({
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGenerationAudio(generationId);
-
+
// Create safe filename from text
- const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
+ const safeText = text
+ .substring(0, 30)
+ .replace(/[^a-z0-9]/gi, '-')
+ .toLowerCase();
const filename = `${safeText}.wav`;
-
- if (isTauri()) {
- // Use Tauri's native save dialog
- try {
- const { save } = await import('@tauri-apps/plugin-dialog');
- const filePath = await save({
- defaultPath: filename,
- filters: [
- {
- name: 'Audio File',
- extensions: ['wav'],
- },
- ],
- });
-
- if (filePath) {
- // Write file using Tauri's filesystem API
- const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
- const arrayBuffer = await blob.arrayBuffer();
- await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
- }
- } catch (error) {
- console.error('Failed to use Tauri dialog, falling back to browser download:', error);
- // Fall back to browser download if Tauri dialog fails
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
- } else {
- // Browser: trigger download
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
-
+
+ await platform.filesystem.saveFile(filename, blob, [
+ {
+ name: 'Audio File',
+ extensions: ['wav'],
+ },
+ ]);
+
return blob;
},
});
diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts
index 1bb1049a..f05fd999 100644
--- a/app/src/lib/hooks/useProfiles.ts
+++ b/app/src/lib/hooks/useProfiles.ts
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { VoiceProfileCreate } from '@/lib/api/types';
-import { isTauri } from '@/lib/tauri';
+import { usePlatform } from '@/platform/PlatformContext';
export function useProfiles() {
return useQuery({
@@ -117,59 +117,24 @@ export function useUpdateSample() {
}
export function useExportProfile() {
+ const platform = usePlatform();
+
return useMutation({
mutationFn: async (profileId: string) => {
const blob = await apiClient.exportProfile(profileId);
-
+
// Get profile name for filename
const profile = await apiClient.getProfile(profileId);
const safeName = profile.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
const filename = `profile-${safeName}.voicebox.zip`;
-
- if (isTauri()) {
- // Use Tauri's native save dialog
- try {
- const { save } = await import('@tauri-apps/plugin-dialog');
- const filePath = await save({
- defaultPath: filename,
- filters: [
- {
- name: 'Voicebox Profile',
- extensions: ['voicebox.zip', 'zip'],
- },
- ],
- });
-
- if (filePath) {
- // Write file using Tauri's filesystem API
- const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
- const arrayBuffer = await blob.arrayBuffer();
- await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
- }
- } catch (error) {
- console.error('Failed to use Tauri dialog, falling back to browser download:', error);
- // Fall back to browser download if Tauri dialog fails
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
- } else {
- // Browser: trigger download
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
-
+
+ await platform.filesystem.saveFile(filename, blob, [
+ {
+ name: 'Voicebox Profile',
+ extensions: ['zip'],
+ },
+ ]);
+
return blob;
},
});
diff --git a/app/src/lib/hooks/useStories.ts b/app/src/lib/hooks/useStories.ts
index 8210cb2b..2b35f381 100644
--- a/app/src/lib/hooks/useStories.ts
+++ b/app/src/lib/hooks/useStories.ts
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { StoryCreate, StoryItemCreate, StoryItemBatchUpdate, StoryItemReorder, StoryItemMove, StoryItemTrim, StoryItemSplit } from '@/lib/api/types';
-import { isTauri } from '@/lib/tauri';
+import { usePlatform } from '@/platform/PlatformContext';
export function useStories() {
return useQuery({
@@ -158,6 +158,8 @@ export function useDuplicateStoryItem() {
}
export function useExportStoryAudio() {
+ const platform = usePlatform();
+
return useMutation({
mutationFn: async ({ storyId, storyName }: { storyId: string; storyName: string }) => {
const blob = await apiClient.exportStoryAudio(storyId);
@@ -166,49 +168,12 @@ export function useExportStoryAudio() {
const safeName = storyName.substring(0, 50).replace(/[^a-z0-9]/gi, '-').toLowerCase();
const filename = `${safeName || 'story'}.wav`;
- if (isTauri()) {
- // Use Tauri's native save dialog
- try {
- const { save } = await import('@tauri-apps/plugin-dialog');
- const filePath = await save({
- defaultPath: filename,
- filters: [
- {
- name: 'Audio File',
- extensions: ['wav'],
- },
- ],
- });
-
- if (filePath) {
- // Write file using Tauri's filesystem API
- const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
- const arrayBuffer = await blob.arrayBuffer();
- await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
- }
- } catch (error) {
- console.error('Failed to use Tauri dialog, falling back to browser download:', error);
- // Fall back to browser download if Tauri dialog fails
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
- } else {
- // Browser: trigger download
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- }
+ await platform.filesystem.saveFile(filename, blob, [
+ {
+ name: 'Audio File',
+ extensions: ['wav'],
+ },
+ ]);
return blob;
},
diff --git a/app/src/lib/hooks/useSystemAudioCapture.ts b/app/src/lib/hooks/useSystemAudioCapture.ts
index e27cdeae..c8112845 100644
--- a/app/src/lib/hooks/useSystemAudioCapture.ts
+++ b/app/src/lib/hooks/useSystemAudioCapture.ts
@@ -1,6 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react';
-import { invoke } from '@tauri-apps/api/core';
-import { isTauri } from '@/lib/tauri';
+import { usePlatform } from '@/platform/PlatformContext';
interface UseSystemAudioCaptureOptions {
maxDurationSeconds?: number;
@@ -15,6 +14,7 @@ export function useSystemAudioCapture({
maxDurationSeconds = 29,
onRecordingComplete,
}: UseSystemAudioCaptureOptions = {}) {
+ const platform = usePlatform();
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const [error, setError] = useState(null);
@@ -26,22 +26,12 @@ export function useSystemAudioCapture({
// Check if system audio capture is supported
useEffect(() => {
- if (!isTauri()) {
- setIsSupported(false);
- return;
- }
-
- invoke('is_system_audio_supported')
- .then((supported) => {
- setIsSupported(supported);
- })
- .catch(() => {
- setIsSupported(false);
- });
- }, []);
+ const supported = platform.audio.isSystemAudioSupported();
+ setIsSupported(supported);
+ }, [platform]);
const startRecording = useCallback(async () => {
- if (!isTauri()) {
+ if (!platform.metadata.isTauri) {
const errorMsg = 'System audio capture is only available in the desktop app.';
setError(errorMsg);
return;
@@ -58,9 +48,7 @@ export function useSystemAudioCapture({
setDuration(0);
// Start native capture
- await invoke('start_system_audio_capture', {
- maxDurationSecs: maxDurationSeconds,
- });
+ await platform.audio.startSystemAudioCapture(maxDurationSeconds);
setIsRecording(true);
isRecordingRef.current = true;
@@ -86,10 +74,10 @@ export function useSystemAudioCapture({
setError(errorMessage);
setIsRecording(false);
}
- }, [maxDurationSeconds, isSupported]);
+ }, [maxDurationSeconds, isSupported, platform]);
const stopRecording = useCallback(async () => {
- if (!isRecording || !isTauri()) {
+ if (!isRecording || !platform.metadata.isTauri) {
return;
}
@@ -102,17 +90,9 @@ export function useSystemAudioCapture({
timerRef.current = null;
}
- // Stop capture and get base64 WAV data
- const base64Data = await invoke('stop_system_audio_capture');
+ // Stop capture and get Blob
+ const blob = await platform.audio.stopSystemAudioCapture();
- // 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);
- }
-
- const blob = new Blob([bytes], { type: 'audio/wav' });
// Pass the actual recorded duration
const recordedDuration = startTimeRef.current
? (Date.now() - startTimeRef.current) / 1000
@@ -125,7 +105,7 @@ export function useSystemAudioCapture({
: 'Failed to stop system audio capture.';
setError(errorMessage);
}
- }, [isRecording, onRecordingComplete]);
+ }, [isRecording, onRecordingComplete, platform]);
// Store stopRecording in ref for use in timer
useEffect(() => {
@@ -155,15 +135,15 @@ export function useSystemAudioCapture({
timerRef.current = null;
}
// Cancel recording on unmount if still recording
- if (isRecordingRef.current && isTauri()) {
+ if (isRecordingRef.current && platform.metadata.isTauri) {
// Call stop directly without the callback to avoid stale closure
- invoke('stop_system_audio_capture').catch((err) => {
+ platform.audio.stopSystemAudioCapture().catch((err) => {
console.error('Error stopping audio capture on unmount:', err);
});
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on unmount
- }, []);
+ }, [platform]);
return {
isRecording,
diff --git a/app/src/lib/tauri.ts b/app/src/lib/tauri.ts
deleted file mode 100644
index 3e6e9795..00000000
--- a/app/src/lib/tauri.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * Tauri integration utilities
- */
-
-import { invoke } from '@tauri-apps/api/core';
-import { listen, emit } from '@tauri-apps/api/event';
-
-/**
- * Check if running in Tauri environment
- */
-export function isTauri(): boolean {
- return '__TAURI_INTERNALS__' in window;
-}
-
-/**
- * Check if running on macOS
- */
-export function isMacOS(): boolean {
- return navigator.platform.toLowerCase().includes('mac');
-}
-
-/**
- * Start the bundled Python server (Tauri only)
- */
-export async function startServer(remote = false): Promise {
- if (!isTauri()) {
- throw new Error('Not running in Tauri environment');
- }
-
- try {
- const result = await invoke('start_server', { remote });
- console.log('Server started:', result);
- return result;
- } catch (error) {
- console.error('Failed to start server:', error);
- throw error;
- }
-}
-
-/**
- * Stop the bundled Python server (Tauri only)
- */
-export async function stopServer(): Promise {
- if (!isTauri()) {
- throw new Error('Not running in Tauri environment');
- }
-
- try {
- await invoke('stop_server');
- console.log('Server stopped');
- } catch (error) {
- console.error('Failed to stop server:', error);
- throw error;
- }
-}
-
-/**
- * Set whether the server should keep running when the app closes (Tauri only)
- */
-export async function setKeepServerRunning(keepRunning: boolean): Promise {
- if (!isTauri()) {
- return;
- }
-
- try {
- await invoke('set_keep_server_running', { keepRunning });
- } catch (error) {
- console.error('Failed to set keep server running setting:', error);
- }
-}
-
-/**
- * Setup window close handler to check setting and stop server if needed
- */
-export async function setupWindowCloseHandler(): Promise {
- if (!isTauri()) {
- return;
- }
-
- try {
- // Listen for window close request from Rust
- await listen('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
- // In dev mode, serverStartedByApp will be false, so we won't try to stop a separately-run server
- // We need to access the module-level variable - this is a bit hacky but works
- // @ts-expect-error - accessing module-level variable from another module
- const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
-
- if (!keepRunning && serverStartedByApp) {
- // Stop server before closing (only if we started it)
- try {
- await 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);
- }
-}
diff --git a/app/src/platform/PlatformContext.tsx b/app/src/platform/PlatformContext.tsx
new file mode 100644
index 00000000..86153e7d
--- /dev/null
+++ b/app/src/platform/PlatformContext.tsx
@@ -0,0 +1,25 @@
+import { createContext, useContext, type ReactNode } from 'react';
+import type { Platform } from './types';
+
+const PlatformContext = createContext(null);
+
+export interface PlatformProviderProps {
+ platform: Platform;
+ children: ReactNode;
+}
+
+export function PlatformProvider({ platform, children }: PlatformProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function usePlatform(): Platform {
+ const platform = useContext(PlatformContext);
+ if (!platform) {
+ throw new Error('usePlatform must be used within PlatformProvider');
+ }
+ return platform;
+}
diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts
new file mode 100644
index 00000000..5ea4d609
--- /dev/null
+++ b/app/src/platform/types.ts
@@ -0,0 +1,70 @@
+/**
+ * Platform abstraction types
+ * These interfaces define the contract that platform implementations must fulfill
+ */
+
+export interface FileFilter {
+ name: string;
+ extensions: string[];
+}
+
+export interface PlatformFilesystem {
+ saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise;
+}
+
+export interface UpdateStatus {
+ checking: boolean;
+ available: boolean;
+ version?: string;
+ downloading: boolean;
+ installing: boolean;
+ readyToInstall: boolean;
+ error?: string;
+ downloadProgress?: number; // 0-100 percentage
+ downloadedBytes?: number;
+ totalBytes?: number;
+}
+
+export interface PlatformUpdater {
+ checkForUpdates(): Promise;
+ downloadAndInstall(): Promise;
+ restartAndInstall(): Promise;
+ getStatus(): UpdateStatus;
+ subscribe(callback: (status: UpdateStatus) => void): () => void;
+}
+
+export interface AudioDevice {
+ id: string;
+ name: string;
+ is_default: boolean;
+}
+
+export interface PlatformAudio {
+ isSystemAudioSupported(): boolean;
+ startSystemAudioCapture(maxDurationSecs: number): Promise;
+ stopSystemAudioCapture(): Promise;
+ listOutputDevices(): Promise;
+ playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise;
+ stopPlayback(): void;
+}
+
+export interface PlatformLifecycle {
+ startServer(remote?: boolean): Promise;
+ stopServer(): Promise;
+ setKeepServerRunning(keep: boolean): Promise;
+ setupWindowCloseHandler(): Promise;
+ onServerReady?: () => void;
+}
+
+export interface PlatformMetadata {
+ getVersion(): Promise;
+ isTauri: boolean;
+}
+
+export interface Platform {
+ filesystem: PlatformFilesystem;
+ updater: PlatformUpdater;
+ audio: PlatformAudio;
+ lifecycle: PlatformLifecycle;
+ metadata: PlatformMetadata;
+}
diff --git a/app/src/router.tsx b/app/src/router.tsx
index af8eb068..dbf94038 100644
--- a/app/src/router.tsx
+++ b/app/src/router.tsx
@@ -10,7 +10,8 @@ import { Toaster } from '@/components/ui/toaster';
import { VoicesTab } from '@/components/VoicesTab/VoicesTab';
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
import { MODEL_DISPLAY_NAMES, useRestoreActiveTasks } from '@/lib/hooks/useRestoreActiveTasks';
-import { isMacOS } from '@/lib/tauri';
+// Simple platform check that works in both web and Tauri
+const isMacOS = () => navigator.platform.toLowerCase().includes('mac');
// Root layout component
function RootLayout() {
diff --git a/tauri/src-tauri/Entitlements.plist b/tauri/src-tauri/Entitlements.plist
index 0d041c2c..ee1823c2 100644
--- a/tauri/src-tauri/Entitlements.plist
+++ b/tauri/src-tauri/Entitlements.plist
@@ -10,5 +10,7 @@
com.apple.security.device.audio-input
+ com.apple.security.files.user-selected.read-write
+
diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car
index c24d3c28..7d0523ee 100644
Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ
diff --git a/tauri/src/main.tsx b/tauri/src/main.tsx
index 43f613b2..398fc57e 100644
--- a/tauri/src/main.tsx
+++ b/tauri/src/main.tsx
@@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
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: {
@@ -20,8 +22,10 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render(
-
- {/* */}
+
+
+ {/* */}
+
,
);
diff --git a/tauri/src/platform/audio.ts b/tauri/src/platform/audio.ts
new file mode 100644
index 00000000..bdcf0b27
--- /dev/null
+++ b/tauri/src/platform/audio.ts
@@ -0,0 +1,45 @@
+import { invoke } from '@tauri-apps/api/core';
+import type { PlatformAudio, AudioDevice } from '@/platform/types';
+
+export const tauriAudio: PlatformAudio = {
+ isSystemAudioSupported(): boolean {
+ // This will be checked dynamically via invoke
+ return true; // Tauri supports it, but actual support depends on platform
+ },
+
+ async startSystemAudioCapture(maxDurationSecs: number): Promise {
+ await invoke('start_system_audio_capture', {
+ maxDurationSecs,
+ });
+ },
+
+ async stopSystemAudioCapture(): Promise {
+ const base64Data = await invoke('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 {
+ return await invoke('list_audio_output_devices');
+ },
+
+ async playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise {
+ 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);
+ });
+ },
+};
diff --git a/tauri/src/platform/filesystem.ts b/tauri/src/platform/filesystem.ts
new file mode 100644
index 00000000..e37b4d18
--- /dev/null
+++ b/tauri/src/platform/filesystem.ts
@@ -0,0 +1,30 @@
+import type { PlatformFilesystem, FileFilter } from '@/platform/types';
+
+export const tauriFilesystem: PlatformFilesystem = {
+ async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
+ try {
+ const { save } = await import('@tauri-apps/plugin-dialog');
+ const filePath = await save({
+ defaultPath: filename,
+ filters: filters || [],
+ });
+
+ if (filePath) {
+ const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
+ const arrayBuffer = await blob.arrayBuffer();
+ await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
+ }
+ } catch (error) {
+ console.error('Failed to use Tauri dialog, falling back to browser download:', error);
+ // Fall back to browser download if Tauri dialog fails
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ }
+ },
+};
diff --git a/tauri/src/platform/index.ts b/tauri/src/platform/index.ts
new file mode 100644
index 00000000..9e564138
--- /dev/null
+++ b/tauri/src/platform/index.ts
@@ -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,
+};
diff --git a/tauri/src/platform/lifecycle.ts b/tauri/src/platform/lifecycle.ts
new file mode 100644
index 00000000..562c75aa
--- /dev/null
+++ b/tauri/src/platform/lifecycle.ts
@@ -0,0 +1,68 @@
+import { invoke } from '@tauri-apps/api/core';
+import { listen, emit } from '@tauri-apps/api/event';
+import type { PlatformLifecycle } from '@/platform/types';
+
+class TauriLifecycle implements PlatformLifecycle {
+ onServerReady?: () => void;
+
+ async startServer(remote = false): Promise {
+ try {
+ const result = await invoke('start_server', { remote });
+ console.log('Server started:', result);
+ this.onServerReady?.();
+ return result;
+ } catch (error) {
+ console.error('Failed to start server:', error);
+ throw error;
+ }
+ }
+
+ async stopServer(): Promise {
+ try {
+ await invoke('stop_server');
+ console.log('Server stopped');
+ } catch (error) {
+ console.error('Failed to stop server:', error);
+ throw error;
+ }
+ }
+
+ async setKeepServerRunning(keepRunning: boolean): Promise {
+ try {
+ await invoke('set_keep_server_running', { keepRunning });
+ } catch (error) {
+ console.error('Failed to set keep server running setting:', error);
+ }
+ }
+
+ async setupWindowCloseHandler(): Promise {
+ try {
+ // Listen for window close request from Rust
+ await listen('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;
+
+ 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);
+ }
+ }
+}
+
+export const tauriLifecycle = new TauriLifecycle();
diff --git a/tauri/src/platform/metadata.ts b/tauri/src/platform/metadata.ts
new file mode 100644
index 00000000..cb12ac9e
--- /dev/null
+++ b/tauri/src/platform/metadata.ts
@@ -0,0 +1,14 @@
+import { getVersion } from '@tauri-apps/api/app';
+import type { PlatformMetadata } from '@/platform/types';
+
+export const tauriMetadata: PlatformMetadata = {
+ async getVersion(): Promise {
+ try {
+ return await getVersion();
+ } catch (error) {
+ console.error('Failed to get version:', error);
+ return '0.1.0';
+ }
+ },
+ isTauri: true,
+};
diff --git a/tauri/src/platform/updater.ts b/tauri/src/platform/updater.ts
new file mode 100644
index 00000000..24a1b6b8
--- /dev/null
+++ b/tauri/src/platform/updater.ts
@@ -0,0 +1,166 @@
+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 {
+ 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) {
+ this.status = {
+ checking: false,
+ available: false,
+ downloading: false,
+ installing: false,
+ readyToInstall: false,
+ error: error instanceof Error ? error.message : 'Failed to check for updates',
+ };
+ this.notifySubscribers();
+ }
+ }
+
+ async downloadAndInstall(): Promise {
+ 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 {
+ 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();
diff --git a/web/src/main.tsx b/web/src/main.tsx
index bf0584df..771f07e8 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,10 +1,28 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from '../../app/src/App';
import '../../app/src/index.css';
+import { PlatformProvider } from '../../app/src/platform/PlatformContext';
+import { webPlatform } 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(
-
+
+
+
+
+
,
);
diff --git a/web/src/platform/audio.ts b/web/src/platform/audio.ts
new file mode 100644
index 00000000..220a5ade
--- /dev/null
+++ b/web/src/platform/audio.ts
@@ -0,0 +1,27 @@
+import type { PlatformAudio, AudioDevice } from '@/platform/types';
+
+export const webAudio: PlatformAudio = {
+ isSystemAudioSupported(): boolean {
+ return false; // System audio capture not supported in web
+ },
+
+ async startSystemAudioCapture(_maxDurationSecs: number): Promise {
+ throw new Error('System audio capture is only available in the desktop app.');
+ },
+
+ async stopSystemAudioCapture(): Promise {
+ throw new Error('System audio capture is only available in the desktop app.');
+ },
+
+ async listOutputDevices(): Promise {
+ return []; // No native device routing in web
+ },
+
+ async playToDevices(_audioData: Uint8Array, _deviceIds: string[]): Promise {
+ throw new Error('Native audio device routing is only available in the desktop app.');
+ },
+
+ stopPlayback(): void {
+ // No-op for web
+ },
+};
diff --git a/web/src/platform/filesystem.ts b/web/src/platform/filesystem.ts
new file mode 100644
index 00000000..1f45a49c
--- /dev/null
+++ b/web/src/platform/filesystem.ts
@@ -0,0 +1,15 @@
+import type { PlatformFilesystem, FileFilter } from '@/platform/types';
+
+export const webFilesystem: PlatformFilesystem = {
+ async saveFile(filename: string, blob: Blob, _filters?: FileFilter[]) {
+ // Browser: trigger download
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ },
+};
diff --git a/web/src/platform/index.ts b/web/src/platform/index.ts
new file mode 100644
index 00000000..f6dcb0ac
--- /dev/null
+++ b/web/src/platform/index.ts
@@ -0,0 +1,14 @@
+import type { Platform } from '@/platform/types';
+import { webFilesystem } from './filesystem';
+import { webUpdater } from './updater';
+import { webAudio } from './audio';
+import { webLifecycle } from './lifecycle';
+import { webMetadata } from './metadata';
+
+export const webPlatform: Platform = {
+ filesystem: webFilesystem,
+ updater: webUpdater,
+ audio: webAudio,
+ lifecycle: webLifecycle,
+ metadata: webMetadata,
+};
diff --git a/web/src/platform/lifecycle.ts b/web/src/platform/lifecycle.ts
new file mode 100644
index 00000000..c5e9ea6e
--- /dev/null
+++ b/web/src/platform/lifecycle.ts
@@ -0,0 +1,27 @@
+import type { PlatformLifecycle } from '@/platform/types';
+
+class WebLifecycle implements PlatformLifecycle {
+ onServerReady?: () => void;
+
+ async startServer(_remote = false): Promise {
+ // Web assumes server is running externally
+ // Return a default URL - this should be configured via env vars
+ const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:17493';
+ this.onServerReady?.();
+ return serverUrl;
+ }
+
+ async stopServer(): Promise {
+ // No-op for web - server is managed externally
+ }
+
+ async setKeepServerRunning(_keep: boolean): Promise {
+ // No-op for web
+ }
+
+ async setupWindowCloseHandler(): Promise {
+ // No-op for web - no window close handling needed
+ }
+}
+
+export const webLifecycle = new WebLifecycle();
diff --git a/web/src/platform/metadata.ts b/web/src/platform/metadata.ts
new file mode 100644
index 00000000..d8a73f89
--- /dev/null
+++ b/web/src/platform/metadata.ts
@@ -0,0 +1,9 @@
+import type { PlatformMetadata } from '@/platform/types';
+
+export const webMetadata: PlatformMetadata = {
+ async getVersion(): Promise {
+ // Return version from env var or package.json
+ return import.meta.env.VITE_APP_VERSION || '0.1.0';
+ },
+ isTauri: false,
+};
diff --git a/web/src/platform/updater.ts b/web/src/platform/updater.ts
new file mode 100644
index 00000000..32ed0148
--- /dev/null
+++ b/web/src/platform/updater.ts
@@ -0,0 +1,44 @@
+import type { PlatformUpdater, UpdateStatus } from '@/platform/types';
+
+class WebUpdater implements PlatformUpdater {
+ private status: UpdateStatus = {
+ checking: false,
+ available: false,
+ downloading: false,
+ installing: false,
+ readyToInstall: false,
+ };
+
+ 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);
+ callback(this.status);
+ return () => {
+ this.subscribers.delete(callback);
+ };
+ }
+
+ getStatus(): UpdateStatus {
+ return { ...this.status };
+ }
+
+ async checkForUpdates(): Promise {
+ // Web apps don't need client-side updates
+ // Updates are handled by redeploying the web app
+ }
+
+ async downloadAndInstall(): Promise {
+ // No-op for web
+ }
+
+ async restartAndInstall(): Promise {
+ // No-op for web
+ }
+}
+
+export const webUpdater = new WebUpdater();