Managed SSH previously tied remote Agent and Runtime artifacts to desktop packaging and Host activation. Settings now manages verified Linux x64 and arm64 .gbagent packages explicitly, with source-aligned download and update plus offline import and export. Each package contains the signed Agent, pinned Node, and compatible OpenCode Runtime, and project activation never downloads one automatically. Dedicated native CI and a protected release workflow publish deterministic, signed cumulative catalogs to GitHub and Beijing OSS without replacing the desktop Latest release. Desktop packaging is metadata-only, remains independent of Agent artifacts and signing credentials, and Linux desktop releases add RPM installers. The managed SSH path also keeps model credentials in Main, enforces Ask as read-only, verifies signed Agent and Runtime inputs, preserves ACP recovery, and reports Host update cancellation and failures accurately. Release note: 远程项目现可在“设置”中手动下载、更新或离线导入签名 Agent 包,桌面安装包与远程组件发布相互独立;Linux 桌面版同时新增 RPM 安装包。
449 lines
14 KiB
JavaScript
449 lines
14 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 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",
|
|
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) => {
|
|
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());
|
|
}
|
|
})();
|