/** * App auto-update hook. * * Owns: * - the state machine for the Tauri shell updater * (idle | checking | available | downloading | ready_to_install | * installing | restarting | up_to_date | error) * - listeners on the `app-update:status` + `app-update:progress` events * emitted by the Rust download/install commands * - an opt-in auto-check cadence: one probe shortly after launch, then * a periodic re-probe while the app stays open * - an opt-in auto-download: when a check reports "available", the hook * automatically calls `download_app_update` so the user only sees a * "Restart to apply" prompt — never a "click to start downloading" one * * Pairs with the Rust side in `app/src-tauri/src/lib.rs` (`check_app_update`, * `download_app_update`, `install_app_update`). See `gitbooks/overview/auto-update.md`. */ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { useCallback, useEffect, useRef, useState } from 'react'; import { applyAppUpdate, type AppUpdateInfo, checkAppUpdate, downloadAppUpdate, installAppUpdate, isTauri, } from '../utils/tauriCommands'; /** Phases driven by `app-update:status`, plus locally-derived ones. */ export type AppUpdatePhase = | 'idle' | 'checking' | 'available' | 'downloading' | 'ready_to_install' | 'installing' | 'restarting' | 'up_to_date' | 'error'; export interface AppUpdateProgress { /** Bytes received in the latest chunk callback. */ chunk: number; /** Total bytes (null when the manifest didn't advertise a content-length). */ total: number | null; } export interface UseAppUpdateOptions { /** * Run an automatic check shortly after the hook mounts. * Default: true. Skipped when `isTauri()` is false. */ autoCheck?: boolean; /** Delay before the first auto-check fires, in ms. Default: 5_000. */ initialCheckDelayMs?: number; /** * Repeat interval between background checks, in ms. Default: 15 * 60 * 1000. * Set to 0 (or a negative number) to disable repeating. */ recheckIntervalMs?: number; /** * When a check reports an available update, automatically start the * download in the background so the user is only ever prompted to * restart. Default: true. */ autoDownload?: boolean; } export interface UseAppUpdateResult { phase: AppUpdatePhase; /** Last successful check result (current/available versions, body). */ info: AppUpdateInfo | null; /** Bytes downloaded so far (sum of every `app-update:progress` chunk this run). */ bytesDownloaded: number; /** Latest `total` reported by the updater (may stay null). */ totalBytes: number | null; /** Last error message, if any phase landed on `error`. */ error: string | null; /** Manually run a check (does not download). */ check: () => Promise; /** * Start a background download. Normally called automatically when a check * reports an available update; exposed so callers can retry on error. */ download: () => Promise; /** * Install previously-downloaded bytes and restart. Never resolves on * success (the process exits mid-await). Falls back to {@link apply} * if no download has been staged. */ install: () => Promise; /** * Legacy combined download+install+restart. Prefer the auto-download flow * above; kept for callers that want a single explicit "do everything" * action. */ apply: () => Promise; /** Reset transient state (error, downloaded bytes) without changing `info`. */ reset: () => void; } const DEFAULT_INITIAL_DELAY_MS = 5_000; const DEFAULT_RECHECK_INTERVAL_MS = 15 * 60 * 1000; // 15m /** A short grace before the auto-download fires, so the UI can show the * fact that an update was *detected* (briefly) before going into "downloading" * state. Cosmetic, not load-bearing. */ const AUTO_DOWNLOAD_GRACE_MS = 1_000; /** * Translate a raw `app-update:status` payload into our phase enum, defaulting * to `error` for any unrecognized string so we don't silently swallow a bad * payload from the Rust side. */ function parseStatusPayload(raw: unknown): AppUpdatePhase { if (raw === 'checking') return 'checking'; if (raw === 'downloading') return 'downloading'; if (raw === 'ready_to_install') return 'ready_to_install'; if (raw === 'installing') return 'installing'; if (raw === 'restarting') return 'restarting'; if (raw === 'up_to_date') return 'up_to_date'; if (raw === 'error') return 'error'; console.warn('[app-update] hook: unknown status payload', raw); return 'error'; } export function useAppUpdate(options: UseAppUpdateOptions = {}): UseAppUpdateResult { const { autoCheck = true, initialCheckDelayMs = DEFAULT_INITIAL_DELAY_MS, recheckIntervalMs = DEFAULT_RECHECK_INTERVAL_MS, autoDownload = true, } = options; const [phase, setPhase] = useState('idle'); const [info, setInfo] = useState(null); const [bytesDownloaded, setBytesDownloaded] = useState(0); const [totalBytes, setTotalBytes] = useState(null); const [error, setError] = useState(null); // Refs to keep callbacks stable + survive React 18 strict-mode double-invoke. const mountedRef = useRef(true); const phaseRef = useRef(phase); phaseRef.current = phase; // Tracks whether we've already kicked off a download for the current // `available` detection so the auto-download effect doesn't loop on // re-renders. const downloadInFlightRef = useRef(false); // Tracks whether bytes have been staged successfully. `install()` checks // this so it can fall back to the legacy combined apply path if the user // reaches "install" without a prior download (e.g. error mid-flow). const stagedRef = useRef(false); /** Probe the updater endpoint. Does not download. */ const check = useCallback(async (): Promise => { if (!isTauri()) { console.debug('[app-update] hook.check: skipped — not running in Tauri'); return null; } if ( phaseRef.current === 'downloading' || phaseRef.current === 'installing' || phaseRef.current === 'ready_to_install' ) { console.debug('[app-update] hook.check: skipped — flow in progress', phaseRef.current); return null; } console.debug('[app-update] hook.check: starting'); setPhase('checking'); setError(null); try { const result = await checkAppUpdate(); if (!mountedRef.current) return result; if (result?.available) { console.info( `[app-update] hook.check: update available ${result.current_version} -> ${result.available_version}` ); setInfo(result); setPhase('available'); } else { console.debug('[app-update] hook.check: up to date'); setInfo(result); setPhase('up_to_date'); } return result; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn('[app-update] hook.check: failed', message); if (mountedRef.current) { setError(message); setPhase('error'); } return null; } }, []); /** Download bytes in the background. Normally fires automatically. */ const download = useCallback(async (): Promise => { if (!isTauri()) { console.debug('[app-update] hook.download: skipped — not running in Tauri'); return; } if (downloadInFlightRef.current) { console.debug('[app-update] hook.download: already in flight'); return; } downloadInFlightRef.current = true; stagedRef.current = false; setBytesDownloaded(0); setTotalBytes(null); setError(null); console.info('[app-update] hook.download: starting'); try { const result = await downloadAppUpdate(); if (!mountedRef.current) return; if (result?.ready) { stagedRef.current = true; console.info(`[app-update] hook.download: staged ${result.version}`); // The Rust side has already emitted `ready_to_install`. The status // listener will move us into that phase; nothing else to do here. } else { console.debug('[app-update] hook.download: nothing to download'); } } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error('[app-update] hook.download: failed', message); if (mountedRef.current) { setError(message); setPhase('error'); } } finally { downloadInFlightRef.current = false; } }, []); /** Install the staged bytes and restart. Falls back to `apply()` if nothing is staged. */ const install = useCallback(async (): Promise => { if (!isTauri()) { console.debug('[app-update] hook.install: skipped — not running in Tauri'); return; } if (!stagedRef.current) { console.warn('[app-update] hook.install: no staged update — falling back to apply'); try { await applyAppUpdate(); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error('[app-update] hook.install: apply fallback failed', message); if (mountedRef.current) { setError(message); setPhase('error'); } } return; } console.info('[app-update] hook.install: starting'); setError(null); // The Rust side consumes the staged bytes via `slot.take()` before // calling `Update::install`, so once we invoke install_app_update the // backend no longer has a pending update — keep `stagedRef` in sync so // a retry after a transient install failure falls back to the legacy // `apply` path (fresh check + download + install) instead of looping // on a now-empty Rust state slot. stagedRef.current = false; try { await installAppUpdate(); console.debug('[app-update] hook.install: returned without restart'); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error('[app-update] hook.install: failed', message); // Defensive — the early clear above already handled this, but if a // future change moves the install_app_update call without resetting // the ref, this guarantees retries don't reuse a consumed staging. stagedRef.current = false; if (mountedRef.current) { setError(message); setPhase('error'); } } }, []); /** * Legacy combined download+install+restart. Prefer the auto-download flow. * Restarts the process mid-promise on success. */ const apply = useCallback(async (): Promise => { if (!isTauri()) { console.debug('[app-update] hook.apply: skipped — not running in Tauri'); return; } console.info('[app-update] hook.apply: starting (legacy path)'); setBytesDownloaded(0); setTotalBytes(null); setError(null); setPhase('checking'); try { await applyAppUpdate(); console.debug('[app-update] hook.apply: returned without restart'); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error('[app-update] hook.apply: failed', message); if (mountedRef.current) { setError(message); setPhase('error'); } } }, []); const reset = useCallback(() => { console.debug('[app-update] hook.reset'); setError(null); setBytesDownloaded(0); setTotalBytes(null); if ( phaseRef.current === 'error' || phaseRef.current === 'up_to_date' || phaseRef.current === 'available' ) { setPhase('idle'); } }, []); // Subscribe to Rust-side updater events for the lifetime of the hook. useEffect(() => { if (!isTauri()) return; mountedRef.current = true; let unlistenStatus: UnlistenFn | undefined; let unlistenProgress: UnlistenFn | undefined; let cancelled = false; (async () => { try { unlistenStatus = await listen('app-update:status', event => { if (!mountedRef.current) return; const next = parseStatusPayload(event.payload); console.debug('[app-update] hook: status →', next); setPhase(next); if (next === 'downloading') { setBytesDownloaded(0); setTotalBytes(null); setError(null); } if (next === 'ready_to_install') { stagedRef.current = true; } if (next === 'error') { setError(prev => prev ?? 'Update failed. See logs for details.'); } }); unlistenProgress = await listen('app-update:progress', event => { if (!mountedRef.current) return; const { chunk, total } = event.payload ?? { chunk: 0, total: null }; setBytesDownloaded(prev => prev + (typeof chunk === 'number' ? chunk : 0)); if (typeof total === 'number') setTotalBytes(total); }); if (cancelled) { unlistenStatus?.(); unlistenProgress?.(); } } catch (err) { console.debug('[app-update] hook: failed to attach listeners', err); } })(); return () => { cancelled = true; mountedRef.current = false; unlistenStatus?.(); unlistenProgress?.(); }; }, []); // Auto-check cadence: one delayed probe, then a periodic re-probe. useEffect(() => { if (!autoCheck || !isTauri()) return; const initialTimer = setTimeout( () => { void check(); }, Math.max(0, initialCheckDelayMs) ); let recheckTimer: ReturnType | undefined; if (recheckIntervalMs > 0) { recheckTimer = setInterval(() => { void check(); }, recheckIntervalMs); } return () => { clearTimeout(initialTimer); if (recheckTimer) clearInterval(recheckTimer); }; }, [autoCheck, initialCheckDelayMs, recheckIntervalMs, check]); // Auto-download: when a check transitions us to `available`, kick off a // background download so the user is only ever asked to restart, never to // download. useEffect(() => { if (!autoDownload || !isTauri()) return; if (phase !== 'available') return; if (downloadInFlightRef.current) return; const timer = setTimeout(() => { void download(); }, AUTO_DOWNLOAD_GRACE_MS); return () => clearTimeout(timer); }, [autoDownload, phase, download]); return { phase, info, bytesDownloaded, totalBytes, error, check, download, install, apply, reset, }; }