From 90221ee3ac1c50e42938e946621d124678613291 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Sun, 4 Oct 2026 20:39:37 +0800 Subject: [PATCH] feat(docs): assemble custom offline driver bundles in the browser --- crates/dbx-driver-agent/src/agent_service.rs | 100 ++++ docs/app/[lang]/drivers/DriversClient.tsx | 403 +++++++++++++- docs/app/[lang]/drivers/page.tsx | 8 +- docs/components/landing/LandingSelect.tsx | 153 ++++++ docs/lib/agentBundle.test.ts | 337 ++++++++++++ docs/lib/agentBundle.ts | 534 +++++++++++++++++++ docs/lib/agentRegistry.ts | 12 +- docs/lib/agentRegistrySource.ts | 10 +- docs/lib/worker.test.ts | 37 +- docs/package.json | 1 + docs/pnpm-lock.yaml | 12 +- docs/scripts/preview-server.mjs | 30 ++ docs/worker.ts | 27 + 13 files changed, 1626 insertions(+), 38 deletions(-) create mode 100644 docs/components/landing/LandingSelect.tsx create mode 100644 docs/lib/agentBundle.test.ts create mode 100644 docs/lib/agentBundle.ts diff --git a/crates/dbx-driver-agent/src/agent_service.rs b/crates/dbx-driver-agent/src/agent_service.rs index 2cbb2096be..4301a42971 100644 --- a/crates/dbx-driver-agent/src/agent_service.rs +++ b/crates/dbx-driver-agent/src/agent_service.rs @@ -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)] diff --git a/docs/app/[lang]/drivers/DriversClient.tsx b/docs/app/[lang]/drivers/DriversClient.tsx index 876e780862..7412879feb 100644 --- a/docs/app/[lang]/drivers/DriversClient.tsx +++ b/docs/app/[lang]/drivers/DriversClient.tsx @@ -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, 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>({}); + const [bundlePlatform, setBundlePlatform] = useState("windows-x64"); + const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); + const [includeJre, setIncludeJre] = useState(false); + const [bundleSource, setBundleSource] = useState("cnb"); + const [bundleBusy, setBundleBusy] = useState(false); + const [bundleProgress, setBundleProgress] = useState(null); + const [bundleError, setBundleError] = useState(null); + const [bundleDone, setBundleDone] = useState(null); + const bundleAbortRef = useRef(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 => { + 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 (
@@ -238,6 +430,14 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa {t.bundles} + + )} + + +
+ + + + + + + + + + + {filteredBundleOptions.map((option) => { + const checked = selectedKeys.has(option.key); + return ( + + + + + + + ); + })} + +
{t.customSelectAll}Driver{t.version}{t.size}
+ toggleBundleDriver(option.key)} + aria-label={`${t.customDownload}: ${option.label}`} + className="h-4 w-4 cursor-pointer accent-landing-blue" + /> + +
+ {option.label} + {option.key} + + {option.kind === "native" ? t.customNativeBadge : t.customJavaBadge} + +
+
{option.version}{formatSize(option.packageSize)}
+ {filteredBundleOptions.length === 0 &&
{t.customNoDrivers}
} +
+
+
+
+ {t.customSelected} {selectedKeys.size} · {t.customEstimate} {formatSize(bundlePlan.totalDownloadBytes)} +
+
{t.customWorkersNote}
+
+
+ + + {bundleBusy ? ( + + ) : ( + + )} +
+
+ {bundleProgress && bundleBusy && ( +
{bundleProgressText}
+ )} + {bundleError && ( +
+ {t.customErrorTitle}: + {bundleError} +
{t.customRetryHint}
+
+ )} + {bundleDone !== null && !bundleBusy && ( +
+ {t.customPhaseDone.replace("{size}", formatSize(bundleDone))} +
+ )} + + )} + {activeTab === "drivers" && ( <>

{t.driversDesc}

@@ -417,17 +782,13 @@ export function DriversClient({ initialCatalog }: { initialCatalog: AgentDownloa - + 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" + /> {group.version} {formatSize(selectedAgent.info.size)} diff --git a/docs/app/[lang]/drivers/page.tsx b/docs/app/[lang]/drivers/page.tsx index ccececb12c..96d564cc7f 100644 --- a/docs/app/[lang]/drivers/page.tsx +++ b/docs/app/[lang]/drivers/page.tsx @@ -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 ; + const source = await fetchAgentDownloadCatalogWithRegistry(); + if (!source) throw new Error("Unable to generate the static driver catalog from R2 or CNB"); + return ; } diff --git a/docs/components/landing/LandingSelect.tsx b/docs/components/landing/LandingSelect.tsx new file mode 100644 index 0000000000..1434ca285b --- /dev/null +++ b/docs/components/landing/LandingSelect.tsx @@ -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 { + value: T; + label: string; +} + +/** + * Landing-styled dropdown replacing native