Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
The Chinese Linux download card now includes a LoongArch processor option and fixes its package type to DEB. It reads a separate strictly validated OSS preview index, while the formal six-target release index and English GitHub downloads remain unchanged. The page keeps three symmetric platform cards, disables only the LoongArch selection when its index is unavailable, and labels the package as unverified on real LoongArch hardware. Release note: 官网 Linux 下载新增龙芯 LoongArch 处理器选项,选择后提供独立 DEB 预览包;正式下载与更新索引保持不变。
581 lines
18 KiB
JavaScript
581 lines
18 KiB
JavaScript
(() => {
|
|
"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 loongArchPreviewManifestUrl =
|
|
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/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]"),
|
|
];
|
|
let verifiedRelease;
|
|
let verifiedLoongArchPreview;
|
|
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",
|
|
rpm: "RPM",
|
|
};
|
|
|
|
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, maximumBytes) => {
|
|
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 setLinkText = (link, visibleText) => {
|
|
const newWindowNotice = link.querySelector(".sr-only");
|
|
link.replaceChildren(document.createTextNode(visibleText));
|
|
if (newWindowNotice) {
|
|
link.append(newWindowNotice);
|
|
}
|
|
};
|
|
|
|
const setReleaseLink = (link, url, visibleText) => {
|
|
link.href = url;
|
|
link.target = "_blank";
|
|
link.rel = "noreferrer";
|
|
link.removeAttribute("aria-disabled");
|
|
link.removeAttribute("tabindex");
|
|
link.classList.remove("is-disabled");
|
|
setLinkText(link, visibleText);
|
|
};
|
|
|
|
const getDownloadCardControls = (card) => {
|
|
const platform = card.dataset.downloadCard;
|
|
const archSelect = card.querySelector("[data-download-arch]");
|
|
const formatSelect = card.querySelector("[data-download-format]");
|
|
const container = card.closest(".download-card");
|
|
const link = container?.querySelector("[data-release-link]");
|
|
const meta = container?.querySelector("[data-download-meta]");
|
|
if (
|
|
!platform ||
|
|
!(archSelect instanceof HTMLSelectElement) ||
|
|
!(formatSelect instanceof HTMLSelectElement) ||
|
|
!(link instanceof HTMLAnchorElement) ||
|
|
!(meta instanceof HTMLElement)
|
|
) {
|
|
throw new Error("下载卡片结构无效");
|
|
}
|
|
return { platform, archSelect, formatSelect, link, meta };
|
|
};
|
|
|
|
const setDownloadUnavailable = (link, meta) => {
|
|
link.removeAttribute("href");
|
|
link.removeAttribute("target");
|
|
link.removeAttribute("rel");
|
|
link.setAttribute("aria-disabled", "true");
|
|
link.tabIndex = -1;
|
|
link.classList.add("is-disabled");
|
|
setLinkText(link, "龙芯预览包暂不可用");
|
|
meta.textContent =
|
|
"实验预览 · 仅 DEB · 索引尚未发布或校验失败;" +
|
|
"尚未完成真机验证。";
|
|
};
|
|
|
|
const setLoongArchFormat = (formatSelect, selected) => {
|
|
for (const option of formatSelect.options) {
|
|
const unavailable = selected && option.value !== "deb";
|
|
option.hidden = unavailable;
|
|
option.disabled = unavailable;
|
|
}
|
|
if (selected) {
|
|
formatSelect.value = "deb";
|
|
}
|
|
formatSelect.disabled = selected;
|
|
};
|
|
|
|
const updateDownloadCard = (card) => {
|
|
const {
|
|
platform,
|
|
archSelect,
|
|
formatSelect,
|
|
link,
|
|
meta,
|
|
} = getDownloadCardControls(card);
|
|
const loongArchSelected =
|
|
platform === "linux" && archSelect.value === "loong64";
|
|
setLoongArchFormat(formatSelect, loongArchSelected);
|
|
|
|
if (loongArchSelected) {
|
|
if (!verifiedLoongArchPreview) {
|
|
setDownloadUnavailable(link, meta);
|
|
return;
|
|
}
|
|
setReleaseLink(
|
|
link,
|
|
verifiedLoongArchPreview.artifact.url,
|
|
"下载 Linux 龙芯 LoongArch DEB 预览版 →",
|
|
);
|
|
meta.textContent =
|
|
`GoodBuddy ${verifiedLoongArchPreview.goodBuddyVersion} · ` +
|
|
`${formatFileSize(verifiedLoongArchPreview.artifact.size)} · ` +
|
|
`实验预览 · 未完成龙芯真机验证 · SHA-256 ` +
|
|
`${verifiedLoongArchPreview.artifact.sha256.slice(0, 12)}…`;
|
|
return;
|
|
}
|
|
|
|
if (!verifiedRelease) {
|
|
setReleaseLink(
|
|
link,
|
|
releaseFallbackUrl,
|
|
`前往 GitHub 下载 ${platformNames[platform] ?? platform} →`,
|
|
);
|
|
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
|
|
return;
|
|
}
|
|
|
|
const target =
|
|
verifiedRelease.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 ${verifiedRelease.version} · ` +
|
|
`${formatFileSize(file.size)} · ` +
|
|
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
|
|
};
|
|
|
|
const updateAllDownloadCards = () => {
|
|
for (const card of downloadCards) {
|
|
updateDownloadCard(card);
|
|
}
|
|
};
|
|
|
|
const setFallbackDownloads = () => {
|
|
verifiedRelease = undefined;
|
|
updateAllDownloadCards();
|
|
};
|
|
|
|
const setLoongArchPreviewUnavailable = () => {
|
|
verifiedLoongArchPreview = undefined;
|
|
const linuxCard = downloadCards.find(
|
|
(card) => card.dataset.downloadCard === "linux",
|
|
);
|
|
if (linuxCard) {
|
|
updateDownloadCard(linuxCard);
|
|
}
|
|
};
|
|
|
|
const configureLoongArchPreview = (payload) => {
|
|
if (!releaseIndexApi?.validateLoongArchPreviewIndex) {
|
|
throw new Error("龙芯预览索引校验器不可用");
|
|
}
|
|
verifiedLoongArchPreview =
|
|
releaseIndexApi.validateLoongArchPreviewIndex(payload);
|
|
const linuxCard = downloadCards.find(
|
|
(card) => card.dataset.downloadCard === "linux",
|
|
);
|
|
if (linuxCard) {
|
|
updateDownloadCard(linuxCard);
|
|
}
|
|
};
|
|
|
|
const configureDownloads = (payload) => {
|
|
if (!releaseIndexApi?.validateReleaseIndex) {
|
|
throw new Error("发布索引校验器不可用");
|
|
}
|
|
verifiedRelease = releaseIndexApi.validateReleaseIndex(payload);
|
|
updateAllDownloadCards();
|
|
};
|
|
|
|
const initializeDownloadCards = () => {
|
|
for (const card of downloadCards) {
|
|
for (const select of card.querySelectorAll("select")) {
|
|
select.addEventListener("change", () => {
|
|
try {
|
|
updateDownloadCard(card);
|
|
} catch {
|
|
setFallbackDownloads();
|
|
}
|
|
});
|
|
}
|
|
updateDownloadCard(card);
|
|
}
|
|
};
|
|
|
|
const fetchBoundedIndex = async (
|
|
url,
|
|
maximumBytes,
|
|
errorLabel,
|
|
) => {
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(
|
|
() => controller.abort(),
|
|
releaseRequestTimeoutMs,
|
|
);
|
|
try {
|
|
const response = await fetch(url, {
|
|
cache: "no-store",
|
|
credentials: "omit",
|
|
redirect: "error",
|
|
referrerPolicy: "no-referrer",
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${errorLabel}请求失败:${response.status}`);
|
|
}
|
|
return await readBoundedJson(response, maximumBytes);
|
|
} finally {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
};
|
|
|
|
const loadRelease = async () => {
|
|
try {
|
|
configureDownloads(
|
|
await fetchBoundedIndex(
|
|
releaseManifestUrl,
|
|
releaseIndexApi?.maximumIndexBytes,
|
|
"发布索引",
|
|
),
|
|
);
|
|
} catch {
|
|
setFallbackDownloads();
|
|
}
|
|
};
|
|
|
|
const loadLoongArchPreview = async () => {
|
|
try {
|
|
configureLoongArchPreview(
|
|
await fetchBoundedIndex(
|
|
loongArchPreviewManifestUrl,
|
|
releaseIndexApi?.maximumPreviewIndexBytes,
|
|
"龙芯预览索引",
|
|
),
|
|
);
|
|
} catch {
|
|
setLoongArchPreviewUnavailable();
|
|
}
|
|
};
|
|
|
|
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();
|
|
initializeDownloadCards();
|
|
if (!isEnglish) {
|
|
void loadRelease();
|
|
void loadLoongArchPreview();
|
|
}
|
|
|
|
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());
|
|
}
|
|
})();
|