(() => { "use strict"; const root = document.documentElement; const header = document.querySelector("[data-site-header]"); const menuToggle = document.querySelector("[data-menu-toggle]"); const navigation = document.querySelector("[data-navigation]"); const menuBackdrop = document.querySelector("[data-menu-backdrop]"); const themeToggle = document.querySelector("[data-theme-toggle]"); const themeColor = document.querySelector('meta[name="theme-color"]'); const tiltStage = document.querySelector("[data-tilt-stage]"); const tiltCard = tiltStage?.querySelector("[data-tilt-card]"); const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const mobileMenu = window.matchMedia("(max-width: 719px)"); const isEnglish = root.lang.toLowerCase().startsWith("en"); const releaseManifestUrl = "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json"; const releaseFallbackUrl = "https://github.com/mesalogo/goodbuddy/releases/latest"; const releaseRequestTimeoutMs = 10_000; const releaseIndexApi = window.GoodBuddyReleaseIndex; const downloadCards = [ ...document.querySelectorAll("[data-download-card]"), ]; const interfaceCopy = isEnglish ? { themeDark: "Switch to dark theme", themeLight: "Switch to light theme", menuOpen: "Open navigation", menuClose: "Close navigation", } : { themeDark: "切换为深色主题", themeLight: "切换为浅色主题", menuOpen: "打开导航", menuClose: "关闭导航", }; const platformNames = { windows: "Windows", macos: "macOS", linux: "Linux", }; const formatNames = { nsis: "安装版", portable: "便携版", dmg: "DMG", zip: "ZIP", AppImage: "AppImage", deb: "DEB", }; const formatFileSize = (bytes) => { const megabytes = bytes / (1024 * 1024); return `${megabytes >= 100 ? megabytes.toFixed(0) : megabytes.toFixed(1)} MB`; }; const listenMediaQuery = (query, listener) => { if (typeof query.addEventListener === "function") { query.addEventListener("change", listener); } else if (typeof query.addListener === "function") { query.addListener(listener); } }; const readBoundedJson = async (response) => { const maximumBytes = releaseIndexApi?.maximumIndexBytes; if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { throw new Error("发布索引大小上限无效"); } const declaredLength = response.headers.get("content-length"); if (declaredLength !== null) { const parsedLength = Number(declaredLength); if ( !Number.isSafeInteger(parsedLength) || parsedLength < 0 || parsedLength > maximumBytes ) { throw new Error("发布索引响应大小无效"); } } if (!response.body) { throw new Error("发布索引响应没有正文"); } const reader = response.body.getReader(); const chunks = []; let length = 0; while (true) { const result = await reader.read(); if (result.done) { break; } length += result.value.byteLength; if (length > maximumBytes) { await reader.cancel(); throw new Error("发布索引响应过大"); } chunks.push(result.value); } const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return JSON.parse(new TextDecoder().decode(bytes)); }; const setReleaseLink = (link, url, visibleText) => { const newWindowNotice = link.querySelector(".sr-only"); link.href = url; link.replaceChildren(document.createTextNode(visibleText)); if (newWindowNotice) { link.append(newWindowNotice); } }; const setFallbackDownloads = () => { for (const card of downloadCards) { const platform = card.dataset.downloadCard; const link = card.closest(".download-card")?.querySelector("[data-release-link]"); const meta = card.closest(".download-card")?.querySelector("[data-download-meta]"); if (link instanceof HTMLAnchorElement) { setReleaseLink( link, releaseFallbackUrl, `前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`, ); } if (meta instanceof HTMLElement) { meta.textContent = "请在 GitHub Release 中选择对应的安装文件。"; } } }; const configureDownloads = (payload) => { if (!releaseIndexApi?.validateReleaseIndex) { throw new Error("发布索引校验器不可用"); } const release = releaseIndexApi.validateReleaseIndex(payload); const updateCard = (card) => { const platform = card.dataset.downloadCard; const archSelect = card.querySelector("[data-download-arch]"); const formatSelect = card.querySelector("[data-download-format]"); const link = card.closest(".download-card")?.querySelector("[data-release-link]"); const meta = card.closest(".download-card")?.querySelector("[data-download-meta]"); if ( !platform || !(archSelect instanceof HTMLSelectElement) || !(formatSelect instanceof HTMLSelectElement) || !(link instanceof HTMLAnchorElement) || !(meta instanceof HTMLElement) ) { throw new Error("下载卡片结构无效"); } const target = release.targets[`${platform}-${archSelect.value}`]; const file = target?.files?.[formatSelect.value]; if (!file) { throw new Error("下载选项不在已校验的发布索引中"); } const platformName = platformNames[platform] ?? platform; const archName = platform === "macos" && archSelect.value === "arm64" ? "Apple 芯片" : archSelect.value === "arm64" ? "ARM64" : "x64"; const formatName = formatNames[formatSelect.value] ?? formatSelect.value; setReleaseLink( link, file.url, `下载 ${platformName} ${archName} ${formatName} →`, ); meta.textContent = `GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` + `${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`; }; for (const card of downloadCards) { const selects = card.querySelectorAll("select"); for (const select of selects) { select.addEventListener("change", () => { try { updateCard(card); } catch { setFallbackDownloads(); } }); } updateCard(card); } }; const loadRelease = async () => { const controller = new AbortController(); const timeout = window.setTimeout( () => controller.abort(), releaseRequestTimeoutMs, ); try { const response = await fetch(releaseManifestUrl, { cache: "no-store", credentials: "omit", redirect: "error", referrerPolicy: "no-referrer", signal: controller.signal, }); if (!response.ok) { throw new Error(`发布索引请求失败:${response.status}`); } configureDownloads(await readBoundedJson(response)); } catch { setFallbackDownloads(); } finally { window.clearTimeout(timeout); } }; const getSavedTheme = () => { try { const savedTheme = localStorage.getItem("goodbuddy-site-theme"); return savedTheme === "light" || savedTheme === "dark" ? savedTheme : null; } catch { return null; } }; const applyTheme = (theme, persist = false) => { root.dataset.theme = theme; themeToggle?.setAttribute( "aria-label", theme === "dark" ? interfaceCopy.themeLight : interfaceCopy.themeDark, ); themeColor?.setAttribute("content", theme === "dark" ? "#07101f" : "#f6f8fb"); if (persist) { try { localStorage.setItem("goodbuddy-site-theme", theme); } catch { // The selected theme still applies for the current page. } } }; let isolatedMenuContent = null; const isolateMenuContent = () => { if (isolatedMenuContent) { return; } isolatedMenuContent = new Map(); for (const element of document.body.children) { if ( element === header || element === menuBackdrop || element instanceof HTMLScriptElement ) { continue; } isolatedMenuContent.set(element, element.inert); element.inert = true; } }; const restoreMenuContent = () => { if (!isolatedMenuContent) { return; } for (const [element, wasInert] of isolatedMenuContent) { element.inert = wasInert; } isolatedMenuContent = null; }; const closeMenu = ({ restoreFocus = true } = {}) => { const wasOpen = header?.classList.contains("is-menu-open") ?? false; header?.classList.remove("is-menu-open"); menuToggle?.setAttribute("aria-expanded", "false"); menuToggle?.setAttribute("aria-label", interfaceCopy.menuOpen); menuBackdrop?.classList.remove("is-active"); restoreMenuContent(); if (wasOpen && restoreFocus) { menuToggle?.focus(); } }; const openMenu = () => { if (!mobileMenu.matches) { closeMenu({ restoreFocus: false }); return; } header?.classList.add("is-menu-open"); menuToggle?.setAttribute("aria-expanded", "true"); menuToggle?.setAttribute("aria-label", interfaceCopy.menuClose); menuBackdrop?.classList.add("is-active"); isolateMenuContent(); navigation?.querySelector("a")?.focus(); }; const setHeaderState = () => { header?.classList.toggle("is-scrolled", window.scrollY > 12); }; applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light")); setHeaderState(); if (!isEnglish) { void loadRelease(); } themeToggle?.addEventListener("click", () => { applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true); }); menuToggle?.addEventListener("click", () => { if (header?.classList.contains("is-menu-open")) { closeMenu(); } else { openMenu(); } }); navigation?.addEventListener("click", (event) => { if (event.target instanceof HTMLAnchorElement) { closeMenu({ restoreFocus: false }); window.setTimeout(() => { if (!header?.classList.contains("is-menu-open")) { menuToggle?.focus(); } }, 0); } }); menuBackdrop?.addEventListener("click", () => closeMenu()); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && header?.classList.contains("is-menu-open")) { closeMenu(); } }); document.addEventListener("click", (event) => { if ( header?.classList.contains("is-menu-open") && event.target instanceof Node && !header.contains(event.target) ) { closeMenu(); } }); listenMediaQuery(systemTheme, (event) => { if (!getSavedTheme()) { applyTheme(event.matches ? "dark" : "light"); } }); listenMediaQuery(mobileMenu, (event) => { if (!event.matches) { closeMenu({ restoreFocus: false }); } }); window.addEventListener("scroll", setHeaderState, { passive: true }); if (tiltStage instanceof HTMLElement && tiltCard instanceof HTMLElement) { let tiltFrame = 0; const resetTilt = () => { window.cancelAnimationFrame(tiltFrame); tiltStage.classList.remove("is-tilting"); tiltStage.style.setProperty("--spotlight-x", "50%"); tiltStage.style.setProperty("--spotlight-y", "50%"); tiltCard.style.setProperty("--spotlight-x", "50%"); tiltCard.style.setProperty("--spotlight-y", "50%"); tiltStage.style.setProperty("--scene-tilt-x", "0deg"); tiltStage.style.setProperty("--scene-tilt-y", "0deg"); }; const updateTilt = (event) => { if (!finePointer.matches || reducedMotion.matches) { resetTilt(); return; } const bounds = tiltStage.getBoundingClientRect(); const x = Math.min(Math.max((event.clientX - bounds.left) / bounds.width, 0), 1); const y = Math.min(Math.max((event.clientY - bounds.top) / bounds.height, 0), 1); window.cancelAnimationFrame(tiltFrame); tiltFrame = window.requestAnimationFrame(() => { const spotlightX = `${(x * 100).toFixed(1)}%`; const spotlightY = `${(y * 100).toFixed(1)}%`; tiltStage.classList.add("is-tilting"); tiltStage.style.setProperty("--spotlight-x", spotlightX); tiltStage.style.setProperty("--spotlight-y", spotlightY); tiltCard.style.setProperty("--spotlight-x", spotlightX); tiltCard.style.setProperty("--spotlight-y", spotlightY); tiltStage.style.setProperty("--scene-tilt-x", `${((0.5 - y) * 8).toFixed(2)}deg`); tiltStage.style.setProperty("--scene-tilt-y", `${((x - 0.5) * 11).toFixed(2)}deg`); }); }; tiltStage.addEventListener("pointermove", updateTilt, { passive: true }); tiltStage.addEventListener("pointerleave", resetTilt); listenMediaQuery(finePointer, resetTilt); listenMediaQuery(reducedMotion, resetTilt); } const sections = [...document.querySelectorAll("main section[id]")]; const navLinks = [...document.querySelectorAll('.site-navigation a[href^="#"]')]; if ("IntersectionObserver" in window) { const observer = new IntersectionObserver( (entries) => { const visibleSection = entries .filter((entry) => entry.isIntersecting) .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0]; if (!visibleSection) { return; } navLinks.forEach((link) => { const isCurrent = link.getAttribute("href") === `#${visibleSection.target.id}`; if (isCurrent) { link.setAttribute("aria-current", "true"); } else { link.removeAttribute("aria-current"); } }); }, { rootMargin: "-25% 0px -55%", threshold: [0.05, 0.2, 0.5] }, ); sections.forEach((section) => observer.observe(section)); } const currentYear = document.querySelector("[data-current-year]"); if (currentYear) { currentYear.textContent = String(new Date().getFullYear()); } })();