mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(docs): assemble custom offline driver bundles in the browser
This commit is contained in:
@@ -6765,6 +6765,106 @@ mod agent_registry_install_tests {
|
||||
assert!(!manager.driver_native_platform_path(SQLITE_WORKER_DRIVER_KEY, "linux-x64").exists());
|
||||
assert!(!manager.driver_native_installed(SQLITE_WORKER_DRIVER_KEY));
|
||||
}
|
||||
|
||||
/// The website drivers page assembles custom offline bundles in the
|
||||
/// browser: raw artifacts under `drivers/`, the JRE tarball under `jre/`,
|
||||
/// and a synthesized registry whose artifact URLs are `offline://` file
|
||||
/// names. This test feeds exactly that JSON shape (including the extra
|
||||
/// `external_driver_required` field the package registries carry) through
|
||||
/// the real inspect + import pipeline.
|
||||
#[tokio::test]
|
||||
async fn offline_zip_imports_a_web_assembled_custom_bundle() {
|
||||
let platform = AgentManager::current_platform();
|
||||
let jar_name = "dbx-agent-h2-1.0.0.jar";
|
||||
let worker_x64 = "dbx-agent-sqlite-worker-0.1.6-linux-x64";
|
||||
let worker_arm = "dbx-agent-sqlite-worker-0.1.6-linux-aarch64";
|
||||
let jre_name = format!("dbx-jre-21-{platform}.tar.zst");
|
||||
let jar_bytes = test_agent_jar();
|
||||
let jre_bytes = b"fake-jre-archive".to_vec();
|
||||
let worker_x64_bytes = linux_native_binary(62);
|
||||
let worker_arm_bytes = linux_native_binary(183);
|
||||
|
||||
let registry_json = serde_json::json!({
|
||||
"jres": {
|
||||
"21": {
|
||||
"version": "21",
|
||||
"platforms": {
|
||||
platform: {
|
||||
"url": format!("offline://{jre_name}"),
|
||||
"sha256": sha256_bytes(&jre_bytes),
|
||||
"size": jre_bytes.len(),
|
||||
"format": "tar_zstd"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"drivers": {
|
||||
"h2": {
|
||||
"version": "1.0.0",
|
||||
"label": "H2",
|
||||
"min_app_version": "0.6.33",
|
||||
"jre": "21",
|
||||
"external_driver_required": false,
|
||||
"jar": {
|
||||
"url": format!("offline://{jar_name}"),
|
||||
"sha256": sha256_bytes(&jar_bytes),
|
||||
"size": jar_bytes.len()
|
||||
}
|
||||
},
|
||||
"sqlite-worker": {
|
||||
"version": "0.1.6",
|
||||
"label": "SQLite SSH Worker",
|
||||
"min_app_version": "0.6.30",
|
||||
"jre": "21",
|
||||
"external_driver_required": false,
|
||||
"native": {
|
||||
"linux-x64": {
|
||||
"url": format!("offline://{worker_x64}"),
|
||||
"sha256": sha256_bytes(&worker_x64_bytes),
|
||||
"size": worker_x64_bytes.len()
|
||||
},
|
||||
"linux-aarch64": {
|
||||
"url": format!("offline://{worker_arm}"),
|
||||
"sha256": sha256_bytes(&worker_arm_bytes),
|
||||
"size": worker_arm_bytes.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let registry: AgentRegistry =
|
||||
serde_json::from_value(registry_json).expect("web-synthesized registry JSON must deserialize");
|
||||
|
||||
let (_dir, package) = write_offline_zip(
|
||||
®istry,
|
||||
&[
|
||||
(format!("drivers/{jar_name}"), jar_bytes),
|
||||
(format!("drivers/{worker_x64}"), worker_x64_bytes),
|
||||
(format!("drivers/{worker_arm}"), worker_arm_bytes),
|
||||
(format!("jre/{jre_name}"), jre_bytes),
|
||||
],
|
||||
);
|
||||
|
||||
let plan = inspect_offline_zip(&package).expect("web-synthesized bundle must pass inspection");
|
||||
assert_eq!(plan.driver_keys, vec!["h2".to_string(), SQLITE_WORKER_DRIVER_KEY.to_string()]);
|
||||
assert!(plan.includes_jre);
|
||||
|
||||
let manager = test_manager("offline-web-custom-bundle");
|
||||
let result = import_offline_zip(&manager, &package, |_| {}).await.unwrap();
|
||||
// The fake JRE archive cannot extract (no java executable inside), but
|
||||
// that must only surface as an isolated JRE failure — never block the
|
||||
// drivers, exactly like a corrupt real-world package.
|
||||
assert!(result.failures.iter().all(|failure| failure.is_jre), "unexpected failures: {:?}", result.failures);
|
||||
// The worker contributes one entry per remote platform, so it appears twice.
|
||||
assert_eq!(
|
||||
result.drivers_installed,
|
||||
vec!["h2".to_string(), SQLITE_WORKER_DRIVER_KEY.to_string(), SQLITE_WORKER_DRIVER_KEY.to_string()]
|
||||
);
|
||||
assert!(manager.driver_jar_path("h2").is_file());
|
||||
assert!(manager.driver_native_platform_path(SQLITE_WORKER_DRIVER_KEY, "linux-x64").is_file());
|
||||
assert!(manager.driver_native_platform_path(SQLITE_WORKER_DRIVER_KEY, "linux-aarch64").is_file());
|
||||
assert_eq!(manager.load_state().installed_drivers.get("h2").map(|d| d.version.as_str()), Some("1.0.0"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LandingNav } from "@/components/landing/LandingNav";
|
||||
import { downloadLinksFor, formatSize, type AgentDownloadCatalog, type DownloadSource, type JreDisplayEntry, type NativeAgentDisplayEntry, type OfflineBundleEntry } from "@/lib/agentRegistry";
|
||||
import { Archive, Cpu, Database, Download, Plug, Search, Terminal, X } from "lucide-react";
|
||||
import { LandingSelect } from "@/components/landing/LandingSelect";
|
||||
import { downloadLinksFor, formatSize, platformLabels, type AgentDownloadCatalog, type AgentRegistry, type DownloadSource, type JreDisplayEntry, type NativeAgentDisplayEntry, type OfflineBundleEntry } from "@/lib/agentRegistry";
|
||||
import { assembleCustomBundle, buildCustomBundleOptions, cnbMirrorUrl, computeBundlePlan, CUSTOM_BUNDLE_PLATFORMS, proxyUrl, resolveBundleJre, type BundleProgress } from "@/lib/agentBundle";
|
||||
import { Archive, Cpu, Database, Download, ListChecks, Plug, Search, Terminal, X } from "lucide-react";
|
||||
import { resolveLang, type DocsLang } from "@/lib/i18n";
|
||||
|
||||
const i18n = {
|
||||
@@ -43,6 +45,30 @@ const i18n = {
|
||||
official: "Official",
|
||||
},
|
||||
downloadHint: "For air-gapped environments: download the bundle for your platform on an internet-connected machine, then transfer it to the offline machine and import it in DBX from Settings > Driver Manager. Use the driver and JRE tabs only when you need individual artifacts.",
|
||||
custom: "Custom Bundle",
|
||||
customDesc: "Check only the drivers you need; this page assembles a single offline ZIP that imports directly in DBX from Settings > Driver Manager.",
|
||||
customPlatform: "Target platform",
|
||||
customIncludeJre: `Include JRE 21`,
|
||||
customIncludeJreForced: "Included automatically — Java agents require the JRE",
|
||||
customSource: "Fetch source",
|
||||
customSourceCnb: "CNB (direct)",
|
||||
customSourceGithub: "GitHub (via this site)",
|
||||
customDownload: "Download bundle",
|
||||
customEstimate: "Estimated download",
|
||||
customSelected: "selected",
|
||||
customSelectAll: "Select all",
|
||||
customClear: "Clear",
|
||||
customWorkersNote: "The SQLite SSH Worker Linux packages are always included — remote SSH hosts execute them.",
|
||||
customNoDrivers: "No drivers match this platform or search.",
|
||||
customPhaseDownload: "Downloading",
|
||||
customPhaseUnpack: "Unpacking",
|
||||
customPhaseAssemble: "Assembling ZIP",
|
||||
customPhaseDone: "Custom bundle downloaded ({size}). Import it in DBX from Settings > Driver Manager > offline import.",
|
||||
customErrorTitle: "Bundle build failed",
|
||||
customCancel: "Cancel",
|
||||
customRetryHint: "Check the network or switch the fetch source, then try again.",
|
||||
customNativeBadge: "Native",
|
||||
customJavaBadge: "Java",
|
||||
},
|
||||
cn: {
|
||||
title: "离线驱动下载",
|
||||
@@ -79,10 +105,45 @@ const i18n = {
|
||||
official: "官方下载",
|
||||
},
|
||||
downloadHint: "内网环境使用说明:在有网的电脑上下载对应平台的整包,然后传输到内网机器,在 DBX 的“设置 > 驱动管理”中导入。只有需要单个产物时再使用驱动和 JRE 标签页。",
|
||||
custom: "自定义离线包",
|
||||
customDesc: "只勾选需要的驱动,本页会在浏览器里合成一个离线 ZIP,在 DBX 的“设置 > 驱动管理”中离线导入即可。",
|
||||
customPlatform: "目标平台",
|
||||
customIncludeJre: "包含 JRE 21",
|
||||
customIncludeJreForced: "已自动包含——Java 驱动必须搭配 JRE",
|
||||
customSource: "下载源",
|
||||
customSourceCnb: "CNB(直连)",
|
||||
customSourceGithub: "GitHub(经本站代理)",
|
||||
customDownload: "下载离线包",
|
||||
customEstimate: "预计下载量",
|
||||
customSelected: "已选",
|
||||
customSelectAll: "全选",
|
||||
customClear: "清空",
|
||||
customWorkersNote: "SQLite SSH Worker 的 Linux 包会自动包含——它们在远端 SSH 主机上执行。",
|
||||
customNoDrivers: "没有匹配该平台或搜索词的驱动。",
|
||||
customPhaseDownload: "正在下载",
|
||||
customPhaseUnpack: "正在解包",
|
||||
customPhaseAssemble: "正在打包",
|
||||
customPhaseDone: "自定义离线包已下载({size})。在 DBX 的“设置 > 驱动管理”中离线导入即可。",
|
||||
customErrorTitle: "打包失败",
|
||||
customCancel: "取消",
|
||||
customRetryHint: "请检查网络或切换下载源后重试。",
|
||||
customNativeBadge: "原生",
|
||||
customJavaBadge: "Java",
|
||||
},
|
||||
};
|
||||
|
||||
type ActiveTab = "bundles" | "drivers" | "native" | "jre" | "jdbcPlugin";
|
||||
type ActiveTab = "bundles" | "custom" | "drivers" | "native" | "jre" | "jdbcPlugin";
|
||||
|
||||
type BundleSource = "cnb" | "github";
|
||||
|
||||
function detectBundlePlatform(): string {
|
||||
if (typeof navigator === "undefined") return "windows-x64";
|
||||
const ua = navigator.userAgent;
|
||||
if (/Windows/i.test(ua)) return /ARM|aarch64/i.test(ua) ? "windows-aarch64" : "windows-x64";
|
||||
if (/Macintosh|Mac OS/i.test(ua)) return "macos-x64";
|
||||
if (/Linux|X11/i.test(ua)) return /aarch64|arm64/i.test(ua) ? "linux-aarch64" : "linux-x64";
|
||||
return "windows-x64";
|
||||
}
|
||||
|
||||
function platformKey(j: JreDisplayEntry): string {
|
||||
return `${j.jreKey}-${j.platformKey}`;
|
||||
@@ -92,10 +153,6 @@ function bundleKey(bundle: OfflineBundleEntry): string {
|
||||
return `${bundle.platformKey}-${bundle.filename}`;
|
||||
}
|
||||
|
||||
function nativeKey(agent: NativeAgentDisplayEntry): string {
|
||||
return `${agent.key}-${agent.platformKey}`;
|
||||
}
|
||||
|
||||
type NativeAgentGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -134,7 +191,7 @@ function matchesSearch(values: Array<string | number | undefined>, query: string
|
||||
return values.filter(Boolean).join(" ").toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloadCatalog }) {
|
||||
export function DriversClient({ initialCatalog, initialRegistry }: { initialCatalog: AgentDownloadCatalog; initialRegistry: AgentRegistry }) {
|
||||
const params = useParams();
|
||||
const rawLang = params?.lang as string | undefined;
|
||||
const lang = resolveLang(rawLang ?? "en");
|
||||
@@ -145,6 +202,16 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedNativePlatforms, setSelectedNativePlatforms] = useState<Record<string, string>>({});
|
||||
|
||||
const [bundlePlatform, setBundlePlatform] = useState("windows-x64");
|
||||
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [includeJre, setIncludeJre] = useState(false);
|
||||
const [bundleSource, setBundleSource] = useState<BundleSource>("cnb");
|
||||
const [bundleBusy, setBundleBusy] = useState(false);
|
||||
const [bundleProgress, setBundleProgress] = useState<BundleProgress | null>(null);
|
||||
const [bundleError, setBundleError] = useState<string | null>(null);
|
||||
const [bundleDone, setBundleDone] = useState<number | null>(null);
|
||||
const bundleAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const bundles = catalog?.bundles ?? [];
|
||||
const drivers = catalog?.drivers ?? [];
|
||||
const nativeAgents = catalog?.nativeAgents ?? [];
|
||||
@@ -204,8 +271,133 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
|
||||
const filteredJres = useMemo(() => jres.filter((j) => matchesSearch([j.platformLabel, j.platformKey, j.jreVersion, j.jreKey, formatSize(j.info.size)], normalizedSearch)), [jres, normalizedSearch]);
|
||||
const filteredJdbcPlugin = useMemo(() => (jdbcPlugin && matchesSearch([jdbcPlugin.label, jdbcPlugin.filename, jdbcPlugin.url, t.jdbcPlugin, t.jdbcPluginDesc], normalizedSearch) ? [jdbcPlugin] : []), [jdbcPlugin, normalizedSearch, t.jdbcPlugin, t.jdbcPluginDesc]);
|
||||
|
||||
const activeCount = activeTab === "bundles" ? filteredBundles.length : activeTab === "drivers" ? filteredDrivers.length : activeTab === "native" ? filteredNativeGroups.length : activeTab === "jre" ? filteredJres.length : filteredJdbcPlugin.length;
|
||||
const activeTotal = activeTab === "bundles" ? bundles.length : activeTab === "drivers" ? drivers.length : activeTab === "native" ? nativeGroups.length : activeTab === "jre" ? jres.length : jdbcPlugin ? 1 : 0;
|
||||
useEffect(() => {
|
||||
// Detected after hydration: the prerendered HTML cannot know the visitor's
|
||||
// platform, and a differing initial state would hydration-mismatch.
|
||||
setBundlePlatform(detectBundlePlatform());
|
||||
}, []);
|
||||
|
||||
const bundleOptions = useMemo(() => buildCustomBundleOptions(initialRegistry, bundlePlatform), [initialRegistry, bundlePlatform]);
|
||||
const filteredBundleOptions = useMemo(() => bundleOptions.filter((option) => matchesSearch([option.label, option.key, option.version, option.kind], normalizedSearch)), [bundleOptions, normalizedSearch]);
|
||||
const bundleJre = useMemo(() => resolveBundleJre(initialRegistry, bundlePlatform), [initialRegistry, bundlePlatform]);
|
||||
const jreRequired = useMemo(() => bundleOptions.some((option) => option.requiresJre && selectedKeys.has(option.key)), [bundleOptions, selectedKeys]);
|
||||
const bundlePlan = useMemo(() => computeBundlePlan(initialRegistry, bundlePlatform, selectedKeys, includeJre), [initialRegistry, bundlePlatform, selectedKeys, includeJre]);
|
||||
|
||||
const toggleBundleDriver = (key: string) => {
|
||||
setSelectedKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const changeBundlePlatform = (platformKey: string) => {
|
||||
setBundlePlatform(platformKey);
|
||||
setSelectedKeys(new Set());
|
||||
};
|
||||
|
||||
const fetchBundlePackage = async (url: string, onProgress: (received: number) => void, signal: AbortSignal): Promise<Uint8Array> => {
|
||||
const proxy = proxyUrl(window.location.origin, url);
|
||||
const mirror = cnbMirrorUrl(url);
|
||||
const attempts: Array<{ label: string; url: string }> =
|
||||
bundleSource === "cnb"
|
||||
? [
|
||||
{ label: t.customSourceCnb, url: mirror },
|
||||
{ label: t.customSourceGithub, url: proxy },
|
||||
]
|
||||
: [
|
||||
{ label: t.customSourceGithub, url: proxy },
|
||||
{ label: t.customSourceCnb, url: mirror },
|
||||
];
|
||||
const errors: string[] = [];
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const response = await fetch(attempt.url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
onProgress(bytes.length);
|
||||
return bytes;
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(received);
|
||||
}
|
||||
const bytes = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
// TypeError is the browser's opaque network/CORS failure; surface a
|
||||
// readable cause so the combined message stays diagnosable.
|
||||
const cause = error instanceof TypeError ? "network error" : error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${attempt.label}: ${cause}`);
|
||||
}
|
||||
}
|
||||
throw new Error(errors.join(" · "));
|
||||
};
|
||||
|
||||
const startBundleDownload = async () => {
|
||||
if (bundleBusy || selectedKeys.size === 0) return;
|
||||
const controller = new AbortController();
|
||||
bundleAbortRef.current = controller;
|
||||
setBundleBusy(true);
|
||||
setBundleError(null);
|
||||
setBundleDone(null);
|
||||
setBundleProgress(null);
|
||||
try {
|
||||
const { blob, filename } = await assembleCustomBundle(bundlePlan, fetchBundlePackage, setBundleProgress, controller.signal);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
setBundleDone(blob.size);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setBundleError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
setBundleBusy(false);
|
||||
setBundleProgress(null);
|
||||
bundleAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelBundleDownload = () => {
|
||||
bundleAbortRef.current?.abort();
|
||||
};
|
||||
|
||||
const bundleProgressText = useMemo(() => {
|
||||
if (!bundleProgress) return "";
|
||||
if (bundleProgress.phase === "download") {
|
||||
const expected = bundleProgress.expectedBytes > 0 ? ` / ${formatSize(bundleProgress.expectedBytes)}` : "";
|
||||
return `${t.customPhaseDownload} ${bundleProgress.label} (${bundleProgress.index}/${bundleProgress.total}) · ${formatSize(bundleProgress.received)}${expected}`;
|
||||
}
|
||||
if (bundleProgress.phase === "unpack") return `${t.customPhaseUnpack} ${bundleProgress.label}`;
|
||||
if (bundleProgress.phase === "assemble") return t.customPhaseAssemble;
|
||||
return "";
|
||||
}, [bundleProgress, t]);
|
||||
|
||||
const activeCount = activeTab === "bundles" ? filteredBundles.length : activeTab === "custom" ? filteredBundleOptions.length : activeTab === "drivers" ? filteredDrivers.length : activeTab === "native" ? filteredNativeGroups.length : activeTab === "jre" ? filteredJres.length : filteredJdbcPlugin.length;
|
||||
const activeTotal = activeTab === "bundles" ? bundles.length : activeTab === "custom" ? bundleOptions.length : activeTab === "drivers" ? drivers.length : activeTab === "native" ? nativeGroups.length : activeTab === "jre" ? jres.length : jdbcPlugin ? 1 : 0;
|
||||
|
||||
return (
|
||||
<main className="landing">
|
||||
@@ -238,6 +430,14 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
|
||||
<Archive size={14} />
|
||||
{t.bundles}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("custom")}
|
||||
className={`inline-flex h-8 cursor-pointer items-center justify-center gap-2 rounded-[6px] px-3 text-xs font-[650] transition-colors ${activeTab === "custom" ? "bg-landing-blue text-white" : "text-landing-muted hover:text-landing-ink"}`}
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
{t.custom}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("drivers")}
|
||||
@@ -350,6 +550,171 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "custom" && (
|
||||
<>
|
||||
<p className="border-b border-landing-line px-5 py-3 text-sm text-landing-muted whitespace-nowrap max-[760px]:whitespace-normal max-[760px]:px-4">{t.customDesc}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 border-b border-landing-line px-5 py-3 max-[760px]:px-4">
|
||||
<div className="flex items-center gap-2 text-xs text-landing-muted">
|
||||
<span className="shrink-0">{t.customPlatform}</span>
|
||||
<LandingSelect
|
||||
value={bundlePlatform}
|
||||
options={CUSTOM_BUNDLE_PLATFORMS.map((platformKey) => ({ value: platformKey, label: platformLabels[platformKey] ?? platformKey }))}
|
||||
onChange={changeBundlePlatform}
|
||||
ariaLabel={t.customPlatform}
|
||||
className="min-w-[190px]"
|
||||
/>
|
||||
</div>
|
||||
<label className={`flex items-center gap-2 text-xs ${jreRequired ? "text-landing-sky" : "text-landing-muted"}`} title={jreRequired ? t.customIncludeJreForced : undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeJre || jreRequired}
|
||||
disabled={jreRequired || !bundleJre || bundleBusy}
|
||||
onChange={() => setIncludeJre((current) => !current)}
|
||||
className="h-3.5 w-3.5 accent-landing-blue"
|
||||
/>
|
||||
{t.customIncludeJre}
|
||||
{bundleJre ? ` (${formatSize(bundleJre.size)})` : ""}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-xs text-landing-muted">
|
||||
<span className="shrink-0">{t.customSource}</span>
|
||||
<LandingSelect<BundleSource>
|
||||
value={bundleSource}
|
||||
options={[
|
||||
{ value: "cnb", label: t.customSourceCnb },
|
||||
{ value: "github", label: t.customSourceGithub },
|
||||
]}
|
||||
onChange={setBundleSource}
|
||||
disabled={bundleBusy}
|
||||
ariaLabel={t.customSource}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative min-w-[200px] flex-1 max-[760px]:min-w-full">
|
||||
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-landing-muted" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t.search}
|
||||
className="h-8 w-full rounded-[6px] border border-landing-line bg-black/10 pl-8 pr-8 text-xs text-landing-ink outline-none transition-colors placeholder:text-landing-muted focus:border-landing-blue"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1.5 top-1/2 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded-[5px] text-landing-muted hover:bg-landing-soft hover:text-landing-ink"
|
||||
aria-label={t.clearSearch}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[520px] overflow-y-auto">
|
||||
<table className="w-full table-fixed border-collapse text-sm max-[760px]:block">
|
||||
<thead className="sticky top-0 z-10 bg-landing-panel text-xs font-medium text-landing-muted max-[760px]:hidden">
|
||||
<tr className="border-b border-landing-line">
|
||||
<th className="w-[36px] px-5 py-2.5 text-left font-medium"><span className="sr-only">{t.customSelectAll}</span></th>
|
||||
<th className="w-[24%] px-5 py-2.5 text-left font-medium">Driver</th>
|
||||
<th className="px-5 py-2.5 text-left font-medium">{t.version}</th>
|
||||
<th className="w-[116px] px-5 py-2.5 text-right font-medium">{t.size}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="max-[760px]:block">
|
||||
{filteredBundleOptions.map((option) => {
|
||||
const checked = selectedKeys.has(option.key);
|
||||
return (
|
||||
<tr key={option.key} className={`border-b border-landing-line transition-colors last:border-b-0 ${checked ? "bg-landing-blue/10" : "hover:bg-landing-panel"} max-[760px]:grid max-[760px]:grid-cols-[auto_1fr_auto] max-[760px]:items-center max-[760px]:gap-3 max-[760px]:px-4`}>
|
||||
<td className="px-5 py-3 max-[760px]:px-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={bundleBusy}
|
||||
onChange={() => toggleBundleDriver(option.key)}
|
||||
aria-label={`${t.customDownload}: ${option.label}`}
|
||||
className="h-4 w-4 cursor-pointer accent-landing-blue"
|
||||
/>
|
||||
</td>
|
||||
<td className="min-w-0 px-5 py-3 font-medium text-landing-ink max-[760px]:px-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
<span className="hidden shrink-0 rounded-[5px] border border-landing-blue/35 bg-landing-blue/10 px-1.5 py-0.5 font-mono text-[11px] text-landing-sky max-[760px]:inline">{option.key}</span>
|
||||
<span className={`hidden shrink-0 rounded-[5px] border px-1.5 py-0.5 font-mono text-[11px] max-[760px]:inline ${option.kind === "native" ? "border-landing-green/35 bg-landing-green/10 text-landing-green" : "border-landing-line bg-black/10 text-landing-muted"}`}>
|
||||
{option.kind === "native" ? t.customNativeBadge : t.customJavaBadge}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-landing-muted max-[760px]:hidden">{option.version}</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-right text-xs text-landing-muted max-[760px]:hidden">{formatSize(option.packageSize)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredBundleOptions.length === 0 && <div className="px-5 py-12 text-center text-sm text-landing-muted">{t.customNoDrivers}</div>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-landing-line px-5 py-3 max-[760px]:px-4">
|
||||
<div className="min-w-0 text-xs leading-[1.6] text-landing-muted">
|
||||
<div>
|
||||
{t.customSelected} {selectedKeys.size} · {t.customEstimate} {formatSize(bundlePlan.totalDownloadBytes)}
|
||||
</div>
|
||||
<div className="text-landing-muted/80">{t.customWorkersNote}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={bundleBusy || filteredBundleOptions.length === 0}
|
||||
onClick={() => setSelectedKeys(new Set(filteredBundleOptions.map((option) => option.key)))}
|
||||
className="h-8 cursor-pointer rounded-[6px] border border-landing-line px-3 text-xs font-medium text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t.customSelectAll}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bundleBusy || selectedKeys.size === 0}
|
||||
onClick={() => setSelectedKeys(new Set())}
|
||||
className="h-8 cursor-pointer rounded-[6px] border border-landing-line px-3 text-xs font-medium text-landing-muted transition-colors hover:border-landing-blue hover:text-landing-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t.customClear}
|
||||
</button>
|
||||
{bundleBusy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelBundleDownload}
|
||||
className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-[6px] border border-landing-line px-3 text-xs font-[650] text-landing-ink transition-colors hover:border-[#ff7376] hover:text-[#ff7376]"
|
||||
>
|
||||
<X size={13} />
|
||||
{t.customCancel}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedKeys.size === 0}
|
||||
onClick={startBundleDownload}
|
||||
className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-[6px] bg-landing-blue px-3 text-xs font-[650] text-white transition-colors hover:bg-landing-blue/85 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Download size={13} />
|
||||
{t.customDownload}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bundleProgress && bundleBusy && (
|
||||
<div className="border-t border-landing-line px-5 py-3 text-xs text-landing-sky max-[760px]:px-4">{bundleProgressText}</div>
|
||||
)}
|
||||
{bundleError && (
|
||||
<div className="border-t border-landing-line px-5 py-3 text-xs leading-[1.6] text-[#ff7376] max-[760px]:px-4">
|
||||
<strong>{t.customErrorTitle}:</strong>
|
||||
{bundleError}
|
||||
<div className="text-landing-muted">{t.customRetryHint}</div>
|
||||
</div>
|
||||
)}
|
||||
{bundleDone !== null && !bundleBusy && (
|
||||
<div className="border-t border-landing-line px-5 py-3 text-xs leading-[1.6] text-landing-green max-[760px]:px-4">
|
||||
{t.customPhaseDone.replace("{size}", formatSize(bundleDone))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "drivers" && (
|
||||
<>
|
||||
<p className="border-b border-landing-line px-5 py-3 text-sm text-landing-muted whitespace-nowrap max-[760px]:whitespace-normal max-[760px]:px-4">{t.driversDesc}</p>
|
||||
@@ -417,17 +782,13 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 max-[760px]:col-span-2 max-[760px]:px-0 max-[760px]:pt-0">
|
||||
<select
|
||||
<LandingSelect
|
||||
value={selectedAgent.platformKey}
|
||||
onChange={(event) => setSelectedNativePlatforms((current) => ({ ...current, [group.key]: event.target.value }))}
|
||||
className="h-8 min-w-[190px] rounded-[6px] border border-landing-line bg-black/10 px-2.5 text-xs text-landing-ink outline-none transition-colors focus:border-landing-blue max-[760px]:w-full"
|
||||
>
|
||||
{group.options.map((option) => (
|
||||
<option key={nativeKey(option)} value={option.platformKey} className="bg-[#202123] text-landing-ink">
|
||||
{option.platformLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={group.options.map((option) => ({ value: option.platformKey, label: option.platformLabel }))}
|
||||
onChange={(platformKey) => setSelectedNativePlatforms((current) => ({ ...current, [group.key]: platformKey }))}
|
||||
ariaLabel={`${group.label}: ${t.platform}`}
|
||||
className="min-w-[190px] max-[760px]:w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-landing-muted max-[760px]:hidden">{group.version}</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-right text-xs text-landing-muted max-[760px]:hidden">{formatSize(selectedAgent.info.size)}</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DriversClient } from "./DriversClient";
|
||||
import { fetchAgentDownloadCatalog } from "@/lib/agentRegistrySource";
|
||||
import { fetchAgentDownloadCatalogWithRegistry } from "@/lib/agentRegistrySource";
|
||||
import { buildMetadata } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
import { resolveLang } from "@/lib/i18n";
|
||||
@@ -30,7 +30,7 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
|
||||
}
|
||||
|
||||
export default async function DriversPage() {
|
||||
const catalog = await fetchAgentDownloadCatalog();
|
||||
if (!catalog) throw new Error("Unable to generate the static driver catalog from R2 or CNB");
|
||||
return <DriversClient initialCatalog={catalog} />;
|
||||
const source = await fetchAgentDownloadCatalogWithRegistry();
|
||||
if (!source) throw new Error("Unable to generate the static driver catalog from R2 or CNB");
|
||||
return <DriversClient initialCatalog={source.catalog} initialRegistry={source.registry} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface LandingSelectOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing-styled dropdown replacing native <select>, which cannot be themed to
|
||||
* match the dark landing cards. The option list renders in a body portal with
|
||||
* fixed coordinates so `overflow-hidden` cards and scrollable table rows never
|
||||
* clip it; it closes on outside click, Escape, and scroll/resize.
|
||||
*/
|
||||
export function LandingSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
className = "",
|
||||
}: {
|
||||
value: T;
|
||||
options: ReadonlyArray<LandingSelectOption<T>>;
|
||||
onChange: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popupStyle, setPopupStyle] = useState<React.CSSProperties | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(() => Math.max(0, options.findIndex((option) => option.value === value)));
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const popupRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const toggle = (next: boolean) => {
|
||||
if (!next) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const rect = rootRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const dropUp = window.innerHeight - rect.bottom < 280 && rect.top > 280;
|
||||
setPopupStyle({
|
||||
position: "fixed",
|
||||
left: Math.max(8, Math.min(rect.left, window.innerWidth - Math.max(rect.width, 200) - 8)),
|
||||
minWidth: rect.width,
|
||||
...(dropUp ? { bottom: `${window.innerHeight - rect.top + 4}px` } : { top: `${rect.bottom + 4}px` }),
|
||||
});
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!rootRef.current?.contains(target) && !popupRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
const onAnchorMoved = () => setOpen(false);
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("scroll", onAnchorMoved, true);
|
||||
window.addEventListener("resize", onAnchorMoved);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("scroll", onAnchorMoved, true);
|
||||
window.removeEventListener("resize", onAnchorMoved);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const selected = options.find((option) => option.value === value);
|
||||
const commit = (index: number) => {
|
||||
const option = options[index];
|
||||
if (!option) return;
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onButtonKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex(Math.max(0, options.findIndex((option) => option.value === value)));
|
||||
toggle(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onListKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => Math.min(current + 1, options.length - 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => Math.max(current - 1, 0));
|
||||
} else if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
commit(activeIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => !disabled && toggle(!open)}
|
||||
onKeyDown={onButtonKeyDown}
|
||||
className="inline-flex h-8 w-full cursor-pointer items-center justify-between gap-2 rounded-[6px] border border-landing-line bg-black/10 px-2.5 text-xs text-landing-ink outline-none transition-colors hover:border-landing-blue focus:border-landing-blue disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 truncate">{selected?.label ?? value}</span>
|
||||
<ChevronDown size={13} className={`shrink-0 text-landing-muted transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<ul
|
||||
ref={popupRef}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onListKeyDown}
|
||||
style={popupStyle ?? undefined}
|
||||
className="z-50 max-h-[260px] w-max min-w-[200px] overflow-y-auto rounded-[7px] border border-landing-line bg-[#202123] py-1 shadow-[0_8px_24px_rgba(0,0,0,0.45)] outline-none"
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<li key={option.value} role="option" aria-selected={option.value === value}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => commit(index)}
|
||||
className={`flex w-full cursor-pointer items-center justify-between gap-3 whitespace-nowrap px-3 py-1.5 text-left text-xs transition-colors ${option.value === value ? "text-landing-sky" : "text-landing-ink"} ${index === activeIndex ? "bg-landing-soft" : ""}`}
|
||||
>
|
||||
<span className="min-w-0 truncate">{option.label}</span>
|
||||
{option.value === value && <Check size={13} className="shrink-0" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
assembleCustomBundle,
|
||||
buildCustomBundleOptions,
|
||||
buildSqliteWorkerOptions,
|
||||
buildStoredZipParts,
|
||||
cnbMirrorUrl,
|
||||
computeBundlePlan,
|
||||
crc32,
|
||||
extractDriverPackage,
|
||||
parseTarEntries,
|
||||
proxyUrl,
|
||||
resolveBundleJre,
|
||||
type BundleFetch,
|
||||
type BundleProgress,
|
||||
} from "./agentBundle";
|
||||
import type { AgentRegistry } from "./agentRegistry";
|
||||
|
||||
// One real zstd-compressed tar package per artifact kind, generated from the
|
||||
// same packaging pipeline the agents release uses: agent-registry.json plus a
|
||||
// single drivers/<raw artifact> entry whose sha256/size live in the package
|
||||
// registry.
|
||||
const H2_PACKAGE_B64 =
|
||||
"KLUv/WQAJ+UIANLPMR5Abx4MRfRiaicr9JyLFkQ+oPd7okg66hWu3jEMvzJUQUDQQoJggC69cU2gMs45/RfsImTnS3ntYEKoNtdhe9Y+T5zT+oxSygeSgxkvdPhYaO8377m7rfHb6Qi9MqZeLOCMQMXePMm10eunqEJO3894EvlMaVSl/JLK92rnq5uGRdYoBFKphB292g/R2xr16sYac+209MDO6PwUnG+vDMbp6XOjMao1latkmqgGUFJC55RrrzX1XmxKekwNZsmSCS8+ByTSL2ogIKBI5OZiDvEB4gGBM0YluJsxQKawR+ruyWIPqAuBfwjWTQIMguX/ukuazBE4rMCIDSoGgHXg1Ns/gAgRNscABWE+lARNsumy9UL2QQWYRssdLqm+fQ==";
|
||||
const WORKER_X64_PACKAGE_B64 =
|
||||
"KLUv/WQAJ8UJANaQOBUg33aNNeLmwGRzcMD4KmOEEQQQxJAyADIAMgAu3N8/m6/YhRx/Ovzfs+OAYQDvF8MyYATAYvjjSBTDABTFG8DKwidhvzFskv8d4vWTX7MZf2sX+oEI3UgI77Xt562m2gUukDQt+LFZYVr6wS/tQvfEQRj96Strvh99WXr4sZkGywebD0TGBFkQ0DfjqIiHgTTJT3zogAbIKIwSNk1/ul+Qr4S7MAxDDYqvrPh6qOXVTH+A5ym+87rNmirPmiAvi6js4pgE8R4xCiBHkKf/22SaDzJJMvpECQ/FGZMvdBrvQyUgIIBmAkXcHM0D8m1MlbhuZICcgByVu8cNsNLFxssT9tBZEXR7j3YCKBmiERgujoFLQ5ARlKwcyUM6QnBhUaEAQZgzJQEn2fX6G1zINqgA06i7A1oXEpU=";
|
||||
const WORKER_ARM_PACKAGE_B64 =
|
||||
"KLUv/WQAJ/0JAMYQOSAQlR5qET7YNofHkpdFP5XGZR5I2104afgPlBQBAADgMi4AMAAuAB2u839wvj2VSIPmPmOa9Nxfth3neQ541CmE0v1JSf1tGw5TiXLHYctnqtveYgNaZ7eeGsapXVBRjMOx2IY1PUkACAgE8B1eOCDaZ1brqWkFK+9BbxucP5M3IOo7rAWxTgj9wphN/ylnlNS6pL0yt65bQbzZV65I6Yz2wvVEUVQou9kj6YWSraVCng0HT1KE4UEgYJMII7HAGIkC9jgoHg0JlF5d4IFXknHQP8I/45XZvG6t9StWfHtZkVKrDCIggGYCrNockQfkx5hqdt34AIkCbmTuHjewShcbAw+sowlEzFIMLdyknUBKQzwFBvESBBqABRBkBSWbI+khHSH4sOhQgCDMj5KAkyx4fW8NyDJoANOougOGR+RC";
|
||||
|
||||
const H2_JAR_BYTES = new TextEncoder().encode("FAKE_JAR_00");
|
||||
const WORKER_X64_BYTES = new TextEncoder().encode("WORKER_BIN_LINUX_X64__");
|
||||
const WORKER_ARM_BYTES = new TextEncoder().encode("WORKER_BIN_LINUX_AARCH64");
|
||||
const JRE_BYTES = new TextEncoder().encode("FAKE_JRE_TAR_ZST_BYTES");
|
||||
const JRE_SHA256 = "6529fca7c62b0e7b0ecf1afe8fe75a924897c30dda8f7e5862677a59bcf68aa0";
|
||||
|
||||
function decodeB64(value: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(value, "base64"));
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes as unknown as ArrayBuffer);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const RELEASE = "https://github.com/t8y2/dbx/releases/download/agents-v9.9.9";
|
||||
|
||||
const h2Package = decodeB64(H2_PACKAGE_B64);
|
||||
const workerX64Package = decodeB64(WORKER_X64_PACKAGE_B64);
|
||||
const workerArmPackage = decodeB64(WORKER_ARM_PACKAGE_B64);
|
||||
|
||||
const testRegistry: AgentRegistry = {
|
||||
jres: {
|
||||
"21": {
|
||||
version: "21",
|
||||
platforms: {
|
||||
"windows-x64": { url: `${RELEASE}/dbx-jre-21-windows-x64.tar.zst`, sha256: JRE_SHA256, size: JRE_BYTES.length, format: "tar_zstd" },
|
||||
"macos-aarch64": { url: `${RELEASE}/dbx-jre-21-macos-aarch64.tar.zst`, sha256: "0".repeat(64), size: 5, format: "tar_zstd" },
|
||||
},
|
||||
},
|
||||
},
|
||||
drivers: {
|
||||
h2: {
|
||||
version: "1.0.0",
|
||||
label: "H2",
|
||||
min_app_version: "0.6.33",
|
||||
jre: "21",
|
||||
jar: { url: `${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`, sha256: await sha256Hex(h2Package), size: h2Package.length, format: "tar_zstd" },
|
||||
},
|
||||
duckdb: {
|
||||
version: "0.1.29",
|
||||
label: "DuckDB",
|
||||
min_app_version: "0.6.33",
|
||||
jre: "21",
|
||||
native: {
|
||||
"windows-x64": { url: `${RELEASE}/dbx-agent-duckdb-0.1.29-windows-x64.tar.zst`, sha256: "1".repeat(64), size: 100, format: "tar_zstd" },
|
||||
"macos-aarch64": { url: `${RELEASE}/dbx-agent-duckdb-0.1.29-macos-aarch64.tar.zst`, sha256: "2".repeat(64), size: 90, format: "tar_zstd" },
|
||||
},
|
||||
},
|
||||
"sqlite-worker": {
|
||||
version: "0.1.6",
|
||||
label: "SQLite SSH Worker",
|
||||
min_app_version: "0.6.30",
|
||||
jre: "21",
|
||||
native: {
|
||||
"linux-x64": { url: `${RELEASE}/dbx-agent-sqlite-worker-0.1.6-linux-x64.tar.zst`, sha256: await sha256Hex(workerX64Package), size: workerX64Package.length, format: "tar_zstd" },
|
||||
"linux-aarch64": { url: `${RELEASE}/dbx-agent-sqlite-worker-0.1.6-linux-aarch64.tar.zst`, sha256: await sha256Hex(workerArmPackage), size: workerArmPackage.length, format: "tar_zstd" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("crc32 matches the canonical check value", () => {
|
||||
assert.equal(crc32(new TextEncoder().encode("123456789")), 0xcbf43926);
|
||||
});
|
||||
|
||||
function tarEntry(name: string, data: Uint8Array): Uint8Array {
|
||||
const header = new Uint8Array(512);
|
||||
header.set(new TextEncoder().encode(name), 0);
|
||||
header.set(new TextEncoder().encode(data.length.toString(8).padStart(11, "0") + "\0"), 124);
|
||||
header[156] = 48;
|
||||
const padded = new Uint8Array(Math.ceil(data.length / 512) * 512);
|
||||
padded.set(data, 0);
|
||||
const entry = new Uint8Array(512 + padded.length);
|
||||
entry.set(header, 0);
|
||||
entry.set(padded, 512);
|
||||
return entry;
|
||||
}
|
||||
|
||||
test("tar entries round-trip through the parser", () => {
|
||||
const registry = new TextEncoder().encode('{"drivers":{}}');
|
||||
const bin = new Uint8Array(600).fill(7);
|
||||
const parts = [tarEntry("agent-registry.json", registry), tarEntry("drivers/agent.bin", bin), new Uint8Array(1024)];
|
||||
const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
bytes.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
|
||||
const entries = parseTarEntries(bytes);
|
||||
assert.deepEqual([...entries.keys()], ["agent-registry.json", "drivers/agent.bin"]);
|
||||
assert.equal(new TextDecoder().decode(entries.get("agent-registry.json")!), '{"drivers":{}}');
|
||||
const parsedBin = entries.get("drivers/agent.bin")!;
|
||||
assert.equal(parsedBin.length, 600);
|
||||
assert.ok(parsedBin.every((byte) => byte === 7));
|
||||
});
|
||||
|
||||
interface ParsedZipEntry {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
method: number;
|
||||
crc: number;
|
||||
}
|
||||
|
||||
function readStoredZip(bytes: Uint8Array): ParsedZipEntry[] {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const entries: ParsedZipEntry[] = [];
|
||||
const expectedOffsets: number[] = [];
|
||||
let offset = 0;
|
||||
while (view.getUint32(offset, true) === 0x04034b50) {
|
||||
const method = view.getUint16(offset + 8, true);
|
||||
const crc = view.getUint32(offset + 14, true);
|
||||
const size = view.getUint32(offset + 22, true);
|
||||
const nameLength = view.getUint16(offset + 26, true);
|
||||
const extraLength = view.getUint16(offset + 28, true);
|
||||
const name = new TextDecoder().decode(bytes.subarray(offset + 30, offset + 30 + nameLength));
|
||||
const dataStart = offset + 30 + nameLength + extraLength;
|
||||
entries.push({ name, data: bytes.subarray(dataStart, dataStart + size), method, crc });
|
||||
expectedOffsets.push(offset);
|
||||
offset = dataStart + size;
|
||||
}
|
||||
const dataEnd = offset;
|
||||
// The central directory follows the file data; every record must mirror the
|
||||
// local header it describes and the EOCD counts must match.
|
||||
let centralCount = 0;
|
||||
while (view.getUint32(offset, true) === 0x02014b50) {
|
||||
const nameLength = view.getUint16(offset + 28, true);
|
||||
const extraLength = view.getUint16(offset + 30, true);
|
||||
const commentLength = view.getUint16(offset + 32, true);
|
||||
const localOffset = view.getUint32(offset + 42, true);
|
||||
assert.equal(view.getUint16(offset + 10, true), 0, "central directory entry must be STORED");
|
||||
const entry = entries[centralCount];
|
||||
assert.ok(entry, `central directory has more entries than local headers: #${centralCount}`);
|
||||
assert.equal(new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength)), entry.name);
|
||||
assert.equal(view.getUint32(offset + 16, true), entry.crc);
|
||||
assert.equal(view.getUint32(offset + 20, true), entry.data.length);
|
||||
assert.equal(localOffset, expectedOffsets[centralCount]);
|
||||
centralCount += 1;
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
const centralEnd = offset;
|
||||
assert.equal(view.getUint32(offset, true), 0x06054b50);
|
||||
assert.equal(view.getUint16(offset + 8, true), entries.length);
|
||||
assert.equal(view.getUint16(offset + 10, true), centralCount);
|
||||
assert.equal(view.getUint32(offset + 12, true), centralEnd - dataEnd);
|
||||
assert.equal(view.getUint32(offset + 16, true), dataEnd);
|
||||
assert.equal(bytes.length, offset + 22);
|
||||
return entries;
|
||||
}
|
||||
|
||||
test("stored zip parts assemble a valid zip32 archive", async () => {
|
||||
const first = new TextEncoder().encode("agent-registry.json content");
|
||||
const second = new Uint8Array(1000).fill(9);
|
||||
const result = buildStoredZipParts([
|
||||
{ name: "agent-registry.json", data: first },
|
||||
{ name: "drivers/dbx-agent.bin", data: second },
|
||||
], new Date(2026, 9, 4, 12, 30, 22));
|
||||
|
||||
assert.equal(result.totalSize, (30 + 19) + 27 + (30 + 21) + 1000 + (46 + 19) + (46 + 21) + 22);
|
||||
const blob = new Blob(result.parts);
|
||||
const entries = readStoredZip(new Uint8Array(await blob.arrayBuffer()));
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
["agent-registry.json", "drivers/dbx-agent.bin"],
|
||||
);
|
||||
assert.ok(entries.every((entry) => entry.method === 0));
|
||||
assert.deepEqual([...entries[0]!.data], [...first]);
|
||||
assert.ok(entries[1]!.data.every((byte) => byte === 9));
|
||||
assert.equal(entries[0]!.crc, crc32(first));
|
||||
|
||||
assert.throws(() => buildStoredZipParts([]), /at least one file/);
|
||||
assert.throws(() => buildStoredZipParts([{ name: "é.bin", data: first }]), /ASCII/);
|
||||
});
|
||||
|
||||
test("driver packages unpack their raw artifact and offline registry entry", () => {
|
||||
const extracted = extractDriverPackage(h2Package, "h2");
|
||||
assert.equal(extracted.artifactKind, "jar");
|
||||
assert.equal(extracted.filename, "dbx-agent-h2-1.0.0.jar");
|
||||
assert.deepEqual([...extracted.bytes], [...H2_JAR_BYTES]);
|
||||
assert.equal(extracted.entry.label, "H2");
|
||||
assert.equal(extracted.entry.min_app_version, "0.6.33");
|
||||
assert.equal(extracted.entry.jar?.url, "offline://dbx-agent-h2-1.0.0.jar");
|
||||
assert.equal(extracted.entry.jar?.size, H2_JAR_BYTES.length);
|
||||
assert.ok(extracted.entry.jar?.sha256);
|
||||
|
||||
const worker = extractDriverPackage(workerX64Package, "sqlite-worker");
|
||||
assert.equal(worker.artifactKind, "native");
|
||||
assert.deepEqual(worker.nativePlatforms, ["linux-x64"]);
|
||||
assert.equal(worker.filename, "dbx-agent-sqlite-worker-0.1.6-linux-x64");
|
||||
assert.deepEqual([...worker.bytes], [...WORKER_X64_BYTES]);
|
||||
assert.equal(worker.entry.native?.["linux-x64"]?.url, "offline://dbx-agent-sqlite-worker-0.1.6-linux-x64");
|
||||
|
||||
assert.throws(() => extractDriverPackage(h2Package, "duckdb"), /exactly the "duckdb"/);
|
||||
});
|
||||
|
||||
test("bundle options follow native-over-jar and platform availability", () => {
|
||||
const windows = buildCustomBundleOptions(testRegistry, "windows-x64");
|
||||
assert.deepEqual(windows.map((option) => option.key), ["duckdb", "h2"]);
|
||||
assert.equal(windows.find((option) => option.key === "duckdb")?.kind, "native");
|
||||
assert.equal(windows.find((option) => option.key === "h2")?.kind, "jar");
|
||||
assert.equal(windows.find((option) => option.key === "h2")?.requiresJre, true);
|
||||
assert.equal(windows.find((option) => option.key === "duckdb")?.requiresJre, false);
|
||||
|
||||
const workers = buildSqliteWorkerOptions(testRegistry, "windows-x64");
|
||||
assert.equal(workers.length, 2);
|
||||
assert.deepEqual(
|
||||
workers.map((worker) => worker.packageUrl.split("/").pop()),
|
||||
["dbx-agent-sqlite-worker-0.1.6-linux-x64.tar.zst", "dbx-agent-sqlite-worker-0.1.6-linux-aarch64.tar.zst"],
|
||||
);
|
||||
|
||||
const jre = resolveBundleJre(testRegistry, "windows-x64");
|
||||
assert.equal(jre?.filename, "dbx-jre-21-windows-x64.tar.zst");
|
||||
assert.equal(jre?.sha256, JRE_SHA256);
|
||||
});
|
||||
|
||||
test("bundle plans force the JRE for Java agents and always bundle SSH workers", () => {
|
||||
const jarPlan = computeBundlePlan(testRegistry, "windows-x64", new Set(["h2"]), false);
|
||||
assert.equal(jarPlan.requiresJre, true);
|
||||
assert.equal(jarPlan.includeJre, true);
|
||||
assert.ok(jarPlan.jre);
|
||||
assert.deepEqual(
|
||||
jarPlan.drivers.map((driver) => driver.key),
|
||||
["h2"],
|
||||
);
|
||||
assert.equal(jarPlan.workers.length, 2);
|
||||
assert.equal(jarPlan.totalDownloadBytes, h2Package.length + workerX64Package.length + workerArmPackage.length + JRE_BYTES.length);
|
||||
assert.equal(jarPlan.filename, "dbx-agents-offline-custom-windows-x64.zip");
|
||||
|
||||
const nativePlan = computeBundlePlan(testRegistry, "windows-x64", new Set(["duckdb"]), true);
|
||||
assert.equal(nativePlan.requiresJre, false);
|
||||
assert.equal(nativePlan.includeJre, true);
|
||||
assert.ok(nativePlan.jre);
|
||||
|
||||
const nativeOnly = computeBundlePlan(testRegistry, "windows-x64", new Set(["duckdb"]), false);
|
||||
assert.equal(nativeOnly.includeJre, false);
|
||||
assert.equal(nativeOnly.jre, null);
|
||||
assert.equal(nativeOnly.totalDownloadBytes, 100 + workerX64Package.length + workerArmPackage.length);
|
||||
});
|
||||
|
||||
test("assemble builds a driver-manager compatible offline ZIP end to end", async () => {
|
||||
const plan = computeBundlePlan(testRegistry, "windows-x64", new Set(["h2"]), false);
|
||||
const fetched: string[] = [];
|
||||
const fetchPackage: BundleFetch = async (url) => {
|
||||
fetched.push(url);
|
||||
if (url.endsWith("dbx-agent-h2-1.0.0.tar.zst")) return h2Package;
|
||||
if (url.endsWith("dbx-agent-sqlite-worker-0.1.6-linux-x64.tar.zst")) return workerX64Package;
|
||||
if (url.endsWith("dbx-agent-sqlite-worker-0.1.6-linux-aarch64.tar.zst")) return workerArmPackage;
|
||||
if (url.endsWith("dbx-jre-21-windows-x64.tar.zst")) return JRE_BYTES;
|
||||
throw new Error(`unexpected url ${url}`);
|
||||
};
|
||||
const progress: BundleProgress[] = [];
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const assembled = await assembleCustomBundle(plan, fetchPackage, (event) => progress.push(event), signal);
|
||||
|
||||
assert.equal(assembled.filename, "dbx-agents-offline-custom-windows-x64.zip");
|
||||
assert.ok(progress.some((event) => event.phase === "download" && event.label === "H2"));
|
||||
assert.ok(progress.some((event) => event.phase === "unpack"));
|
||||
assert.ok(progress.some((event) => event.phase === "assemble"));
|
||||
assert.ok(progress.some((event) => event.phase === "done"));
|
||||
|
||||
const bytes = new Uint8Array(await assembled.blob.arrayBuffer());
|
||||
const entries = readStoredZip(bytes);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.name),
|
||||
["agent-registry.json", "drivers/dbx-agent-h2-1.0.0.jar", "drivers/dbx-agent-sqlite-worker-0.1.6-linux-x64", "drivers/dbx-agent-sqlite-worker-0.1.6-linux-aarch64", "jre/dbx-jre-21-windows-x64.tar.zst"],
|
||||
);
|
||||
assert.deepEqual([...entries[1]!.data], [...H2_JAR_BYTES]);
|
||||
assert.deepEqual([...entries[2]!.data], [...WORKER_X64_BYTES]);
|
||||
assert.deepEqual([...entries[4]!.data], [...JRE_BYTES]);
|
||||
|
||||
const registry = JSON.parse(new TextDecoder().decode(entries[0]!.data)) as {
|
||||
jres: Record<string, { version: string; platforms: Record<string, { url: string; sha256?: string; size: number; format: string }> }>;
|
||||
drivers: Record<string, Record<string, unknown> & { jar?: { url: string; size: number }; native?: Record<string, { url: string }> }>;
|
||||
};
|
||||
assert.deepEqual(Object.keys(registry.jres), ["21"]);
|
||||
const jreEntry = registry.jres["21"]!.platforms["windows-x64"]!;
|
||||
assert.equal(jreEntry.url, "offline://dbx-jre-21-windows-x64.tar.zst");
|
||||
assert.equal(jreEntry.sha256, JRE_SHA256);
|
||||
assert.equal(jreEntry.size, JRE_BYTES.length);
|
||||
assert.equal(jreEntry.format, "tar_zstd");
|
||||
assert.equal(registry.drivers.h2!.jar!.url, "offline://dbx-agent-h2-1.0.0.jar");
|
||||
assert.equal(registry.drivers.h2!.min_app_version, "0.6.33");
|
||||
assert.equal(registry.drivers.h2!.label, "H2");
|
||||
assert.deepEqual(Object.keys(registry.drivers["sqlite-worker"]!.native!), ["linux-x64", "linux-aarch64"]);
|
||||
assert.equal(fetched.length, 4);
|
||||
});
|
||||
|
||||
test("assembly rejects packages whose checksum does not match the registry", async () => {
|
||||
const corrupted: AgentRegistry = JSON.parse(JSON.stringify(testRegistry));
|
||||
corrupted.drivers!.h2!.jar!.sha256 = "f".repeat(64);
|
||||
const plan = computeBundlePlan(corrupted, "windows-x64", new Set(["h2"]), false);
|
||||
const fetchPackage: BundleFetch = async (url) => (url.endsWith("h2-1.0.0.tar.zst") ? h2Package : url.endsWith("linux-x64.tar.zst") ? workerX64Package : url.endsWith("linux-aarch64.tar.zst") ? workerArmPackage : JRE_BYTES);
|
||||
await assert.rejects(
|
||||
assembleCustomBundle(plan, fetchPackage, () => {}, new AbortController().signal),
|
||||
/checksum mismatch/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("mirror and proxy URLs map GitHub assets to their alternates", () => {
|
||||
assert.equal(cnbMirrorUrl(`${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`), `https://cnb.cool/dbxio.com/dbx/-/releases/download/agents-v9.9.9/dbx-agent-h2-1.0.0.tar.zst`);
|
||||
assert.equal(
|
||||
proxyUrl("https://dbxio.com", `${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`),
|
||||
`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(`${RELEASE}/dbx-agent-h2-1.0.0.tar.zst`)}`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,534 @@
|
||||
import { decompress } from "fzstd";
|
||||
import { labelForDriver, platformLabels, type AgentRegistry, type AgentRegistryArtifact, type AgentRegistryDriver } from "./agentRegistry";
|
||||
|
||||
/**
|
||||
* Custom offline bundle builder.
|
||||
*
|
||||
* The website assembles a driver-manager-compatible ZIP (`agent-registry.json`
|
||||
* + `drivers/` + `jre/`) entirely in the browser:
|
||||
*
|
||||
* 1. The agent registry exposes every driver as a `.tar.zst` *package* that
|
||||
* wraps the raw artifact (a Java agent JAR or a native executable) plus a
|
||||
* single-driver `agent-registry.json` with the raw artifact's authoritative
|
||||
* SHA-256 and size.
|
||||
* 2. Each selected package is downloaded, decompressed, and unpacked in the
|
||||
* browser; the raw artifact is stored verbatim while the package registry
|
||||
* provides the metadata for the synthesized bundle registry.
|
||||
* 3. The bundle is a STORED (uncompressed) ZIP — the desktop offline import
|
||||
* (`collect_offline_entries`) matches entries by filename against
|
||||
* `offline://<filename>` registry URLs and validates size/SHA-256, so raw
|
||||
* artifacts must be embedded byte-for-byte.
|
||||
*
|
||||
* The SQLite SSH worker binaries execute on the remote SSH host, so the Linux
|
||||
* packages must ship in every bundle regardless of the chosen platform (#8987).
|
||||
*/
|
||||
|
||||
export const SQLITE_WORKER_DRIVER_KEY = "sqlite-worker";
|
||||
export const SQLITE_WORKER_NATIVE_PLATFORMS = ["linux-x64", "linux-aarch64"] as const;
|
||||
|
||||
export const CUSTOM_BUNDLE_PLATFORMS = ["macos-aarch64", "macos-x64", "linux-x64", "linux-aarch64", "windows-x64", "windows-aarch64"] as const;
|
||||
export type CustomBundlePlatform = (typeof CUSTOM_BUNDLE_PLATFORMS)[number];
|
||||
|
||||
const WINDOWS_NATIVE_SUFFIX = ".exe";
|
||||
const PACKAGE_SUFFIX = ".tar.zst";
|
||||
/** Hard stop before the zip32 4 GiB ceiling plus header overhead. */
|
||||
const MAX_BUNDLE_BYTES = 3.5 * 1024 * 1024 * 1024;
|
||||
|
||||
export interface CustomBundleDriverOption {
|
||||
key: string;
|
||||
label: string;
|
||||
version: string;
|
||||
minAppVersion: string;
|
||||
kind: "jar" | "native";
|
||||
requiresJre: boolean;
|
||||
packageUrl: string;
|
||||
packageSha256?: string;
|
||||
packageSize: number;
|
||||
platformLabel: string;
|
||||
}
|
||||
|
||||
export interface CustomBundleJre {
|
||||
key: string;
|
||||
version: string;
|
||||
platformKey: string;
|
||||
url: string;
|
||||
sha256?: string;
|
||||
size: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string): string {
|
||||
const bare = url.split("offline://").pop() ?? url;
|
||||
return bare.substring(bare.lastIndexOf("/") + 1);
|
||||
}
|
||||
|
||||
function artifactForPlatform(driver: AgentRegistryDriver, platform: string): { kind: "jar" | "native"; artifact: AgentRegistryArtifact } | null {
|
||||
const native = driver.native?.[platform];
|
||||
if (native?.url && native.size > 0) return { kind: "native", artifact: native };
|
||||
if (driver.jar?.url && driver.jar.size > 0) return { kind: "jar", artifact: driver.jar };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Drivers available for a platform, excluding the auto-included SQLite worker. */
|
||||
export function buildCustomBundleOptions(registry: AgentRegistry, platform: string): CustomBundleDriverOption[] {
|
||||
return Object.entries(registry.drivers ?? {})
|
||||
.filter(([key]) => key !== SQLITE_WORKER_DRIVER_KEY)
|
||||
.flatMap(([key, driver]) => {
|
||||
const match = artifactForPlatform(driver, platform);
|
||||
if (!match) return [];
|
||||
return [
|
||||
{
|
||||
key,
|
||||
label: driver.label ?? labelForDriver(key),
|
||||
version: driver.version ?? "",
|
||||
minAppVersion: driver.min_app_version ?? "",
|
||||
kind: match.kind,
|
||||
requiresJre: match.kind === "jar" && !!(driver.jre && driver.jre.trim() !== ""),
|
||||
packageUrl: match.artifact.url,
|
||||
packageSha256: match.artifact.sha256,
|
||||
packageSize: match.artifact.size,
|
||||
platformLabel: platformLabels[platform] ?? platform,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/** The SQLite SSH worker packages that every custom bundle must include. */
|
||||
export function buildSqliteWorkerOptions(registry: AgentRegistry, platform: string): CustomBundleDriverOption[] {
|
||||
const driver = registry.drivers?.[SQLITE_WORKER_DRIVER_KEY];
|
||||
if (!driver) return [];
|
||||
const options: CustomBundleDriverOption[] = [];
|
||||
for (const workerPlatform of SQLITE_WORKER_NATIVE_PLATFORMS) {
|
||||
const artifact = driver.native?.[workerPlatform];
|
||||
if (!artifact?.url || artifact.size <= 0) continue;
|
||||
options.push({
|
||||
key: SQLITE_WORKER_DRIVER_KEY,
|
||||
label: `${driver.label ?? "SQLite SSH Worker"} (${platformLabels[workerPlatform] ?? workerPlatform})`,
|
||||
version: driver.version ?? "",
|
||||
minAppVersion: driver.min_app_version ?? "",
|
||||
kind: "native",
|
||||
requiresJre: false,
|
||||
packageUrl: artifact.url,
|
||||
packageSha256: artifact.sha256,
|
||||
packageSize: artifact.size,
|
||||
platformLabel: platformLabels[workerPlatform] ?? workerPlatform,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function resolveBundleJre(registry: AgentRegistry, platform: string): CustomBundleJre | null {
|
||||
for (const [key, jre] of Object.entries(registry.jres ?? {})) {
|
||||
const artifact = jre.platforms?.[platform];
|
||||
if (!artifact?.url || artifact.size <= 0) continue;
|
||||
return {
|
||||
key,
|
||||
version: jre.version ?? key,
|
||||
platformKey: platform,
|
||||
url: artifact.url,
|
||||
sha256: artifact.sha256,
|
||||
size: artifact.size,
|
||||
filename: filenameFromUrl(artifact.url),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BundlePlan {
|
||||
platform: string;
|
||||
drivers: CustomBundleDriverOption[];
|
||||
workers: CustomBundleDriverOption[];
|
||||
jre: CustomBundleJre | null;
|
||||
requiresJre: boolean;
|
||||
includeJre: boolean;
|
||||
totalDownloadBytes: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function computeBundlePlan(registry: AgentRegistry, platform: string, selectedKeys: ReadonlySet<string>, includeJre: boolean): BundlePlan {
|
||||
const options = buildCustomBundleOptions(registry, platform);
|
||||
const drivers = options.filter((option) => selectedKeys.has(option.key));
|
||||
const workers = buildSqliteWorkerOptions(registry, platform);
|
||||
const jre = resolveBundleJre(registry, platform);
|
||||
const requiresJre = drivers.some((option) => option.requiresJre);
|
||||
const effectiveIncludeJre = includeJre || requiresJre;
|
||||
const items = [...drivers, ...workers];
|
||||
const totalDownloadBytes = items.reduce((sum, item) => sum + item.packageSize, 0) + (effectiveIncludeJre && jre ? jre.size : 0);
|
||||
return {
|
||||
platform,
|
||||
drivers,
|
||||
workers,
|
||||
jre: effectiveIncludeJre ? jre : null,
|
||||
requiresJre,
|
||||
includeJre: effectiveIncludeJre,
|
||||
totalDownloadBytes,
|
||||
filename: `dbx-agents-offline-custom-${platform}.zip`,
|
||||
};
|
||||
}
|
||||
|
||||
interface PackageDriverEntry {
|
||||
version: string;
|
||||
label?: string;
|
||||
min_app_version: string;
|
||||
jre?: string;
|
||||
jar?: { url: string; sha256?: string; size: number };
|
||||
native?: Record<string, { url: string; sha256?: string; size: number }>;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExtractedDriverPackage {
|
||||
driverKey: string;
|
||||
entry: PackageDriverEntry;
|
||||
artifactKind: "jar" | "native";
|
||||
nativePlatforms: string[];
|
||||
filename: string;
|
||||
bytes: Uint8Array;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
function octal(bytes: Uint8Array): number {
|
||||
let value = 0;
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0 || byte === 32) break;
|
||||
if (byte < 48 || byte > 55) throw new Error("Invalid tar size field");
|
||||
value = value * 8 + (byte - 48);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Minimal ustar tar reader for driver packages (regular files only). */
|
||||
export function parseTarEntries(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
const entries = new Map<string, Uint8Array>();
|
||||
let offset = 0;
|
||||
let longName: string | null = null;
|
||||
while (offset + 512 <= bytes.length) {
|
||||
const header = bytes.subarray(offset, offset + 512);
|
||||
if (header.every((byte) => byte === 0)) break;
|
||||
const rawName = String.fromCharCode(...header.subarray(0, 100)).replace(/\0.*$/, "");
|
||||
const size = octal(header.subarray(124, 136));
|
||||
const type = String.fromCharCode(header[156] || 48);
|
||||
const dataStart = offset + 512;
|
||||
const dataEnd = dataStart + size;
|
||||
if (dataEnd > bytes.length) throw new Error(`Truncated tar entry: ${rawName}`);
|
||||
const data = bytes.subarray(dataStart, dataEnd);
|
||||
if (type === "L") {
|
||||
longName = new TextDecoder().decode(data.subarray(0, data.indexOf(0) === -1 ? data.length : data.indexOf(0)));
|
||||
} else if (type === "0" || type === "\0") {
|
||||
let name = longName ?? rawName;
|
||||
longName = null;
|
||||
const magic = String.fromCharCode(...header.subarray(257, 262));
|
||||
if (magic === "ustar") {
|
||||
const prefix = String.fromCharCode(...header.subarray(345, 500)).replace(/\0.*$/, "");
|
||||
if (prefix) name = `${prefix}/${name}`;
|
||||
}
|
||||
if (name) entries.set(name, data);
|
||||
} else if (type !== "5" && type !== "x" && type !== "g") {
|
||||
throw new Error(`Unsupported tar entry type "${type}": ${rawName}`);
|
||||
}
|
||||
offset = dataStart + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks a downloaded `.tar.zst` driver package and returns the raw artifact
|
||||
* plus its package registry entry with the artifact URL rewritten to the
|
||||
* `offline://` form the desktop offline import matches on.
|
||||
*/
|
||||
export function extractDriverPackage(packageBytes: Uint8Array, expectedKey: string): ExtractedDriverPackage {
|
||||
const tarEntries = parseTarEntries(decompress(packageBytes));
|
||||
const registryRaw = tarEntries.get("agent-registry.json");
|
||||
if (!registryRaw) throw new Error("Driver package is missing agent-registry.json");
|
||||
const packageRegistry = JSON.parse(new TextDecoder().decode(registryRaw)) as { drivers?: Record<string, PackageDriverEntry> };
|
||||
const drivers = packageRegistry.drivers ?? {};
|
||||
if (Object.keys(drivers).length !== 1 || !(expectedKey in drivers)) {
|
||||
throw new Error(`Driver package registry does not describe exactly the "${expectedKey}" driver`);
|
||||
}
|
||||
const entry = drivers[expectedKey];
|
||||
const rewrite = (artifact: { url: string; sha256?: string; size: number }) => {
|
||||
const filename = filenameFromUrl(artifact.url);
|
||||
if (!filename) throw new Error(`Driver package artifact has no filename: ${artifact.url}`);
|
||||
return { url: `offline://${filename}`, ...(artifact.sha256 ? { sha256: artifact.sha256 } : {}), size: artifact.size };
|
||||
};
|
||||
|
||||
if (entry.jar && !entry.native) {
|
||||
const filename = filenameFromUrl(entry.jar.url);
|
||||
const bytes = tarEntries.get(`drivers/${filename}`);
|
||||
if (!bytes) throw new Error(`Driver package is missing drivers/${filename}`);
|
||||
if (entry.jar.size > 0 && bytes.length !== entry.jar.size) throw new Error(`Driver package artifact size mismatch for ${filename}`);
|
||||
return {
|
||||
driverKey: expectedKey,
|
||||
entry: { ...entry, jar: rewrite(entry.jar) },
|
||||
artifactKind: "jar",
|
||||
nativePlatforms: [],
|
||||
filename,
|
||||
bytes,
|
||||
sha256: entry.jar.sha256,
|
||||
};
|
||||
}
|
||||
if (entry.native && Object.keys(entry.native).length === 1 && !entry.jar) {
|
||||
const [platform, artifact] = Object.entries(entry.native)[0]!;
|
||||
const platformFilename = filenameFromUrl(artifact.url);
|
||||
const platformBytes = tarEntries.get(`drivers/${platformFilename}`);
|
||||
if (!platformBytes) throw new Error(`Driver package is missing drivers/${platformFilename}`);
|
||||
if (artifact.size > 0 && platformBytes.length !== artifact.size) throw new Error(`Driver package artifact size mismatch for ${platformFilename}`);
|
||||
return {
|
||||
driverKey: expectedKey,
|
||||
entry: { ...entry, native: { [platform]: rewrite(artifact) } },
|
||||
artifactKind: "native",
|
||||
nativePlatforms: [platform],
|
||||
filename: platformFilename,
|
||||
bytes: platformBytes,
|
||||
sha256: artifact.sha256,
|
||||
};
|
||||
}
|
||||
throw new Error(`Driver package for "${expectedKey}" must contain exactly one artifact`);
|
||||
}
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
export function crc32(bytes: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
export interface StoredZipEntry {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface StoredZipResult {
|
||||
parts: BlobPart[];
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
function dosDateTime(date: Date): { time: number; date: number } {
|
||||
const year = Math.max(1980, date.getFullYear());
|
||||
return {
|
||||
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >>> 1),
|
||||
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a STORED (uncompressed) zip32 archive as Blob parts. Sizes and CRCs
|
||||
* are known upfront, so entries need no data descriptors and the central
|
||||
* directory is written after all file data.
|
||||
*/
|
||||
export function buildStoredZipParts(entries: StoredZipEntry[], date = new Date()): StoredZipResult {
|
||||
if (entries.length === 0) throw new Error("A custom bundle needs at least one file");
|
||||
if (entries.length > 0xffff) throw new Error("Too many bundle entries for a zip32 archive");
|
||||
const names = entries.map((entry) => new TextEncoder().encode(entry.name));
|
||||
for (const name of names) {
|
||||
if (name.length === 0 || name.length > 0xffff || name.some((byte) => byte > 0x7f)) {
|
||||
throw new Error("Bundle entry names must be non-empty ASCII");
|
||||
}
|
||||
}
|
||||
const { time, date: dosDate } = dosDateTime(date);
|
||||
const parts: BlobPart[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
let centralSize = 0;
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const name = names[index];
|
||||
const crc = crc32(entries[index].data);
|
||||
const size = entries[index].data.length;
|
||||
if (offset + size > 0xffffffff) throw new Error("Bundle exceeds the 4 GiB zip32 limit");
|
||||
|
||||
const local = new Uint8Array(30 + name.length);
|
||||
const localView = new DataView(local.buffer);
|
||||
localView.setUint32(0, 0x04034b50, true);
|
||||
localView.setUint16(4, 20, true);
|
||||
localView.setUint16(6, 0, true);
|
||||
localView.setUint16(8, 0, true);
|
||||
localView.setUint16(10, time, true);
|
||||
localView.setUint16(12, dosDate, true);
|
||||
localView.setUint32(14, crc, true);
|
||||
localView.setUint32(18, size, true);
|
||||
localView.setUint32(22, size, true);
|
||||
localView.setUint16(26, name.length, true);
|
||||
localView.setUint16(28, 0, true);
|
||||
local.set(name, 30);
|
||||
parts.push(local, entries[index].data as unknown as BlobPart);
|
||||
|
||||
const centralEntry = new Uint8Array(46 + name.length);
|
||||
const centralView = new DataView(centralEntry.buffer);
|
||||
centralView.setUint32(0, 0x02014b50, true);
|
||||
centralView.setUint16(4, 20, true);
|
||||
centralView.setUint16(6, 20, true);
|
||||
centralView.setUint16(8, 0, true);
|
||||
centralView.setUint16(10, 0, true);
|
||||
centralView.setUint16(12, time, true);
|
||||
centralView.setUint16(14, dosDate, true);
|
||||
centralView.setUint32(16, crc, true);
|
||||
centralView.setUint32(20, size, true);
|
||||
centralView.setUint32(24, size, true);
|
||||
centralView.setUint16(28, name.length, true);
|
||||
centralView.setUint16(30, 0, true);
|
||||
centralView.setUint16(32, 0, true);
|
||||
centralView.setUint16(34, 0, true);
|
||||
centralView.setUint16(36, 0, true);
|
||||
centralView.setUint32(38, 0, true);
|
||||
centralView.setUint32(42, offset, true);
|
||||
centralEntry.set(name, 46);
|
||||
central.push(centralEntry);
|
||||
centralSize += centralEntry.length;
|
||||
offset += local.length + size;
|
||||
}
|
||||
|
||||
const eocd = new Uint8Array(22);
|
||||
const eocdView = new DataView(eocd.buffer);
|
||||
eocdView.setUint32(0, 0x06054b50, true);
|
||||
eocdView.setUint16(8, entries.length, true);
|
||||
eocdView.setUint16(10, entries.length, true);
|
||||
eocdView.setUint32(12, centralSize, true);
|
||||
eocdView.setUint32(16, offset, true);
|
||||
parts.push(...(central as unknown as BlobPart[]), eocd);
|
||||
return { parts, totalSize: offset + centralSize + 22 };
|
||||
}
|
||||
|
||||
export type BundleFetch = (url: string, onProgress: (received: number) => void, signal: AbortSignal) => Promise<Uint8Array>;
|
||||
|
||||
export type BundleProgress =
|
||||
| { phase: "download"; label: string; index: number; total: number; received: number; expectedBytes: number }
|
||||
| { phase: "unpack"; label: string }
|
||||
| { phase: "assemble" }
|
||||
| { phase: "done"; filename: string; size: number };
|
||||
|
||||
export interface AssembleResult {
|
||||
blob: Blob;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes as unknown as ArrayBuffer);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function mergeDriverEntry(target: Record<string, PackageDriverEntry>, extracted: ExtractedDriverPackage): void {
|
||||
const existing = target[extracted.driverKey];
|
||||
if (!existing) {
|
||||
target[extracted.driverKey] = extracted.entry;
|
||||
return;
|
||||
}
|
||||
// The SQLite worker ships one package per remote platform; merge their
|
||||
// native maps into a single registry entry.
|
||||
if (existing.native && extracted.entry.native) {
|
||||
target[extracted.driverKey] = { ...existing, native: { ...existing.native, ...extracted.entry.native } };
|
||||
return;
|
||||
}
|
||||
throw new Error(`Duplicate driver package for "${extracted.driverKey}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads every planned package, unpacks the raw artifacts, verifies the
|
||||
* package-provided SHA-256 digests, and assembles the final driver-manager
|
||||
* compatible ZIP. `fetchPackage` is injected so tests can stub the network.
|
||||
*/
|
||||
export async function assembleCustomBundle(
|
||||
plan: BundlePlan,
|
||||
fetchPackage: BundleFetch,
|
||||
onProgress: (progress: BundleProgress) => void,
|
||||
signal: AbortSignal,
|
||||
deps: { sha256?: (bytes: Uint8Array) => Promise<string> } = {},
|
||||
): Promise<AssembleResult> {
|
||||
if (plan.drivers.length === 0) throw new Error("Select at least one driver");
|
||||
const hash = deps.sha256 ?? sha256Hex;
|
||||
const items = [...plan.drivers, ...plan.workers];
|
||||
const drivers: Record<string, PackageDriverEntry> = {};
|
||||
const zipEntries: StoredZipEntry[] = [];
|
||||
let rawBytes = 0;
|
||||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
signal.throwIfAborted();
|
||||
onProgress({ phase: "download", label: item.label, index: index + 1, total: items.length + (plan.jre ? 1 : 0), received: 0, expectedBytes: item.packageSize });
|
||||
let packageBytes: Uint8Array;
|
||||
try {
|
||||
packageBytes = await fetchPackage(item.packageUrl, (received) => {
|
||||
onProgress({ phase: "download", label: item.label, index: index + 1, total: items.length + (plan.jre ? 1 : 0), received, expectedBytes: item.packageSize });
|
||||
}, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
throw new Error(`${item.label} — ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (item.packageSha256) {
|
||||
const actual = await hash(packageBytes);
|
||||
if (actual !== item.packageSha256.toLowerCase()) throw new Error(`Package checksum mismatch for ${item.label}`);
|
||||
}
|
||||
onProgress({ phase: "unpack", label: item.label });
|
||||
const extracted = extractDriverPackage(packageBytes, item.key);
|
||||
if (extracted.artifactKind !== item.kind) throw new Error(`Package kind mismatch for ${item.label}: expected ${item.kind}`);
|
||||
if (extracted.sha256) {
|
||||
const actual = await hash(extracted.bytes);
|
||||
if (actual !== extracted.sha256.toLowerCase()) throw new Error(`Artifact checksum mismatch for ${item.label}`);
|
||||
}
|
||||
mergeDriverEntry(drivers, extracted);
|
||||
zipEntries.push({ name: `drivers/${extracted.filename}`, data: extracted.bytes });
|
||||
rawBytes += extracted.bytes.length;
|
||||
}
|
||||
|
||||
let jres: Record<string, { version: string; platforms: Record<string, { url: string; sha256?: string; size: number; format: "tar_zstd" }> }> = {};
|
||||
if (plan.jre) {
|
||||
signal.throwIfAborted();
|
||||
const jre = plan.jre;
|
||||
const index = items.length + 1;
|
||||
const total = index;
|
||||
onProgress({ phase: "download", label: `JRE ${jre.key}`, index, total, received: 0, expectedBytes: jre.size });
|
||||
let jreBytes: Uint8Array;
|
||||
try {
|
||||
jreBytes = await fetchPackage(jre.url, (received) => {
|
||||
onProgress({ phase: "download", label: `JRE ${jre.key}`, index, total, received, expectedBytes: jre.size });
|
||||
}, signal);
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
throw new Error(`JRE ${jre.key} — ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (jre.sha256) {
|
||||
const actual = await hash(jreBytes);
|
||||
if (actual !== jre.sha256.toLowerCase()) throw new Error("JRE package checksum mismatch");
|
||||
}
|
||||
jres = {
|
||||
[jre.key]: {
|
||||
version: jre.version,
|
||||
platforms: {
|
||||
[jre.platformKey]: { url: `offline://${jre.filename}`, ...(jre.sha256 ? { sha256: jre.sha256 } : {}), size: jre.size, format: "tar_zstd" },
|
||||
},
|
||||
},
|
||||
};
|
||||
zipEntries.push({ name: `jre/${jre.filename}`, data: jreBytes });
|
||||
rawBytes += jreBytes.length;
|
||||
}
|
||||
if (rawBytes > MAX_BUNDLE_BYTES) throw new Error("Selection exceeds the maximum custom bundle size (3.5 GB)");
|
||||
|
||||
onProgress({ phase: "assemble" });
|
||||
const registryJson = new TextEncoder().encode(JSON.stringify({ jres, drivers }));
|
||||
const result = buildStoredZipParts([{ name: "agent-registry.json", data: registryJson }, ...zipEntries]);
|
||||
const blob = new Blob(result.parts, { type: "application/zip" });
|
||||
onProgress({ phase: "done", filename: plan.filename, size: blob.size });
|
||||
return { blob, filename: plan.filename };
|
||||
}
|
||||
|
||||
/** CNB mirror URL for a GitHub agents-release asset (CORS-enabled). */
|
||||
export function cnbMirrorUrl(githubUrl: string): string {
|
||||
return githubUrl.replace("https://github.com/t8y2/dbx/releases/download/", "https://cnb.cool/dbxio.com/dbx/-/releases/download/");
|
||||
}
|
||||
|
||||
/** Same-origin worker proxy URL for GitHub agents-release assets (no CORS on GitHub). */
|
||||
export function proxyUrl(origin: string, githubUrl: string): string {
|
||||
return `${origin}/api/agent-asset?url=${encodeURIComponent(githubUrl)}`;
|
||||
}
|
||||
@@ -18,14 +18,16 @@ export interface GitHubReleaseAsset {
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
interface AgentRegistryArtifact {
|
||||
export interface AgentRegistryArtifact {
|
||||
url: string;
|
||||
sha256?: string;
|
||||
size: number;
|
||||
format?: "tar_zstd";
|
||||
}
|
||||
|
||||
interface AgentRegistryDriver {
|
||||
export interface AgentRegistryDriver {
|
||||
version?: string;
|
||||
label?: string;
|
||||
min_app_version?: string;
|
||||
jre?: string;
|
||||
jar?: AgentRegistryArtifact;
|
||||
@@ -33,7 +35,7 @@ interface AgentRegistryDriver {
|
||||
}
|
||||
|
||||
export interface AgentRegistry {
|
||||
jres?: Record<string, { platforms?: Record<string, AgentRegistryArtifact> }>;
|
||||
jres?: Record<string, { version?: string; platforms?: Record<string, AgentRegistryArtifact> }>;
|
||||
drivers?: Record<string, AgentRegistryDriver>;
|
||||
}
|
||||
|
||||
@@ -92,7 +94,7 @@ const CNB_RELEASE_DOWNLOAD_PREFIX = "https://cnb.cool/dbxio.com/dbx/-/releases/d
|
||||
const MIN_APP_VERSION = "0.6.0";
|
||||
const driverVersionMap = driverVersions as Record<string, string>;
|
||||
|
||||
const platformLabels: Record<string, string> = {
|
||||
export const platformLabels: Record<string, string> = {
|
||||
"macos-aarch64": "macOS (Apple Silicon)",
|
||||
"macos-x64": "macOS (Intel)",
|
||||
"linux-aarch64": "Linux (ARM64)",
|
||||
@@ -158,7 +160,7 @@ const jreVersions: Record<string, string> = {
|
||||
"21": "21",
|
||||
};
|
||||
|
||||
function labelForDriver(key: string): string {
|
||||
export function labelForDriver(key: string): string {
|
||||
return driverLabels[key] ?? key.replace(/-/g, " ");
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,16 @@ async function fetchRegistry(url: string): Promise<AgentRegistry> {
|
||||
}
|
||||
|
||||
export async function fetchAgentDownloadCatalog(): Promise<AgentDownloadCatalog | null> {
|
||||
const result = await fetchAgentDownloadCatalogWithRegistry();
|
||||
return result?.catalog ?? null;
|
||||
}
|
||||
|
||||
export async function fetchAgentDownloadCatalogWithRegistry(): Promise<{ catalog: AgentDownloadCatalog; registry: AgentRegistry } | null> {
|
||||
for (const url of AGENT_REGISTRY_URLS) {
|
||||
try {
|
||||
const catalog = buildAgentDownloadCatalogFromRegistry(await fetchRegistry(url));
|
||||
if (hasDownloadAssets(catalog)) return catalog;
|
||||
const registry = await fetchRegistry(url);
|
||||
const catalog = buildAgentDownloadCatalogFromRegistry(registry);
|
||||
if (hasDownloadAssets(catalog)) return { catalog, registry };
|
||||
} catch {
|
||||
// Both sources contain the same generated registry; try the synchronized mirror next.
|
||||
}
|
||||
|
||||
+34
-3
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { afterEach, test, vi } from "vitest";
|
||||
import worker, { issueRedirectPath, pluginDetailShellRequest, sanitizeReturnTo, signPayload, staticAssetCacheControl, verifySignedPayload } from "../worker";
|
||||
|
||||
test("native installers bypass HTML routing and are served as plain text", async () => {
|
||||
@@ -66,8 +66,7 @@ test("static assets receive browser cache headers without caching HTML", () => {
|
||||
assert.equal(staticAssetCacheControl("/cn/changelog.txt"), null);
|
||||
});
|
||||
|
||||
test("plugin detail fallback maps unknown ids onto the shell route", () => {
|
||||
const shell = pluginDetailShellRequest(new URL("https://dbxio.com/cn/plugins/io.github.t8y2.s3"), new Request("https://dbxio.com/cn/plugins/io.github.t8y2.s3"));
|
||||
test("plugin detail fallback maps unknown ids onto the shell route", () => { const shell = pluginDetailShellRequest(new URL("https://dbxio.com/cn/plugins/io.github.t8y2.s3"), new Request("https://dbxio.com/cn/plugins/io.github.t8y2.s3"));
|
||||
assert.equal(shell?.url, "https://dbxio.com/cn/plugins/detail?id=io.github.t8y2.s3");
|
||||
|
||||
const encoded = pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins/a%20b"), new Request("https://dbxio.com/en/plugins/a%20b"));
|
||||
@@ -81,3 +80,35 @@ test("plugin detail fallback maps unknown ids onto the shell route", () => {
|
||||
);
|
||||
assert.equal(pluginDetailShellRequest(new URL("https://dbxio.com/en/plugins"), new Request("https://dbxio.com/en/plugins")), null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("agent asset proxy only streams allowlisted agents-release assets", async () => {
|
||||
const env = {};
|
||||
const assetUrl = "https://github.com/t8y2/dbx/releases/download/agents-v0.2.130/dbx-agent-dameng-0.1.85.tar.zst";
|
||||
const upstream = new Response("package-bytes", { headers: { "Content-Length": "13" } });
|
||||
const fetchMock = vi.fn(async () => upstream);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const proxied = await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(assetUrl)}`), env);
|
||||
assert.equal(proxied.status, 200);
|
||||
assert.equal(proxied.headers.get("Content-Type"), "application/octet-stream");
|
||||
assert.equal(proxied.headers.get("X-Content-Type-Options"), "nosniff");
|
||||
assert.equal(await proxied.text(), "package-bytes");
|
||||
assert.equal(fetchMock.mock.calls[0]?.[0], assetUrl);
|
||||
|
||||
assert.equal((await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(assetUrl)}`, { method: "HEAD" }), env)).status, 200);
|
||||
assert.equal((await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(assetUrl)}`, { method: "POST" }), env)).status, 404);
|
||||
for (const invalid of [
|
||||
"https://evil.example/dbx-agent-dameng-0.1.85.tar.zst",
|
||||
"https://github.com/t8y2/other/releases/download/agents-v0.2.130/dbx-agent-dameng-0.1.85.tar.zst",
|
||||
"https://github.com/t8y2/dbx/releases/download/v0.6.33/DBX_0.6.33_x64-setup.exe",
|
||||
"https://github.com/t8y2/dbx/releases/download/agents-v0.2.130/../../secret",
|
||||
"",
|
||||
]) {
|
||||
const response = await worker.fetch(new Request(`https://dbxio.com/api/agent-asset?url=${encodeURIComponent(invalid)}`), env);
|
||||
assert.equal(response.status, 400, invalid);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"fumadocs-core": "^16.8.7",
|
||||
"fumadocs-mdx": "^14.3.2",
|
||||
"fumadocs-ui": "^16.8.7",
|
||||
"fzstd": "^0.1.1",
|
||||
"lucide-react": "^0.515.0",
|
||||
"marked": "18.0.4",
|
||||
"next": "^16.3.0",
|
||||
|
||||
Generated
+9
-3
@@ -5,9 +5,7 @@ settings:
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
next-themes@0.4.6:
|
||||
hash: bc954c2c679c43ecf5351d4a9d68d9f36a9b8fb2dc4237fdbf57304cae4b0c04
|
||||
path: patches/next-themes@0.4.6.patch
|
||||
next-themes@0.4.6: bc954c2c679c43ecf5351d4a9d68d9f36a9b8fb2dc4237fdbf57304cae4b0c04
|
||||
|
||||
importers:
|
||||
|
||||
@@ -37,6 +35,9 @@ importers:
|
||||
fumadocs-ui:
|
||||
specifier: ^16.8.7
|
||||
version: 16.8.7(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.7(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.515.0(react@19.2.5))(next@16.3.0(@types/node@25.6.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(next@16.3.0(@types/node@25.6.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4)
|
||||
fzstd:
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1
|
||||
lucide-react:
|
||||
specifier: ^0.515.0
|
||||
version: 0.515.0(react@19.2.5)
|
||||
@@ -1312,6 +1313,9 @@ packages:
|
||||
next:
|
||||
optional: true
|
||||
|
||||
fzstd@0.1.1:
|
||||
resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==}
|
||||
|
||||
get-nonce@1.0.1:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3037,6 +3041,8 @@ snapshots:
|
||||
- '@types/react-dom'
|
||||
- tailwindcss
|
||||
|
||||
fzstd@0.1.1: {}
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
github-slugger@2.0.0: {}
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
// /foo/index.html, and unknown paths fall back to 404.html like
|
||||
// not_found_handling: "404-page". Run `pnpm build` before this script.
|
||||
//
|
||||
// It also mirrors the production worker's /api/agent-asset proxy so the
|
||||
// drivers page custom-bundle builder works locally against GitHub sources.
|
||||
//
|
||||
// Usage: node scripts/preview-server.mjs [port]
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "../out");
|
||||
const PORT = Number(process.argv[2] ?? 3000);
|
||||
const AGENT_ASSET_URL_PATTERN = /^https:\/\/github\.com\/t8y2\/dbx\/releases\/download\/(agents-v[0-9][A-Za-z0-9._-]*|agents-latest)\/([A-Za-z0-9][A-Za-z0-9._-]*\.(?:tar\.zst|zip|jar|exe|json))$/;
|
||||
const types = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
@@ -36,9 +41,34 @@ async function tryFile(rel) {
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyAgentAsset(req, res) {
|
||||
const target = new URL(req.url, "http://localhost").searchParams.get("url") ?? "";
|
||||
if (!AGENT_ASSET_URL_PATTERN.test(target)) {
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: "Unsupported asset URL" }));
|
||||
}
|
||||
const upstream = await fetch(target, { redirect: "follow" });
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
res.writeHead(502, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: "Asset unavailable" }));
|
||||
}
|
||||
const headers = {
|
||||
"content-type": "application/octet-stream",
|
||||
"cache-control": "public, max-age=3600",
|
||||
"x-content-type-options": "nosniff",
|
||||
};
|
||||
if (upstream.headers.has("content-length")) headers["content-length"] = upstream.headers.get("content-length");
|
||||
res.writeHead(upstream.status, headers);
|
||||
if (req.method === "HEAD") return res.end();
|
||||
Readable.fromWeb(upstream.body).pipe(res);
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const path = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
||||
if (path === "/api/agent-asset" && (req.method === "GET" || req.method === "HEAD")) {
|
||||
return await proxyAgentAsset(req, res);
|
||||
}
|
||||
if (path.includes("..")) {
|
||||
res.writeHead(400);
|
||||
return res.end();
|
||||
|
||||
@@ -301,6 +301,32 @@ async function contributorAvatar(request: Request): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
const AGENT_ASSET_URL_PATTERN = /^https:\/\/github\.com\/t8y2\/dbx\/releases\/download\/(agents-v[0-9][A-Za-z0-9._-]*|agents-latest)\/([A-Za-z0-9][A-Za-z0-9._-]*\.(?:tar\.zst|zip|jar|exe|json))$/;
|
||||
|
||||
/**
|
||||
* Streams GitHub agents-release assets through the site because GitHub release
|
||||
* downloads send no CORS headers; the browser custom-bundle builder on the
|
||||
* drivers page fetches tar.zst driver packages through this route. CNB stays
|
||||
* the primary source — this is the fallback for networks where CNB is slow.
|
||||
*/
|
||||
async function agentReleaseAsset(request: Request): Promise<Response> {
|
||||
const target = new URL(request.url).searchParams.get("url") ?? "";
|
||||
if (!AGENT_ASSET_URL_PATTERN.test(target)) return json({ error: "Unsupported asset URL" }, 400);
|
||||
|
||||
const response = await fetch(target, { redirect: "follow" });
|
||||
if (!response.ok || !response.body) return json({ error: "Asset unavailable" }, 502);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=3600, s-maxage=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
};
|
||||
const length = response.headers.get("Content-Length");
|
||||
if (length) headers["Content-Length"] = length;
|
||||
return new Response(request.method === "HEAD" ? null : response.body, { headers });
|
||||
}
|
||||
|
||||
function issueSessionCookie(value: string, maxAge: number): string {
|
||||
return `${ISSUE_SESSION_COOKIE}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Strict`;
|
||||
}
|
||||
@@ -658,6 +684,7 @@ export default {
|
||||
if (url.pathname === "/api/auth/me" && request.method === "GET") return currentUser(request, env);
|
||||
if (url.pathname === "/api/auth/logout" && request.method === "POST") return logout();
|
||||
if (url.pathname === "/api/contributor-avatar" && request.method === "GET") return contributorAvatar(request);
|
||||
if (url.pathname === "/api/agent-asset" && (request.method === "GET" || request.method === "HEAD")) return agentReleaseAsset(request);
|
||||
// API paths must never fall through to the cached HTML 404 page: a navigation to an
|
||||
// unmatched /api route would otherwise be edge-cached and shadow this worker.
|
||||
if (url.pathname.startsWith("/api/")) return json({ error: "NOT_FOUND" }, 404);
|
||||
|
||||
Reference in New Issue
Block a user