From 6a9b433b6c5940186c43de766a2c145a3911dacc Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:59:01 +0400 Subject: [PATCH] feat: new About and egg --- e2e/tests/motion.test.mjs | 197 +++-- e2e/tests/ui.test.mjs | 10 +- package.json | 3 +- src/app/page.tsx | 11 +- src/components/about-dialog.tsx | 877 +++++++++++++++------ src/components/donut-lab.tsx | 748 ++++++++++++++++++ src/components/donut-snack.tsx | 102 --- src/hooks/use-app-update-notifications.tsx | 28 +- src/i18n/locales/de.json | 61 +- src/i18n/locales/en.json | 61 +- src/i18n/locales/es.json | 61 +- src/i18n/locales/fr.json | 61 +- src/i18n/locales/ja.json | 61 +- src/i18n/locales/ko.json | 61 +- src/i18n/locales/pt.json | 61 +- src/i18n/locales/ru.json | 63 +- src/i18n/locales/tr.json | 61 +- src/i18n/locales/vi.json | 61 +- src/i18n/locales/zh.json | 61 +- src/lib/donut-lab.test.mjs | 218 +++++ src/lib/donut-lab.ts | 748 ++++++++++++++++++ src/lib/ui-sounds.ts | 14 +- 22 files changed, 3080 insertions(+), 549 deletions(-) create mode 100644 src/components/donut-lab.tsx delete mode 100644 src/components/donut-snack.tsx create mode 100644 src/lib/donut-lab.test.mjs create mode 100644 src/lib/donut-lab.ts diff --git a/e2e/tests/motion.test.mjs b/e2e/tests/motion.test.mjs index 822e1a81..43f67039 100644 --- a/e2e/tests/motion.test.mjs +++ b/e2e/tests/motion.test.mjs @@ -254,17 +254,6 @@ test("first-run profile cutaway is interactive, isolated, and readable at both w ); }); -async function openReplay(app) { - await app.clickSelector(`[aria-label="${en.rail.more.label}"]`); - await waitForSelector(app, '[role="menu"]'); - await app.clickText(en.rail.more.about, { - exact: false, - roles: ["menuitem"], - }); - await app.clickSelector(slot("isolation-demo-replay")); - await waitForSelector(app, slot("profile-isolation-demo")); -} - async function assertPopupReadable(app, selector) { await app.waitFor( () => @@ -386,9 +375,8 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async roles: ["menuitem"], }); await waitForSelector(app, '[role="menu"]', false); - await app.clickSelector(slot("isolation-demo-replay")); - await waitForSelector(app, slot("profile-isolation-demo")); - await app.capture("paused-css-about-replay"); + await assertPopupReadable(app, slot("about-diagnostics")); + await app.capture("paused-css-about"); } finally { await app.execute(` document.getElementById("donut-motion-paused-css")?.remove(); @@ -824,26 +812,11 @@ async function resumeAnimations(app) { `); } -test("profile replay, inspector, group gestures, and remote handoff preserve context", async () => { +test("profile inspector, group gestures, and remote handoff preserve context", async () => { await withApp( "motion-workspace", async (app) => { await resize(app, 1480, 900); - await openReplay(app); - await exerciseIsolation(app); - await app.capture("isolation-replay-wide"); - await resize(app, 640, 480); - await assertContained(app, '[role="dialog"]'); - await app.capture("isolation-replay-narrow"); - await app.clickText(en.common.buttons.back, { roles: ["button"] }); - await waitForSelector(app, slot("isolation-demo-replay")); - assert.equal( - await app.execute(`return document.activeElement?.dataset.slot;`), - "isolation-demo-replay", - ); - await app.pressShortcut({ key: "Escape" }); - await waitForSelector(app, '[role="dialog"]', false); - await resize(app, 1480, 900); const group = await app.invoke("create_profile_group", { name: "Motion research group", @@ -2101,8 +2074,8 @@ test("partial import receipts survive retry without duplicating successful profi ); }); -test("About hides a snack drawer that responds to bites and resets on close", async () => { - await withApp("motion-donut-snack", async (app) => { +test("About shows diagnostics and hides a donut lab that takes bites inside the dialog", async () => { + await withApp("motion-donut-lab", async (app) => { await resize(app, 760, 620); const openAbout = async () => { await app.clickSelector(`[aria-label="${en.rail.more.label}"]`); @@ -2112,49 +2085,149 @@ test("About hides a snack drawer that responds to bites and resets on close", as }); await waitForSelector(app, slot("about-logo")); }; + const openLab = () => + app.execute(` + const logo = document.querySelector('[data-slot="about-logo"]'); + logo.click(); + logo.click(); + logo.click(); + `); + const lab = () => + app.execute(` + const node = document.querySelector('[data-slot="donut-lab"]'); + return node ? { bites: Number(node.dataset.bites), state: node.dataset.state } : null; + `); + await openAbout(); - await app.clickSelector(slot("about-logo")); - await waitForSelector(app, slot("donut-snack"), false); - await app.execute( - `document.querySelector('[data-slot="about-logo"]').focus();`, + const system = await app.invoke("get_system_info"); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="about-build"]')?.textContent.includes(arguments[0]);`, + [`${system.os} ${system.arch}`], + ), + { description: "the build line" }, ); - await app.pressShortcut({ key: "\uE006", shift: true }); - await waitForSelector(app, slot("donut-snack")); - const biteCount = () => - app.execute( - `return Number(document.querySelector('[data-slot="donut-snack"]').dataset.bites);`, - ); - assert.equal(await biteCount(), 0); - await app.capture("secret-snack-drawer"); - for (const count of [1, 2, 3]) { - await app.clickSelector(slot("donut-snack-bite")); - assert.equal(await biteCount(), count); - await assertContained(app, slot("donut-snack")); - if (count === 2) await app.capture("secret-snack-two-bites"); - } - await app.waitForText(en.about.snack.finished); - await app.capture("secret-snack-crumbs"); - await app.clickSelector(slot("donut-snack-bite")); - assert.equal(await biteCount(), 0); - await activateFocusedByKeyboard(app); - assert.equal(await biteCount(), 1); + const updateState = await app.waitFor( + () => + app.execute(` + const state = document.querySelector('[data-slot="about-update-status"]')?.dataset.state; + return state && state !== "loading" && state !== "checking" ? state : null; + `), + { description: "a settled update status" }, + ); + assert.ok( + ["development", "current", "failed", "available"].includes(updateState), + updateState, + ); + await assertContained(app, '[role="dialog"]'); + await app.capture("about-wide"); + + // Copy details gives a plain block for a bug report, with nothing private. + await app.execute(` + window.__copied = []; + navigator.clipboard.writeText = async (text) => { window.__copied.push(text); }; + `); + await app.clickSelector(slot("about-copy-details")); + const [details] = await app.waitFor( + async () => { + const copied = await app.execute("return window.__copied;"); + return copied.length === 1 ? copied : null; + }, + { description: "copied details" }, + ); + assert.ok(details.startsWith(`Donut Browser ${system.app_version}\n`)); + assert.ok(details.includes(`System: ${system.os} ${system.arch}`)); + assert.ok(details.includes("Wayfern: ")); + assert.doesNotMatch(details, /@|\/Users\/|\/home\/|\\Users\\/); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="about-copy-details"]')?.textContent.includes(arguments[0]);`, + [en.common.buttons.copied], + ), + { description: "the copied state" }, + ); + + // One click is only a press; three quick ones open the lab. + await app.clickSelector(slot("about-logo")); + await waitForSelector(app, slot("donut-lab"), false); + await openLab(); + await waitForSelector(app, slot("donut-lab")); + assert.equal( + await app.execute( + `return Boolean(document.querySelector('[data-slot="dialog-content"] > [data-slot="donut-lab"] canvas[role="img"]'));`, + ), + true, + "the lab is drawn inside the dialog", + ); + await assertContained(app, slot("donut-lab")); + assert.equal( + await app.execute(` + const box = document.querySelector('[data-slot="dialog-content"]').getBoundingClientRect(); + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); + return Boolean(hit?.closest('[data-slot="donut-lab"]')); + `), + true, + "the lab covers the About content", + ); + assert.deepEqual(await lab(), { bites: 0, state: "whole" }); + await app.capture("donut-lab"); + + await app.clickSelector(`${slot("donut-lab")} canvas`); + await app.waitFor(async () => (await lab()).bites === 1, { + description: "a bite from a click on the middle", + }); + await app.execute( + `document.querySelector('[data-slot="donut-lab"] canvas').focus();`, + ); + await app.pressShortcut({ key: "\uE007" }); + await app.waitFor(async () => (await lab()).bites === 2, { + description: "a bite from Enter", + }); await freezeAnimations(app); try { - await app.clickSelector(slot("donut-snack-bite")); - assert.equal(await biteCount(), 2); - await assertPopupReadable(app, slot("donut-snack")); + await app.pressShortcut({ key: "\uE007" }); + await app.waitFor(async () => (await lab()).bites === 3, { + description: "a bite under a frozen clock", + }); + await assertPopupReadable(app, slot("donut-lab")); } finally { await resumeAnimations(app); } + await app.capture("donut-lab-bitten"); assert.deepEqual( await app.invoke("list_browser_profiles"), [], - "the snack never touches profiles", + "the donut never touches profiles", ); + + // Escape puts the donut away and gives the focus back to the logo. await app.pressShortcut({ key: "Escape" }); - await waitForSelector(app, slot("donut-snack"), false); + await waitForSelector(app, slot("donut-lab"), false); + await waitForSelector(app, slot("about-diagnostics")); + assert.equal( + await app.execute(`return document.activeElement?.dataset.slot;`), + "about-logo", + ); + + // Closing About bakes a fresh donut for next time. + await openLab(); + await waitForSelector(app, slot("donut-lab")); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, slot("donut-lab"), false); + await app.pressShortcut({ key: "Escape" }); + await waitForSelector(app, '[role="dialog"]', false); await openAbout(); - await waitForSelector(app, slot("donut-snack"), false); + await waitForSelector(app, slot("donut-lab"), false); + await openLab(); + await waitForSelector(app, slot("donut-lab")); + assert.deepEqual(await lab(), { bites: 0, state: "whole" }); + + await resize(app, 640, 400); + await assertContained(app, '[role="dialog"]'); + await assertContained(app, slot("donut-lab")); + await app.capture("donut-lab-small"); }); }); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 6db534ed..b7b1a57d 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -2481,7 +2481,13 @@ test("About exposes a searchable, responsive third-party license inventory", asy exact: false, roles: ["menuitem"], }); - await app.waitForText("Open-source anti-detect browser."); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector("[role='dialog'] [data-slot='about-diagnostics']"));`, + ), + { description: "About diagnostics" }, + ); const aboutText = await app.execute( `return document.querySelector("[role='dialog']")?.textContent ?? "";`, @@ -2571,7 +2577,7 @@ test("About exposes a searchable, responsive third-party license inventory", asy await app.waitFor( () => app.execute( - `return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "About";`, + `return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "Donut Browser";`, ), { description: "About view after returning from licenses" }, ); diff --git a/package.json b/package.json index bbee2736..e959f75a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "prebuild": "pnpm licenses:generate", "build": "next build", "start": "next start", - "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:palette-search && pnpm test:ui-sounds && pnpm test:schedules && pnpm test:team-activity && pnpm test:table-tools && pnpm test:motion && pnpm test:profile-store && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", + "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:palette-search && pnpm test:ui-sounds && pnpm test:schedules && pnpm test:team-activity && pnpm test:table-tools && pnpm test:motion && pnpm test:donut-lab && pnpm test:profile-store && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", "test:tips": "node --test src/lib/tips.test.mjs src/lib/coach-mark.test.mjs", "test:window-decorations": "node --test src/lib/window-decorations.test.mjs", @@ -30,6 +30,7 @@ "test:schedules": "node --test src/lib/schedules.test.mjs", "test:team-activity": "node --test src/lib/team-activity.test.mjs", "test:motion": "node --test src/lib/motion.test.mjs", + "test:donut-lab": "node --test src/lib/donut-lab.test.mjs", "test:profile-store": "node --test src/lib/profile-list-store.test.mjs src/components/profile-table/traffic-snapshots.test.mjs", "test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check", "test:xray-packaging": "node --test src-tauri/download-xray.test.mjs", diff --git a/src/app/page.tsx b/src/app/page.tsx index 068385a2..b6773e62 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1133,7 +1133,7 @@ export default function Home() { const updateNotifications = useUpdateNotifications(); const { checkForUpdates, isUpdating } = updateNotifications; - useAppUpdateNotifications(); + const appUpdate = useAppUpdateNotifications(); // Check for startup URLs but only process them once const [hasCheckedStartupUrl, setHasCheckedStartupUrl] = useState(false); @@ -3295,6 +3295,15 @@ export default function Home() { onClose={() => { setAboutDialogOpen(false); }} + update={appUpdate} + onOpenFeedback={() => { + setAboutDialogOpen(false); + setFeedbackDialogOpen(true); + }} + onOpenShortcuts={() => { + setAboutDialogOpen(false); + handleRailNavigate("shortcuts"); + }} /> void; -} +const WEBSITE_URL = "https://donutbrowser.com"; +const REPOSITORY_URL = "https://github.com/donutbrowser/donutbrowser"; +const RELEASES_URL = `${REPOSITORY_URL}/releases`; +/** A check older than this runs again when About opens. */ +const CHECK_TTL_MS = 10 * 60 * 1000; +/** Three clicks on the logo, each this soon after the last, open the lab. */ +const EGG_CLICK_GAP_MS = 600; +const EGG_CLICKS = 3; interface SystemInfo { app_version: string; @@ -36,31 +71,76 @@ interface SystemInfo { portable: boolean; } -type AboutView = "about" | "licenses" | "isolation"; +type BuildChannel = "stable" | "nightly" | "development"; -// Flywheel: each click adds spin; past this speed the donut escapes the -// dialog and bounces around the window (shared physics with the rail egg). -const SPIN_PER_CLICK = 540; // deg/s -const ESCAPE_VELOCITY = 2200; // deg/s -const SPIN_FRICTION = 1.1; // fraction of velocity lost per second +/** The channel, the version people read, and its release notes, from the build's version. */ +function describeBuild(version: string) { + if (version.startsWith("dev-")) { + return { + channel: "development" as BuildChannel, + label: version.slice(4), + notesUrl: RELEASES_URL, + }; + } + if (version.startsWith("nightly-")) { + return { + channel: "nightly" as BuildChannel, + label: version.slice(8), + notesUrl: RELEASES_URL, + }; + } + const label = version.replace(/^v/, ""); + return { + channel: "stable" as BuildChannel, + label, + notesUrl: `${RELEASES_URL}/tag/v${label}`, + }; +} -export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { - const { t } = useTranslation(); +type CheckState = "idle" | "checking" | "done" | "failed"; + +type AboutView = "about" | "licenses"; + +interface AboutDialogProps { + isOpen: boolean; + onClose: () => void; + update: AppUpdateState; + onOpenFeedback: () => void; + onOpenShortcuts: () => void; +} + +export function AboutDialog({ + isOpen, + onClose, + update, + onOpenFeedback, + onOpenShortcuts, +}: AboutDialogProps) { + const { t, i18n } = useTranslation(); const reducedMotion = useReducedMotion(); const [systemInfo, setSystemInfo] = useState(null); - const [logoFlown, setLogoFlown] = useState(false); - const [snackOpen, setSnackOpen] = useState(false); + const [wayfernVersions, setWayfernVersions] = useState(null); const [view, setView] = useState("about"); const [licenseQuery, setLicenseQuery] = useState(""); + const [check, setCheck] = useState("idle"); + const [copied, setCopied] = useState(false); + const [lab, setLab] = useState<{ x: number; y: number } | null>(null); + const contentRef = useRef(null); const logoRef = useRef(null); - const rotationRef = useRef(0); - const velocityRef = useRef(0); - const rafRef = useRef(0); - const lastTimeRef = useRef(0); - const cancelLaunchRef = useRef<(() => void) | null>(null); + const logoClicksRef = useRef([]); + const lastCheckAtRef = useRef(0); + const copiedTimerRef = useRef(undefined); const restoreLicensesButtonFocusRef = useRef(false); - const restoreExampleButtonFocusRef = useRef(false); + const restoreLogoFocusRef = useRef(false); + const diagnosticsId = useId(); + const helpId = useId(); + + const build = useMemo( + () => (systemInfo ? describeBuild(systemInfo.app_version) : null), + [systemInfo], + ); + const { checkForAppUpdatesManual } = update; useEffect(() => { if (!isOpen) return; @@ -69,84 +149,133 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { .catch(() => { setSystemInfo(null); }); + invoke("get_downloaded_browser_versions", { + browserStr: "wayfern", + }) + .then(setWayfernVersions) + .catch(() => { + setWayfernVersions([]); + }); }, [isOpen]); - const stopSpin = useCallback(() => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - rafRef.current = 0; - velocityRef.current = 0; + const runCheck = useCallback(async () => { + setCheck("checking"); + lastCheckAtRef.current = Date.now(); + try { + await checkForAppUpdatesManual(); + setCheck("done"); + } catch (error) { + console.error("Failed to check for app updates:", error); + setCheck("failed"); + } + }, [checkForAppUpdatesManual]); + + // Opening About checks for an update, unless one is known or a check ran lately. + const channel = build?.channel; + const knownUpdate = update.updateInfo !== null; + useEffect(() => { + if (!isOpen || !channel || channel === "development" || knownUpdate) { + return; + } + if (check === "checking") return; + if ( + check !== "idle" && + Date.now() - lastCheckAtRef.current < CHECK_TTL_MS + ) { + return; + } + void runCheck(); + }, [isOpen, channel, knownUpdate, check, runCheck]); + + useEffect(() => () => window.clearTimeout(copiedTimerRef.current), []); + + const details = useMemo(() => { + if (!systemInfo || !build) return ""; + return [ + `Donut Browser ${systemInfo.app_version}`, + `Channel: ${build.channel}`, + `System: ${systemInfo.os} ${systemInfo.arch}`, + `Wayfern: ${wayfernVersions?.length ? wayfernVersions.join(", ") : "none"}`, + `Portable: ${systemInfo.portable ? "yes" : "no"}`, + `Language: ${i18n.language}`, + ].join("\n"); + }, [systemInfo, build, wayfernVersions, i18n.language]); + + const copyDetails = useCallback(async () => { + try { + await navigator.clipboard.writeText(details); + playUiSound("copy"); + setCopied(true); + window.clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = window.setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy details:", error); + showErrorToast(t("about.diagnostics.copyFailed")); + } + }, [details, t]); + + const openLogs = useCallback(async () => { + try { + await invoke("open_log_directory"); + } catch (error) { + console.error("Failed to open the log folder:", error); + showErrorToast(t("about.diagnostics.openLogsFailed")); + } + }, [t]); + + const closeLab = useCallback(() => { + restoreLogoFocusRef.current = true; + setLab(null); }, []); - const spinFrame = useCallback( - (time: number) => { - const el = logoRef.current; - if (!el) { - rafRef.current = 0; - return; - } - const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05); - lastTimeRef.current = time; - - rotationRef.current += velocityRef.current * dt; - velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt); - el.style.transform = `rotate(${rotationRef.current}deg)`; - - if (velocityRef.current >= ESCAPE_VELOCITY) { - // The flywheel wins: the donut tears loose and joins the bounce sim, - // keeping its spin. - stopSpin(); - setLogoFlown(true); - cancelLaunchRef.current = launchDonutClone(el, { - initialVX: Math.random() > 0.5 ? 420 : -420, - initialVY: -750, - spinSpeed: ESCAPE_VELOCITY, - onExit: () => { - cancelLaunchRef.current = null; - }, - }); - return; - } - - if (velocityRef.current > 5) { - rafRef.current = requestAnimationFrame(spinFrame); - } else { - rafRef.current = 0; - velocityRef.current = 0; - } - }, - [stopSpin], - ); + useEffect(() => { + if (lab || !restoreLogoFocusRef.current) return; + restoreLogoFocusRef.current = false; + logoRef.current?.focus(); + }, [lab]); const handleLogoClick = useCallback(() => { - if (reducedMotion || logoFlown) return; - velocityRef.current += SPIN_PER_CLICK; - if (!rafRef.current) { - lastTimeRef.current = performance.now(); - rafRef.current = requestAnimationFrame(spinFrame); + const now = performance.now(); + const clicks = logoClicksRef.current; + const last = clicks[clicks.length - 1]; + if (last !== undefined && now - last > EGG_CLICK_GAP_MS) clicks.length = 0; + clicks.push(now); + const logo = logoRef.current; + if (clicks.length >= EGG_CLICKS && logo) { + clicks.length = 0; + const rect = logo.getBoundingClientRect(); + contentRef.current?.scrollTo({ top: 0 }); + setLab({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }); + return; } - }, [reducedMotion, logoFlown, spinFrame]); - - const resetLogo = useCallback(() => { - stopSpin(); - cancelLaunchRef.current?.(); - cancelLaunchRef.current = null; - rotationRef.current = 0; - if (logoRef.current) { - logoRef.current.style.transform = ""; - logoRef.current.style.visibility = ""; + // Each press squeezes a little harder, as if something were inside. + if (!reducedMotion && logo && typeof logo.animate === "function") { + const depth = clicks.length; + logo.animate( + [ + { transform: "scale(1) rotate(0deg)" }, + { + transform: `scale(${0.93 - depth * 0.03}) rotate(${depth * -7}deg)`, + }, + { transform: "scale(1) rotate(0deg)" }, + ], + { + duration: 240, + easing: `cubic-bezier(${MOTION_EASE_OUT.join(",")})`, + }, + ); } - setLogoFlown(false); - }, [stopSpin]); + }, [reducedMotion]); const handleClose = useCallback(() => { - resetLogo(); - setSnackOpen(false); + setLab(null); + logoClicksRef.current = []; + restoreLogoFocusRef.current = false; setView("about"); setLicenseQuery(""); restoreLicensesButtonFocusRef.current = false; - restoreExampleButtonFocusRef.current = false; onClose(); - }, [onClose, resetLogo]); + }, [onClose]); const handleLicensesButtonRef = useCallback( (node: HTMLButtonElement | null) => { @@ -158,18 +287,11 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { [], ); - const handleOpenLicenses = useCallback(() => { - resetLogo(); - setLicenseQuery(""); - setView("licenses"); - }, [resetLogo]); - const handleBackToAbout = useCallback(() => { - restoreLicensesButtonFocusRef.current = view === "licenses"; - restoreExampleButtonFocusRef.current = view === "isolation"; + restoreLicensesButtonFocusRef.current = true; setLicenseQuery(""); setView("about"); - }, [view]); + }, []); const filteredLicenses = useMemo(() => { const query = licenseQuery.trim().toLocaleLowerCase(); @@ -181,12 +303,16 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { ); }, [licenseQuery]); - useEffect(() => { - return () => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - cancelLaunchRef.current?.(); - }; - }, []); + const meta = systemInfo + ? [ + build?.label, + build && t(`about.channel.${build.channel}`), + `${systemInfo.os} ${systemInfo.arch}`, + systemInfo.portable ? t("about.portable") : null, + ] + .filter(Boolean) + .join(" · ") + : null; return ( { + if (!lab) return; + event.preventDefault(); + closeLab(); + }} + className={cn( + "max-w-2xl", + view === "licenses" + ? "flex h-[min(80dvh,40rem)] flex-col overflow-hidden" + : "@container", + lab && "overflow-hidden", + )} > {view === "about" ? ( - <> - - {t("about.title")} - - -
- {snackOpen ? ( - - ) : ( - +
+ + Donut Browser + +

- - - )} - -

-

Donut Browser

- {systemInfo && ( - <> -

- {t("about.version", { - version: systemInfo.app_version, - })} - {systemInfo.portable && ( - - {t("about.portableBadge")} - - )} -

-

- {systemInfo.os} {systemInfo.arch} -

- + {meta} +

+ {build && ( + void openUrl(build.notesUrl)} + > + {t("about.whatsNew")} + )}
+ -

- {t("about.licenseNotice")} -

+ void runCheck()} + /> -
- - - -
- + + {t("about.diagnostics.title")} + +
+ + {systemInfo?.app_version} + + + {build && t(`about.channel.${build.channel}`)} + + + {systemInfo && `${systemInfo.os} ${systemInfo.arch}`} + + + {wayfernVersions && + (wayfernVersions.length > 0 + ? wayfernVersions.join(", ") + : t("about.diagnostics.notInstalled"))} + + + {systemInfo && + (systemInfo.portable + ? t("about.diagnostics.yes") + : t("about.diagnostics.no"))} + +
+
+ + +
+ + +
-
- - {t("common.buttons.close")} - -
- - ) : view === "isolation" ? ( - <> - - - {t("isolationDemo.replay")} - - - +
+

{t("about.footer")}

+ void openUrl(REPOSITORY_URL)}> + +
+
) : ( <> @@ -373,6 +518,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { { setLicenseQuery(event.target.value); @@ -394,7 +540,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { {filteredLicenses.map(({ name, license }) => (
  • {name === "Xray-core" ? (
  • @@ -436,7 +582,254 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) { )}
    + {lab && view === "about" && ( + + )}
    ); } + +function SectionLabel({ + id, + className, + children, +}: { + id: string; + className?: string; + children: ReactNode; +}) { + return ( +

    + {children} +

    + ); +} + +function DiagnosticRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + <> +
    {label}
    +
    + {children} +
    + + ); +} + +function LinkButton({ + className, + onClick, + children, +}: { + className?: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function HelpItem({ + icon: Icon, + external = false, + buttonRef, + onClick, + children, +}: { + icon: IconType; + external?: boolean; + buttonRef?: (node: HTMLButtonElement | null) => void; + onClick: () => void; + children: ReactNode; +}) { + return ( +
  • + +
  • + ); +} + +function UpdateStatus({ + channel, + check, + update, + onCheck, +}: { + channel: BuildChannel | null; + check: CheckState; + update: AppUpdateState; + onCheck: () => void; +}) { + const { t } = useTranslation(); + const info = update.updateInfo; + const percent = update.updateProgress?.percentage; + + let state: string; + let icon: ReactNode; + let text: string; + let action: ReactNode = null; + + const spinner = ( +