diff --git a/app/src/App.tsx b/app/src/App.tsx index 7a859159..7ea797df 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils/cn'; import { router } from '@/router'; import { useServerStore } from '@/stores/serverStore'; import { usePlatform } from '@/platform/PlatformContext'; +import { useAutoUpdater } from '@/hooks/useAutoUpdater'; const LOADING_MESSAGES = [ 'Warming up tensors...', @@ -38,6 +39,9 @@ function App() { const [loadingMessageIndex, setLoadingMessageIndex] = useState(0); const serverStartingRef = useRef(false); + // Automatically check for app updates on startup and show toast notifications + useAutoUpdater({ checkOnMount: true, showToast: true }); + // Sync stored setting to Rust on startup useEffect(() => { if (platform.metadata.isTauri) { diff --git a/app/src/components/ServerSettings/UpdateStatus.tsx b/app/src/components/ServerSettings/UpdateStatus.tsx index f0a2e9fc..a3d832aa 100644 --- a/app/src/components/ServerSettings/UpdateStatus.tsx +++ b/app/src/components/ServerSettings/UpdateStatus.tsx @@ -13,9 +13,10 @@ export function UpdateStatus() { const [currentVersion, setCurrentVersion] = useState(''); useEffect(() => { - platform.metadata.getVersion() + platform.metadata + .getVersion() .then(setCurrentVersion) - .catch(() => setCurrentVersion('0.1.0')); + .catch(() => setCurrentVersion('Unknown')); }, [platform]); return ( diff --git a/app/src/hooks/useAutoUpdater.tsx b/app/src/hooks/useAutoUpdater.tsx new file mode 100644 index 00000000..a7562115 --- /dev/null +++ b/app/src/hooks/useAutoUpdater.tsx @@ -0,0 +1,202 @@ +import { Download, RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Progress } from '@/components/ui/progress'; +import { ToastAction } from '@/components/ui/toast'; +import { useToast } from '@/components/ui/use-toast'; +import { usePlatform } from '@/platform/PlatformContext'; +import type { UpdateStatus } from '@/platform/types'; + +// Re-export UpdateStatus for backwards compatibility +export type { UpdateStatus }; + +interface UseAutoUpdaterOptions { + checkOnMount?: boolean; + showToast?: boolean; +} + +export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) { + // Support both old boolean API and new options object + const { checkOnMount, showToast } = + typeof options === 'boolean' + ? { checkOnMount: options, showToast: false } + : { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false }; + + const platform = usePlatform(); + const { toast } = useToast(); + const [status, setStatus] = useState(platform.updater.getStatus()); + const hasCheckedRef = useRef(false); + const toastIdRef = useRef(null); + const toastUpdateRef = useRef< + | ((props: { + title?: React.ReactNode; + description?: React.ReactNode; + duration?: number; + variant?: 'default' | 'destructive'; + open?: boolean; + action?: React.ReactElement; + }) => void) + | null + >(null); + + // Subscribe to updater status changes + useEffect(() => { + const unsubscribe = platform.updater.subscribe((newStatus) => { + setStatus(newStatus); + }); + return unsubscribe; + }, [platform]); + + const checkForUpdates = useCallback(async () => { + await platform.updater.checkForUpdates(); + }, [platform]); + + const downloadAndInstall = useCallback(async () => { + await platform.updater.downloadAndInstall(); + }, [platform]); + + const restartAndInstall = useCallback(async () => { + await platform.updater.restartAndInstall(); + }, [platform]); + + // Check for updates on mount + useEffect(() => { + if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) { + hasCheckedRef.current = true; + checkForUpdates().catch((error) => { + console.error('Auto update check failed:', error); + }); + } + }, [checkOnMount, checkForUpdates, platform.metadata.isTauri]); + + // Show toast when update is available + useEffect(() => { + if ( + !showToast || + !status.available || + status.downloading || + status.readyToInstall || + toastIdRef.current + ) { + return; + } + + const handleUpdateNow = async () => { + await downloadAndInstall(); + }; + + const toastResult = toast({ + title: 'Update Available', + description: `Version ${status.version} is ready to download.`, + duration: Infinity, + action: ( + + Update Now + + ), + }); + + toastIdRef.current = toastResult.id; + // Type assertion needed because update function has broader type than our ref + toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current; + }, [ + showToast, + status.available, + status.downloading, + status.readyToInstall, + status.version, + downloadAndInstall, + toast, + ]); + + // Update toast when downloading + useEffect(() => { + if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) { + return; + } + + const progressPercent = status.downloadProgress || 0; + const progressText = + status.downloadedBytes !== undefined && + status.totalBytes !== undefined && + status.totalBytes > 0 + ? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB` + : ''; + + toastUpdateRef.current({ + title: ( +
+ + Downloading Update +
+ ), + description: ( +
+
Version {status.version}
+ {progressPercent > 0 && ( + <> + + {progressText &&
{progressText}
} + + )} +
+ ), + duration: Infinity, + }); + }, [ + showToast, + status.downloading, + status.downloadProgress, + status.downloadedBytes, + status.totalBytes, + status.version, + ]); + + // Update toast when ready to install + useEffect(() => { + if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) { + return; + } + + const handleRestartNow = async () => { + await restartAndInstall(); + }; + + toastUpdateRef.current({ + title: 'Update Ready', + description: `Version ${status.version} has been downloaded and is ready to install.`, + duration: Infinity, + action: ( + + + Restart Now + + ), + }); + }, [showToast, status.readyToInstall, status.version, restartAndInstall]); + + // Handle errors in toast + useEffect(() => { + if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) { + return; + } + + toastUpdateRef.current({ + title: 'Update Failed', + description: status.error, + variant: 'destructive', + duration: 5000, + }); + + setTimeout(() => { + toastIdRef.current = null; + toastUpdateRef.current = null; + }, 5000); + }, [showToast, status.error]); + + return { + status, + checkForUpdates, + downloadAndInstall, + restartAndInstall, + }; +} diff --git a/bun.lock b/bun.lock index bd8425fe..9e08a825 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "app": { "name": "@voicebox/app", - "version": "0.1.9", + "version": "0.1.11", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -68,7 +68,7 @@ }, "landing": { "name": "@voicebox/landing", - "version": "0.1.9", + "version": "0.1.11", "dependencies": { "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", @@ -93,10 +93,14 @@ }, "tauri": { "name": "@voicebox/tauri", - "version": "0.1.9", + "version": "0.1.11", "dependencies": { "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-fs": "^2.0.0", + "@tauri-apps/plugin-process": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", + "@tauri-apps/plugin-updater": "^2.0.0", }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", @@ -112,7 +116,7 @@ }, "web": { "name": "@voicebox/web", - "version": "0.1.9", + "version": "0.1.11", "dependencies": { "@tanstack/react-query": "^5.0.0", "react": "^18.3.0", diff --git a/tauri/package.json b/tauri/package.json index 0e71a085..44794d97 100644 --- a/tauri/package.json +++ b/tauri/package.json @@ -10,7 +10,11 @@ }, "dependencies": { "@tauri-apps/api": "^2.0.0", - "@tauri-apps/plugin-shell": "^2.0.0" + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-fs": "^2.0.0", + "@tauri-apps/plugin-process": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "@tauri-apps/plugin-updater": "^2.0.0" }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index e867da32..0ebcba94 100644 Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ