"use client"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useCallback, useEffect, useRef, useState } from "react"; interface DownloadProgress { browser: string; version: string; downloaded_bytes: number; total_bytes: number | null; percentage: number; speed_bytes_per_sec: number; eta_seconds?: number | null; stage: string; } export type SetupPhase = "downloading" | "extracting" | "ready" | "error"; export type SetupErrorStage = | "downloading" | "extracting" | "verifying" | "other"; export interface SetupError { stage: SetupErrorStage; } // The backend reports real extraction percentages for most archive formats // (zip, tar.*, dmg). For formats that can't measure progress (e.g. MSI) the // "extracting" events carry percentage 0, so we fall back to estimating from // elapsed time vs. a learned average, seeded at 2 minutes and refined with // the real durations we record. const DEFAULT_EXTRACT_MS = 2 * 60 * 1000; const MAX_SAMPLES = 5; // the 2-min seed + up to 4 most recent real durations const storageKey = (browser: string) => `donut.extractDurations.${browser}`; function readDurations(browser: string): number[] { try { const raw = localStorage.getItem(storageKey(browser)); const arr = raw ? (JSON.parse(raw) as unknown) : null; if ( Array.isArray(arr) && arr.length > 0 && arr.every((n) => typeof n === "number" && n > 0) ) { return arr as number[]; } } catch { // fall through to the seed } return [DEFAULT_EXTRACT_MS]; } function recordDuration(browser: string, ms: number) { if (!(ms > 0)) return; const current = readDurations(browser); // Keep the 2-min seed as the first value, then the most recent real samples. const samples = current[0] === DEFAULT_EXTRACT_MS ? current.slice(1) : current; const next = [ DEFAULT_EXTRACT_MS, ...[...samples, ms].slice(-(MAX_SAMPLES - 1)), ]; try { localStorage.setItem(storageKey(browser), JSON.stringify(next)); } catch { // ignore persistence failures } } function average(values: number[]): number { return values.reduce((a, b) => a + b, 0) / values.length; } // Map a backend stage to the error stage we report when something fails. function toErrorStage(stage: string): SetupErrorStage { switch (stage) { case "downloading": return "downloading"; case "extracting": return "extracting"; case "verifying": return "verifying"; default: return "other"; } } /** * Tracks first-launch setup of a browser: real download progress plus * extraction progress — real backend percentages when the archive format * supports them, otherwise a time-based estimate (percentages only). * `active` should be true while the owning dialog is open. */ export function useBrowserSetup(browser: string, active: boolean) { const [phase, setPhase] = useState("downloading"); // Download metrics straight from the latest "downloading" event. const [downloadPercent, setDownloadPercent] = useState(0); const [downloadedBytes, setDownloadedBytes] = useState(0); const [totalBytes, setTotalBytes] = useState(null); const [speedBytesPerSec, setSpeedBytesPerSec] = useState(0); const [etaSeconds, setEtaSeconds] = useState(null); // Estimated extraction progress (percentages only, capped at 99 until done). const [extractionPercent, setExtractionPercent] = useState(0); const [extractionOvertime, setExtractionOvertime] = useState(false); const [error, setError] = useState(null); const extractStartRef = useRef(null); const estimateRef = useRef(DEFAULT_EXTRACT_MS); // True once an "extracting" event carried a real percentage — from then on // the backend drives the bar and the time-based estimate stays out of it. const sawRealExtractionRef = useRef(false); // Fallback bookkeeping so a listener that mounts mid-flight, or that only // ever receives percentage-0 "extracting" events (formats that can't // measure progress), can still show extraction progress. const sawDownloadingRef = useRef(false); const lastProgressAtRef = useRef(null); const lastDownloadPercentRef = useRef(0); // The last non-terminal stage we observed, used to label an error. const lastStageRef = useRef("downloading"); // Set once a terminal state (ready/error) is reached. Stops the tick so the // mid-flight extraction fallback can't re-arm and fight the readiness poll // (which would oscillate "ready" ↔ "Almost finished" forever). const doneRef = useRef(false); useEffect(() => { if (!active) { // Fully reset when the owning dialog closes. setPhase("downloading"); setDownloadPercent(0); setDownloadedBytes(0); setTotalBytes(null); setSpeedBytesPerSec(0); setEtaSeconds(null); setExtractionPercent(0); setExtractionOvertime(false); setError(null); extractStartRef.current = null; sawRealExtractionRef.current = false; sawDownloadingRef.current = false; lastProgressAtRef.current = null; lastDownloadPercentRef.current = 0; lastStageRef.current = "downloading"; doneRef.current = false; return; } let alive = true; estimateRef.current = average(readDurations(browser)); extractStartRef.current = null; sawRealExtractionRef.current = false; sawDownloadingRef.current = false; lastProgressAtRef.current = null; lastDownloadPercentRef.current = 0; lastStageRef.current = "downloading"; doneRef.current = false; const finishExtraction = () => { if (extractStartRef.current != null) { recordDuration(browser, Date.now() - extractStartRef.current); extractStartRef.current = null; } }; const unlistenPromise = listen( "download-progress", (event) => { if (!alive) return; const p = event.payload; if (p.browser !== browser) return; switch (p.stage) { case "downloading": lastStageRef.current = "downloading"; sawDownloadingRef.current = true; lastProgressAtRef.current = Date.now(); lastDownloadPercentRef.current = p.percentage; setPhase("downloading"); setDownloadPercent(Math.round(p.percentage)); setDownloadedBytes(p.downloaded_bytes); setTotalBytes(p.total_bytes ?? null); setSpeedBytesPerSec(p.speed_bytes_per_sec); setEtaSeconds(p.eta_seconds ?? null); break; case "extracting": lastStageRef.current = "extracting"; if (extractStartRef.current == null) { extractStartRef.current = Date.now(); } lastProgressAtRef.current = Date.now(); setPhase("extracting"); if (p.percentage > 0) { sawRealExtractionRef.current = true; setExtractionPercent(Math.min(99, Math.round(p.percentage))); setExtractionOvertime(false); } break; case "verifying": lastStageRef.current = "verifying"; finishExtraction(); // Verification is the tail of extraction; keep the bar near full // but don't claim "ready" until "completed" arrives. setPhase("extracting"); setExtractionPercent(99); break; case "completed": doneRef.current = true; finishExtraction(); setPhase("ready"); setExtractionPercent(100); setExtractionOvertime(false); setError(null); break; case "error": doneRef.current = true; finishExtraction(); setPhase("error"); setError({ stage: toErrorStage(lastStageRef.current) }); break; case "cancelled": // Treat a cancellation like an error so the dialog can offer retry. doneRef.current = true; finishExtraction(); setPhase("error"); setError({ stage: "other" }); break; default: break; } }, ); // Authoritative completion signal: poll the registry. The "completed" event // is only a fast-path — we never rely on it alone. This MUST be a recurring // interval rather than a one-shot loop: independent firings mean a single // invoke that stalls during heavy extraction can't kill detection, it keeps // confirming readiness so retry() re-detects an already-downloaded browser // without restarting the effect, and it covers a browser downloaded before // this hook mounted. setPhase("ready") is idempotent, so re-confirming is // free (React bails out when state is unchanged). let checkingReady = false; const checkReady = async () => { if (!alive || checkingReady) return; checkingReady = true; try { const versions = await invoke( "get_downloaded_browser_versions", { browserStr: browser }, ); if (alive && versions.length > 0) { doneRef.current = true; finishExtraction(); setPhase("ready"); setExtractionPercent(100); setExtractionOvertime(false); setError(null); } } catch (err) { console.error("Failed to check browser download status:", err); } finally { checkingReady = false; } }; void checkReady(); const readyPoll = setInterval(() => { void checkReady(); }, 1000); // Drive the estimated extraction percentage while extracting. const tick = setInterval(() => { if (!alive || doneRef.current) return; // If the download visibly finished but we never saw any "extracting" // event, start estimating extraction anyway — anchored to the last // download event, which is roughly when extraction began. if ( extractStartRef.current == null && sawDownloadingRef.current && lastDownloadPercentRef.current >= 99 && lastProgressAtRef.current != null && Date.now() - lastProgressAtRef.current > 1200 ) { extractStartRef.current = lastProgressAtRef.current; lastStageRef.current = "extracting"; setPhase("extracting"); } if (extractStartRef.current == null) return; // Real backend percentages drive the bar; the estimate would only // fight them (and flag bogus "overtime" on a healthy extraction). if (sawRealExtractionRef.current) return; const elapsed = Date.now() - extractStartRef.current; const est = estimateRef.current || DEFAULT_EXTRACT_MS; if (elapsed >= est) { // We've blown past the estimate — hold at 99 and flag overtime so the // dialog can show "Almost finished" instead of a stalled number. setExtractionPercent(99); setExtractionOvertime(true); } else { setExtractionPercent(Math.min(99, Math.round((elapsed / est) * 100))); setExtractionOvertime(false); } }, 250); return () => { alive = false; clearInterval(tick); clearInterval(readyPoll); void unlistenPromise.then((u) => { u(); }); }; }, [browser, active]); const retry = useCallback(() => { // Reset visible state and the bookkeeping refs, then kick off the download // again. The effect's event listener and registry poll stay alive the whole // time the dialog is open, so they pick up the fresh attempt — no need to // restart the effect. setPhase("downloading"); setDownloadPercent(0); setDownloadedBytes(0); setTotalBytes(null); setSpeedBytesPerSec(0); setEtaSeconds(null); setExtractionPercent(0); setExtractionOvertime(false); setError(null); extractStartRef.current = null; sawRealExtractionRef.current = false; sawDownloadingRef.current = false; lastProgressAtRef.current = null; lastDownloadPercentRef.current = 0; lastStageRef.current = "downloading"; doneRef.current = false; void (async () => { try { await invoke("ensure_active_browsers_downloaded"); } catch (err) { console.error("Failed to re-trigger browser setup:", err); setPhase("error"); setError({ stage: "other" }); } })(); }, []); return { phase, downloadPercent, downloadedBytes, totalBytes, speedBytesPerSec, etaSeconds, extractionPercent, extractionOvertime, ready: phase === "ready", error, retry, }; }