From bfa38b36b72e98b21cec973bd2b3bb73cb3ea134 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 30 Jan 2026 14:39:29 -0800 Subject: [PATCH 1/4] Update file filters for export generation and profile export - Modified the file extension filters in useHistory.ts and useProfiles.ts to only allow 'zip' files, removing 'voicebox.zip' for a more streamlined export process. - Added user-selected read-write permission in Entitlements.plist to enhance file handling capabilities. --- app/src/lib/hooks/useHistory.ts | 2 +- app/src/lib/hooks/useProfiles.ts | 2 +- tauri/src-tauri/Entitlements.plist | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/lib/hooks/useHistory.ts b/app/src/lib/hooks/useHistory.ts index b664b26b..c8d147d9 100644 --- a/app/src/lib/hooks/useHistory.ts +++ b/app/src/lib/hooks/useHistory.ts @@ -47,7 +47,7 @@ export function useExportGeneration() { filters: [ { name: 'Voicebox Generation', - extensions: ['voicebox.zip', 'zip'], + extensions: ['zip'], }, ], }); diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts index 1bb1049a..d230ee79 100644 --- a/app/src/lib/hooks/useProfiles.ts +++ b/app/src/lib/hooks/useProfiles.ts @@ -135,7 +135,7 @@ export function useExportProfile() { filters: [ { name: 'Voicebox Profile', - extensions: ['voicebox.zip', 'zip'], + extensions: ['zip'], }, ], }); 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 + From 30352e2419c6b45cdd589b4f0c1385975c657e94 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 30 Jan 2026 14:39:41 -0800 Subject: [PATCH 2/4] formatting --- app/src/lib/hooks/useHistory.ts | 26 ++++++++++++++++---------- app/src/lib/hooks/useProfiles.ts | 8 ++++---- tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/src/lib/hooks/useHistory.ts b/app/src/lib/hooks/useHistory.ts index c8d147d9..88f73d91 100644 --- a/app/src/lib/hooks/useHistory.ts +++ b/app/src/lib/hooks/useHistory.ts @@ -33,11 +33,14 @@ export function useExportGeneration() { 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 { @@ -51,7 +54,7 @@ export function useExportGeneration() { }, ], }); - + if (filePath) { // Write file using Tauri's filesystem API const { writeBinaryFile } = await import('@tauri-apps/plugin-fs'); @@ -81,7 +84,7 @@ export function useExportGeneration() { window.URL.revokeObjectURL(url); document.body.removeChild(a); } - + return blob; }, }); @@ -91,11 +94,14 @@ export function useExportGenerationAudio() { 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 { @@ -109,7 +115,7 @@ export function useExportGenerationAudio() { }, ], }); - + if (filePath) { // Write file using Tauri's filesystem API const { writeBinaryFile } = await import('@tauri-apps/plugin-fs'); @@ -139,7 +145,7 @@ export function useExportGenerationAudio() { window.URL.revokeObjectURL(url); document.body.removeChild(a); } - + return blob; }, }); diff --git a/app/src/lib/hooks/useProfiles.ts b/app/src/lib/hooks/useProfiles.ts index d230ee79..6468170c 100644 --- a/app/src/lib/hooks/useProfiles.ts +++ b/app/src/lib/hooks/useProfiles.ts @@ -120,12 +120,12 @@ export function useExportProfile() { 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 { @@ -139,7 +139,7 @@ export function useExportProfile() { }, ], }); - + if (filePath) { // Write file using Tauri's filesystem API const { writeBinaryFile } = await import('@tauri-apps/plugin-fs'); @@ -169,7 +169,7 @@ export function useExportProfile() { window.URL.revokeObjectURL(url); document.body.removeChild(a); } - + return blob; }, }); diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index c24d3c28d4065a368eba84afddfec6529c2a9f82..2ef2d88aa0d8517eceb95a7c103e5bc7858b5950 100644 GIT binary patch delta 808 zcmZwGyKWOf6b4{B#>7Az6Cfc81{{L9Pdq!M~ z(IBN*m%IUwK#7GA5>;CMC`sk4^l7xx>ilQU%#MHku8-l{&wjgItJRjE1Ix$%`ae2{ zygB)<4<>I+Z_+dT_@n7Gir{5*j&sH>Ii_St%uBqMrIglD7Ibn;yhhaDCLeW3cU}l)p|jhC$9x( zq70U)NS0CQIrA2K%S4$8epyFe(fQ81)vRo7GzzUWc_R}eDbP{OE%OOev&Q7sVu;br z7jm-Xq|Z89AMNk8;2KseGjW6BE2h)2(?G)8(-LfCSC%rX1U38XR7zi$(h;z@w2@TU%qe5^jfXfEX=_4@t=)vy#q0M z_|@(mzH+OFt4V7Y0ENh z?!9?^u(teR#DfvHVFEgE2`1q(Of@6!rpYo};iVFSqRf$ub%82V*OaAng=Hn#@pv7j zv@`$etTg*&_hYA&S!E|vAp8&{qb4-qNl6uQmAG(x!GTq(GM@*%jotZnsa>sESVJ;f zOGd>&zD|imrD9I3ovSW7QDFT)XjFHXI;E;JpLR!CM~NU=Q>Anwt2`IUhoGezYb~p~ z=Ch#1!R}5MuE15e2GcMDv(SUtgfk^VX-Szqd`@+W-In From a6b070201b6efbf2f0cbfb9382a716ff25ce198f Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 30 Jan 2026 15:04:38 -0800 Subject: [PATCH 3/4] Refactor Tauri Integration to Use Platform Context - Replaced direct Tauri API calls with a unified platform context across multiple components, enhancing code maintainability and readability. - Removed the deprecated tauri.ts file, consolidating platform-related logic into the new PlatformContext. - Updated components such as App, AudioPlayer, and ServerSettings to utilize the new platform context for lifecycle management and server interactions. - Improved platform detection and handling for audio playback and system audio capture functionalities. - Ensured consistent error handling and user feedback across the application when interacting with platform-specific features. --- app/src/App.tsx | 39 ++-- .../components/AudioPlayer/AudioPlayer.tsx | 56 +++--- app/src/components/AudioTab/AudioTab.tsx | 13 +- .../ServerSettings/ConnectionForm.tsx | 5 +- .../ServerSettings/UpdateStatus.tsx | 7 +- app/src/components/ServerTab/ServerTab.tsx | 5 +- .../components/VoiceProfiles/ProfileForm.tsx | 9 +- .../components/VoiceProfiles/SampleUpload.tsx | 9 +- app/src/hooks/useAutoUpdater.ts | 181 +++--------------- app/src/lib/hooks/useAudioRecording.ts | 8 +- app/src/lib/hooks/useHistory.ts | 104 ++-------- app/src/lib/hooks/useProfiles.ts | 53 +---- app/src/lib/hooks/useStories.ts | 53 +---- app/src/lib/hooks/useSystemAudioCapture.ts | 50 ++--- app/src/lib/tauri.ts | 108 ----------- app/src/platform/PlatformContext.tsx | 25 +++ app/src/platform/types.ts | 70 +++++++ app/src/router.tsx | 3 +- tauri/src-tauri/gen/Assets.car | Bin 3847048 -> 3847048 bytes tauri/src/main.tsx | 8 +- tauri/src/platform/audio.ts | 45 +++++ tauri/src/platform/filesystem.ts | 30 +++ tauri/src/platform/index.ts | 14 ++ tauri/src/platform/lifecycle.ts | 68 +++++++ tauri/src/platform/metadata.ts | 14 ++ tauri/src/platform/updater.ts | 166 ++++++++++++++++ web/src/main.tsx | 20 +- web/src/platform/audio.ts | 27 +++ web/src/platform/filesystem.ts | 15 ++ web/src/platform/index.ts | 14 ++ web/src/platform/lifecycle.ts | 27 +++ web/src/platform/metadata.ts | 9 + web/src/platform/updater.ts | 44 +++++ 33 files changed, 748 insertions(+), 551 deletions(-) delete mode 100644 app/src/lib/tauri.ts create mode 100644 app/src/platform/PlatformContext.tsx create mode 100644 app/src/platform/types.ts create mode 100644 tauri/src/platform/audio.ts create mode 100644 tauri/src/platform/filesystem.ts create mode 100644 tauri/src/platform/index.ts create mode 100644 tauri/src/platform/lifecycle.ts create mode 100644 tauri/src/platform/metadata.ts create mode 100644 tauri/src/platform/updater.ts create mode 100644 web/src/platform/audio.ts create mode 100644 web/src/platform/filesystem.ts create mode 100644 web/src/platform/index.ts create mode 100644 web/src/platform/lifecycle.ts create mode 100644 web/src/platform/metadata.ts create mode 100644 web/src/platform/updater.ts 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 88f73d91..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,6 +30,8 @@ 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); @@ -41,49 +43,12 @@ export function useExportGeneration() { .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: ['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; }, @@ -91,6 +56,8 @@ export function useExportGeneration() { } export function useExportGenerationAudio() { + const platform = usePlatform(); + return useMutation({ mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => { const blob = await apiClient.exportGenerationAudio(generationId); @@ -102,49 +69,12 @@ export function useExportGenerationAudio() { .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 6468170c..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,6 +117,8 @@ export function useUpdateSample() { } export function useExportProfile() { + const platform = usePlatform(); + return useMutation({ mutationFn: async (profileId: string) => { const blob = await apiClient.exportProfile(profileId); @@ -126,49 +128,12 @@ export function useExportProfile() { 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: ['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/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index 2ef2d88aa0d8517eceb95a7c103e5bc7858b5950..7d0523eebab87e8518bcb2a7af39343dcdae784e 100644 GIT binary patch delta 858 zcmZwGJ5Ll*7zW^3R%E#eDj=+jf{OPK&SlPxW;nMEr6nbuow2mAFtJ!#LhQ6Bu`!_- zO8)_Wz{1XrF-C1@eK)MtI?0ohuX(?jcV-U0eQzAVr?ZXK*`g?BU=FUpB(z`}rcQou zoXs7w@uM}pfAm@HAHC6?#+Q>1`p;MuxRE(@9cjh8R7&T`S!+n}9F-C!&rB2NLI`4m zOQ@I?GiBG=&6h8pAFj9G_Fb><8ZZV;xCG;H87At!Yvu8Z#U#CqiHzlpN*yFQAq;uL zg!f(Mgv_IRgo{C6?CI18RqI`|N>K_a61sf#=mp#Kv(aQVL zcCwFbSaZ8~yz>+YHem}Ga1bD&1XUmJ==x`+y!JA?K*>mt7A>ccJtxg%WS&{i?SB^9 zEaLD3Y4=XI3|MgW>DG4hHD4|Y^%cLs7|vffmx@BqU(9+9=K|)P;rwhc*FQdPzn}OE D%p2m- delta 858 zcmZwGzfTlF6bJA-&XeWmp#lOPa;W%wxY_x+k7jRXb~cojlyr8+(!#>T;%Eu6)1Jh} zgkrDsKj2@m^J0uq6GQ8la8$=8pS-+e^4`pw*@N#t>Id-UbZdR8R;wj21Jf`Ev(SS1 zlRsOhtwY{C>X`kbuX_LJooUzqoqRHXC-UHBbdGbzEjgxSD4CZ?i$Y54C=I%FOe?D` ztCxqHYww5DA5tAApaIvQ3D;q=8d4IQx#YsD;5CanqE8USd>RDH zB6%0M;U=Z=K0+v2T=Z#I2YK?Lk#m=o#&|4FtzgPzfst37xe&Z@fmVWdu`p3QJz+fA?z##P22(6cgPR~+t|XpDo8j5% z@rKHjQeHM3bZvjo&48*wcc<|-U98pgH~az<1PRI 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(); From b6e772c6acc70476578d42a4de80158986aa242f Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Fri, 30 Jan 2026 15:08:34 -0800 Subject: [PATCH 4/4] formatting --- app/src/components/VoiceProfiles/ProfileForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/components/VoiceProfiles/ProfileForm.tsx b/app/src/components/VoiceProfiles/ProfileForm.tsx index e96e19fc..f4fc5711 100644 --- a/app/src/components/VoiceProfiles/ProfileForm.tsx +++ b/app/src/components/VoiceProfiles/ProfileForm.tsx @@ -43,8 +43,8 @@ import { } from '@/lib/hooks/useProfiles'; import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture'; import { useTranscription } from '@/lib/hooks/useTranscription'; -import { usePlatform } from '@/platform/PlatformContext'; import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio'; +import { usePlatform } from '@/platform/PlatformContext'; import { useServerStore } from '@/stores/serverStore'; import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore'; import { AudioSampleRecording } from './AudioSampleRecording';