/** * First-run bootstrap splash. * * Two data sources drive this UI: * 1. `bootstrap_status` Tauri command (polled every 1 s) — coarse stage. * 2. `bootstrap-log` + `bootstrap-progress` Tauri events — live stdout * from `uv sync`, ffmpeg byte counts, etc. The log panel shows the * last N lines so users can see *something* happening during the 5–10 * min dependency install. */ import { useEffect, useRef, useState } from 'react'; import './BootstrapSplash.css'; // Vite injects package.json version at build time. const APP_VERSION = __APP_VERSION__ || '0.0.0'; const STAGE_LABEL = { checking: 'Checking environment…', downloading_uv: 'Downloading uv (Python package manager)…', creating_venv: 'Creating Python virtual environment…', installing_deps: 'Installing dependencies — first run, 5–10 min.', starting_backend: 'Starting backend…', ready: 'Ready', failed: 'Setup failed', }; const STEPS = [ 'checking', 'downloading_uv', 'creating_venv', 'installing_deps', 'starting_backend', ]; const MAX_LOG_LINES = 200; /** Scan logs + error message for known failure patterns and return actionable hints. */ function detectHints(message, logs) { const hints = []; const all = (message || '') + '\n' + logs.map(l => l.line).join('\n'); if (/README\.md/i.test(all)) hints.push('README.md was missing from the bundle. This is now auto-fixed — retry should work.'); if (/uv.*download|uv.*install/i.test(all) && /timeout|connection/i.test(all)) hints.push('Network timeout downloading uv. Check your internet connection or try the China mirror.'); if (/uv sync failed/i.test(all)) hints.push('Dependency install failed. "Clean & Retry" will delete the cached venv and start fresh.'); if (/hatchling|build_editable/i.test(all)) hints.push('Python build backend error. "Clean & Retry" removes the broken venv so it rebuilds from scratch.'); if (/ffmpeg/i.test(all) && /download|timeout/i.test(all)) hints.push('ffmpeg download failed. This is non-fatal — retry or install ffmpeg manually.'); if (/port.*in use|address.*in use/i.test(all)) hints.push('Port 3900 is already in use. Close other instances of OmniVoice or apps using that port.'); if (/no error output/i.test(all)) hints.push('Backend crashed silently. "Clean & Retry" often fixes corrupt venv issues.'); if (hints.length === 0) hints.push('Try "Retry" first. If it fails again, "Clean & Retry" will rebuild the environment from scratch.'); return hints; } function formatBytes(n) { if (!n || n < 0) return ''; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let v = n; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; } export function BootstrapSplash({ stage, message }) { const label = STAGE_LABEL[stage] || stage; const stepIndex = Math.max(0, STEPS.indexOf(stage)); const isFailed = stage === 'failed'; const [logs, setLogs] = useState([]); const [logsOpen, setLogsOpen] = useState(true); const [copied, setCopied] = useState(false); const [progress, setProgress] = useState(null); const [region, setRegionState] = useState('auto'); const [retrying, setRetrying] = useState(false); const logRef = useRef(null); const handleRetry = async () => { if (retrying) return; setRetrying(true); try { const { invoke } = await import('@tauri-apps/api/core'); setLogs([]); await invoke('retry_bootstrap'); } catch (e) { console.error('retry failed', e); } finally { setRetrying(false); } }; const handleCleanRetry = async () => { if (retrying) return; if (!confirm('This will delete the cached Python environment and re-download all dependencies (~5-10 min). Continue?')) return; setRetrying(true); try { const { invoke } = await import('@tauri-apps/api/core'); setLogs([]); await invoke('clean_and_retry_bootstrap'); } catch (e) { console.error('clean retry failed', e); } finally { setRetrying(false); } }; // Load persisted region on mount. useEffect(() => { if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return; (async () => { try { const { invoke } = await import('@tauri-apps/api/core'); const r = await invoke('get_region'); if (r) setRegionState(r); } catch { /* older build without region support */ } })(); }, []); const handleRegionChange = async (newRegion) => { setRegionState(newRegion); try { const { invoke } = await import('@tauri-apps/api/core'); await invoke('set_region', { region: newRegion }); } catch { /* silent */ } }; // Subscribe to live log + progress events from the Rust bootstrap. // Also backfill any logs emitted before the webview finished loading. useEffect(() => { if (typeof window === 'undefined') return; if (!('__TAURI_INTERNALS__' in window)) return; let unlistenLog = null; let unlistenProgress = null; let cancelled = false; (async () => { try { const { listen } = await import('@tauri-apps/api/event'); const { invoke } = await import('@tauri-apps/api/core'); if (cancelled) return; // Backfill: fetch all log lines buffered on the Rust side before // the webview was ready to receive events. try { const buffered = await invoke('get_bootstrap_logs'); if (!cancelled && Array.isArray(buffered) && buffered.length > 0) { setLogs(buffered.map(({ stage: s, line }) => ({ stage: s, line, t: Date.now(), }))); } } catch { /* command may not exist in older builds */ } // Subscribe to live events for anything new from here on. unlistenLog = await listen('bootstrap-log', (e) => { const { stage: s, line } = e.payload || {}; if (!line) return; setLogs((prev) => { // Deduplicate against backfill by checking the last few lines. const lastFew = prev.slice(-5); if (lastFew.some(l => l.stage === s && l.line === line)) return prev; const next = prev.concat([{ stage: s, line, t: Date.now() }]); return next.length > MAX_LOG_LINES ? next.slice(next.length - MAX_LOG_LINES) : next; }); }); unlistenProgress = await listen('bootstrap-progress', (e) => { setProgress(e.payload || null); }); } catch { /* not in Tauri or listen unavailable — silent */ } })(); return () => { cancelled = true; if (unlistenLog) unlistenLog(); if (unlistenProgress) unlistenProgress(); }; }, []); // Auto-scroll the log panel to the latest line whenever it opens or // new lines arrive. useEffect(() => { if (logsOpen && logRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [logs, logsOpen]); // Auto-expand logs on failure so users can see + copy the full output. // Also expand on failure (in case user collapsed manually). useEffect(() => { if (isFailed) setLogsOpen(true); }, [isFailed]); const handleCopyLogs = () => { const logText = logs.length === 0 ? 'No log output captured.' : logs.map(l => `[${l.stage}] ${l.line}`).join('\n'); const full = isFailed && message ? `ERROR: ${message}\n\n--- Bootstrap Logs ---\n${logText}` : logText; navigator.clipboard.writeText(full).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }).catch(() => {}); }; const stageProgress = progress && progress.stage === stage ? progress : null; const pctFromBytes = stageProgress?.percent != null ? stageProgress.percent : null; return (

OmniVoice Studio

v{APP_VERSION}

{label}

{isFailed ? ( <>
{message || 'Unknown error'}
💡 What to try:
    {detectHints(message, logs).map((h, i) =>
  • {h}
  • )}
) : ( <>
{stageProgress && (
{formatBytes(stageProgress.bytes_done)} {stageProgress.bytes_total > 0 ? ` / ${formatBytes(stageProgress.bytes_total)}` : ''} {pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
)}
    {STEPS.map((s, i) => (
  1. {STAGE_LABEL[s]}
  2. ))}
)} {/* Live log panel — always visible so users see what's happening */}
{logs.length > 0 && `${logs.length} lines`}
{logsOpen && (
            {logs.length === 0
              ? 'Waiting for output…'
              : logs.map((l, i) => `[${l.stage}] ${l.line}`).join('\n')}
          
)}
); } /** * Hook: polls the Rust `bootstrap_status` command every pollMs ms. Returns * the current stage (string) + message. In a non-Tauri context (dev web), * returns 'ready' immediately so the splash never mounts. */ export function useBootstrapStage(pollMs = 1000) { const [state, setState] = useState({ stage: 'checking', message: null }); useEffect(() => { if (typeof window === 'undefined') { setState({ stage: 'ready', message: null }); return; } if (!('__TAURI_INTERNALS__' in window)) { setState({ stage: 'ready', message: null }); return; } if (import.meta.env.DEV) { setState({ stage: 'ready', message: null }); return; } let cancelled = false; let timer = null; const invoke = async () => { try { const { invoke: tauriInvoke } = await import('@tauri-apps/api/core'); return tauriInvoke; } catch { return null; } }; (async () => { const tauriInvoke = await invoke(); if (!tauriInvoke) { setState({ stage: 'ready', message: null }); return; } const tick = async () => { if (cancelled) return; try { const res = await tauriInvoke('bootstrap_status'); if (cancelled) return; // Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc. setState({ stage: res.stage || 'ready', message: res.message || null }); if (res.stage !== 'ready' && res.stage !== 'failed') { timer = setTimeout(tick, pollMs); } } catch { setState({ stage: 'ready', message: null }); } }; tick(); })(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [pollMs]); return state; }