mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 23:13:58 +02:00
feat: add onboarding
This commit is contained in:
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import { isOnboardingActive } from "@/lib/onboarding-signal";
|
||||
import {
|
||||
dismissToast,
|
||||
showDownloadToast,
|
||||
@@ -327,31 +328,39 @@ export function useBrowserDownload() {
|
||||
: i18n.t("browserDownload.toast.calculating");
|
||||
|
||||
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
|
||||
showDownloadToast(
|
||||
browserName,
|
||||
progress.version,
|
||||
"downloading",
|
||||
{
|
||||
percentage: progress.percentage,
|
||||
speed: speedMBps,
|
||||
eta: etaText,
|
||||
},
|
||||
{
|
||||
onCancel: () => {
|
||||
invoke("cancel_download", {
|
||||
browserStr: progress.browser,
|
||||
version: progress.version,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to cancel download:", err);
|
||||
});
|
||||
dismissToast(toastId);
|
||||
// During first-run onboarding the welcome dialog shows browser
|
||||
// setup progress itself, so suppress the global download toast.
|
||||
if (!isOnboardingActive()) {
|
||||
showDownloadToast(
|
||||
browserName,
|
||||
progress.version,
|
||||
"downloading",
|
||||
{
|
||||
percentage: progress.percentage,
|
||||
speed: speedMBps,
|
||||
eta: etaText,
|
||||
},
|
||||
},
|
||||
);
|
||||
{
|
||||
onCancel: () => {
|
||||
invoke("cancel_download", {
|
||||
browserStr: progress.browser,
|
||||
version: progress.version,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to cancel download:", err);
|
||||
});
|
||||
dismissToast(toastId);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (progress.stage === "extracting") {
|
||||
showDownloadToast(browserName, progress.version, "extracting");
|
||||
if (!isOnboardingActive()) {
|
||||
showDownloadToast(browserName, progress.version, "extracting");
|
||||
}
|
||||
} else if (progress.stage === "verifying") {
|
||||
showDownloadToast(browserName, progress.version, "verifying");
|
||||
if (!isOnboardingActive()) {
|
||||
showDownloadToast(browserName, progress.version, "verifying");
|
||||
}
|
||||
} else if (progress.stage === "cancelled") {
|
||||
setDownloadingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -372,17 +381,21 @@ export function useBrowserDownload() {
|
||||
`download-${browserName.toLowerCase()}-${progress.version}`,
|
||||
);
|
||||
setDownloadProgress(null);
|
||||
showErrorToast(
|
||||
i18n.t("browserDownload.toast.extractionFailed", {
|
||||
browser: browserName,
|
||||
version: progress.version,
|
||||
}),
|
||||
{
|
||||
description: i18n.t(
|
||||
"browserDownload.toast.extractionFailedDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
// During first-run onboarding the welcome dialog surfaces a
|
||||
// concrete setup error itself, so suppress the global toast.
|
||||
if (!isOnboardingActive()) {
|
||||
showErrorToast(
|
||||
i18n.t("browserDownload.toast.extractionFailed", {
|
||||
browser: browserName,
|
||||
version: progress.version,
|
||||
}),
|
||||
{
|
||||
description: i18n.t(
|
||||
"browserDownload.toast.extractionFailedDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (progress.stage === "completed") {
|
||||
setDownloadingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -401,7 +414,9 @@ export function useBrowserDownload() {
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
showDownloadToast(browserName, progress.version, "completed");
|
||||
if (!isOnboardingActive()) {
|
||||
showDownloadToast(browserName, progress.version, "completed");
|
||||
}
|
||||
setDownloadProgress(null);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"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 emits a real percentage only while downloading; extraction sends
|
||||
// a single "extracting" event with no incremental progress (it takes ~2 min).
|
||||
// So we estimate extraction progress 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 an
|
||||
* estimated extraction progress (no countdown timer, 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);
|
||||
// Fallback bookkeeping so a listener that mounts mid-flight (and therefore
|
||||
// misses the single "extracting" event) 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;
|
||||
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;
|
||||
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");
|
||||
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 the (single)
|
||||
// "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;
|
||||
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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user