mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-18 10:27:29 +02:00
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
"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<SetupPhase>("downloading");
|
|
// Download metrics straight from the latest "downloading" event.
|
|
const [downloadPercent, setDownloadPercent] = useState(0);
|
|
const [downloadedBytes, setDownloadedBytes] = useState(0);
|
|
const [totalBytes, setTotalBytes] = useState<number | null>(null);
|
|
const [speedBytesPerSec, setSpeedBytesPerSec] = useState(0);
|
|
const [etaSeconds, setEtaSeconds] = useState<number | null>(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<SetupError | null>(null);
|
|
|
|
const extractStartRef = useRef<number | null>(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<number | null>(null);
|
|
const lastDownloadPercentRef = useRef(0);
|
|
// The last non-terminal stage we observed, used to label an error.
|
|
const lastStageRef = useRef<string>("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<DownloadProgress>(
|
|
"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<string[]>(
|
|
"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,
|
|
};
|
|
}
|