diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml
index e335bbc..0e4b0fd 100644
--- a/.github/workflows/dev-build.yml
+++ b/.github/workflows/dev-build.yml
@@ -434,6 +434,28 @@ jobs:
./cmd/optional-driver-agent
fi
bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}"
+ if [ "$DRIVER" = "duckdb" ] && [ -n "$DUCKDB_LIB_DIR" ]; then
+ DUCKDB_ZIP_PATH="${OUTDIR}/duckdb-driver.zip" \
+ DUCKDB_AGENT_PATH="${OUTPUT_PATH}" \
+ DUCKDB_DLL_PATH="${OUTDIR}/duckdb.dll" \
+ python3 - <<'PY'
+import os
+import zipfile
+
+zip_path = os.environ["DUCKDB_ZIP_PATH"]
+entries = [
+ ("Windows/duckdb-driver-agent-windows-amd64.exe", os.environ["DUCKDB_AGENT_PATH"]),
+ ("Windows/duckdb.dll", os.environ["DUCKDB_DLL_PATH"]),
+]
+
+with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for arcname, src in entries:
+ if not os.path.isfile(src):
+ raise FileNotFoundError(src)
+ zf.write(src, arcname)
+PY
+ echo "📦 已生成 DuckDB Windows 专属驱动包: ${DUCKDB_ZIP_PATH}"
+ fi
done
bash ./tools/verify-driver-agent-revisions.sh \
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c359b9b..3e66c8f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -426,6 +426,28 @@ jobs:
./cmd/optional-driver-agent
fi
bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}"
+ if [ "$DRIVER" = "duckdb" ] && [ -n "$DUCKDB_LIB_DIR" ]; then
+ DUCKDB_ZIP_PATH="${OUTDIR}/duckdb-driver.zip" \
+ DUCKDB_AGENT_PATH="${OUTPUT_PATH}" \
+ DUCKDB_DLL_PATH="${OUTDIR}/duckdb.dll" \
+ python3 - <<'PY'
+import os
+import zipfile
+
+zip_path = os.environ["DUCKDB_ZIP_PATH"]
+entries = [
+ ("Windows/duckdb-driver-agent-windows-amd64.exe", os.environ["DUCKDB_AGENT_PATH"]),
+ ("Windows/duckdb.dll", os.environ["DUCKDB_DLL_PATH"]),
+]
+
+with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for arcname, src in entries:
+ if not os.path.isfile(src):
+ raise FileNotFoundError(src)
+ zf.write(src, arcname)
+PY
+ echo "📦 已生成 DuckDB Windows 专属驱动包: ${DUCKDB_ZIP_PATH}"
+ fi
done
bash ./tools/verify-driver-agent-revisions.sh \
@@ -707,6 +729,7 @@ jobs:
REQUIRED_FILES+=("drivers/Windows/${driver}-driver-agent-windows-arm64.exe")
else
REQUIRED_FILES+=("drivers/Windows/duckdb.dll")
+ REQUIRED_FILES+=("drivers/Windows/duckdb-driver.zip")
fi
for file in "${REQUIRED_FILES[@]}"; do
diff --git a/build-driver-agents.sh b/build-driver-agents.sh
index 9d1d7c2..d975e1e 100755
--- a/build-driver-agents.sh
+++ b/build-driver-agents.sh
@@ -145,6 +145,40 @@ PY
fi
}
+zip_duckdb_windows_package() {
+ local bundle_stage_dir="$1"
+ local zip_path="$2"
+
+ rm -f "$zip_path"
+ if command -v python3 >/dev/null 2>&1; then
+ BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$zip_path" python3 - <<'PY'
+import os
+import zipfile
+
+stage_dir = os.environ["BUNDLE_STAGE_DIR"]
+zip_path = os.environ["BUNDLE_ZIP_PATH"]
+entries = [
+ ("Windows/duckdb-driver-agent-windows-amd64.exe", os.path.join(stage_dir, "Windows", "duckdb-driver-agent-windows-amd64.exe")),
+ ("Windows/duckdb.dll", os.path.join(stage_dir, "Windows", "duckdb.dll")),
+]
+
+with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for arcname, src in entries:
+ if not os.path.isfile(src):
+ raise FileNotFoundError(src)
+ zf.write(src, arcname)
+PY
+ elif command -v zip >/dev/null 2>&1; then
+ (
+ cd "$bundle_stage_dir"
+ zip -qry "$zip_path" "Windows/duckdb-driver-agent-windows-amd64.exe" "Windows/duckdb.dll"
+ )
+ else
+ echo "❌ 未找到 python3 或 zip,无法生成 DuckDB Windows 专属驱动包。"
+ exit 1
+ fi
+}
+
prepare_duckdb_windows_library() {
local cache_root="$1"
local lib_dir="$cache_root/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
@@ -202,6 +236,7 @@ driver_csv=""
target_platform=""
out_root="dist/driver-agents"
bundle_name="GoNavi-DriverAgents.zip"
+duckdb_windows_zip_name="duckdb-driver.zip"
strict_mode="false"
upx_mode="${GONAVI_DRIVER_AGENT_UPX:-auto}"
@@ -403,6 +438,14 @@ fi
zip_bundle "$bundle_zip_path" "$bundle_stage_dir"
+duckdb_asset_path="$out_root_abs/windows-amd64/duckdb-driver-agent-windows-amd64.exe"
+duckdb_dll_path="$out_root_abs/windows-amd64/$DUCKDB_WINDOWS_SUPPORT_DLL"
+if [[ -f "$duckdb_asset_path" && -f "$duckdb_dll_path" ]]; then
+ duckdb_zip_path="$out_root_abs/windows-amd64/$duckdb_windows_zip_name"
+ zip_duckdb_windows_package "$bundle_stage_dir" "$duckdb_zip_path"
+ built_assets+=("Windows/$duckdb_windows_zip_name")
+fi
+
echo ""
echo "✅ 构建完成"
echo " 单文件输出根目录:$out_root_abs"
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index bed8925..7396e24 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-0295a42fd931778d85157816d79d29e5
\ No newline at end of file
+d0464f9da25e9356e61652e638c99ffe
\ No newline at end of file
diff --git a/frontend/src/components/DriverManagerModal.test.tsx b/frontend/src/components/DriverManagerModal.test.tsx
new file mode 100644
index 0000000..47ca2d7
--- /dev/null
+++ b/frontend/src/components/DriverManagerModal.test.tsx
@@ -0,0 +1,204 @@
+import React from 'react';
+import { act, create, type ReactTestRenderer } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import DriverManagerModal from './DriverManagerModal';
+
+const storeState = vi.hoisted(() => ({
+ theme: 'light',
+ appearance: {
+ enabled: true,
+ opacity: 1,
+ blur: 0,
+ uiVersion: 'legacy',
+ },
+}));
+
+const backendApp = vi.hoisted(() => ({
+ CheckDriverNetworkStatus: vi.fn(),
+ DownloadDriverPackage: vi.fn(),
+ GetDriverVersionList: vi.fn(),
+ GetDriverVersionPackageSize: vi.fn(),
+ GetDriverStatusList: vi.fn(),
+ InstallLocalDriverPackage: vi.fn(),
+ OpenDriverDownloadDirectory: vi.fn(),
+ RemoveDriverPackage: vi.fn(),
+ SelectDriverPackageDirectory: vi.fn(),
+ SelectDriverPackageFile: vi.fn(),
+}));
+
+const runtimeApi = vi.hoisted(() => ({
+ EventsOn: vi.fn(() => vi.fn()),
+}));
+
+const messageApi = vi.hoisted(() => ({
+ error: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+}));
+
+vi.mock('../store', () => ({
+ useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
+}));
+
+vi.mock('../../wailsjs/go/app/App', () => backendApp);
+vi.mock('../../wailsjs/runtime/runtime', () => runtimeApi);
+
+vi.mock('@ant-design/icons', () => {
+ const Icon = () => ;
+ return {
+ DeleteOutlined: Icon,
+ DownloadOutlined: Icon,
+ FileSearchOutlined: Icon,
+ FolderOpenOutlined: Icon,
+ InfoCircleFilled: Icon,
+ ReloadOutlined: Icon,
+ };
+});
+
+vi.mock('antd', () => {
+ const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
+
+ );
+
+ const Input: any = ({ value, onChange, placeholder }: any) => (
+
+ );
+ Input.Search = ({ value, onChange, placeholder }: any) => (
+
+ );
+
+ const Select = () => null;
+ const Progress = () =>
;
+ const Tag = ({ children }: any) => {children};
+ const Switch = ({ checked, onChange, disabled }: any) => (
+
+ );
+ const Space = ({ children }: any) => {children}
;
+ const Text = ({ children }: any) => {children};
+ const Paragraph = ({ children }: any) => {children}
;
+ const Typography = { Paragraph, Text };
+ const Alert = ({ children, message, description }: any) => {children}{message}{description}
;
+ const Empty: any = ({ description }: any) => {description}
;
+ Empty.PRESENTED_IMAGE_SIMPLE = null;
+ const Collapse = ({ items }: any) => (
+ {items?.map((item: any) =>
{item.label}{item.children}
)}
+ );
+ const Modal: any = ({ children, open, footer, title }: any) => (open ? (
+
+ {children}
+ {footer}
+
+ ) : null);
+ Modal.confirm = vi.fn();
+
+ return {
+ Alert,
+ Button,
+ Collapse,
+ Empty,
+ Input,
+ Modal,
+ Progress,
+ Select,
+ Space,
+ Switch,
+ Tag,
+ Typography,
+ message: messageApi,
+ };
+});
+
+const flushPromises = async () => {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+};
+
+const textContent = (node: any): string =>
+ (node.children || [])
+ .map((item: any) => (typeof item === 'string' ? item : textContent(item)))
+ .join('');
+
+const findButton = (renderer: ReactTestRenderer, text: string) =>
+ renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
+
+describe('DriverManagerModal toolbar actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ backendApp.GetDriverStatusList.mockResolvedValue({
+ success: true,
+ data: {
+ downloadDir: 'D:/drivers',
+ drivers: [
+ {
+ type: 'duckdb',
+ name: 'DuckDB',
+ builtIn: false,
+ pinnedVersion: '2.5.6',
+ runtimeAvailable: false,
+ packageInstalled: false,
+ connectable: false,
+ defaultDownloadUrl: 'builtin://activate/duckdb',
+ message: '未启用',
+ },
+ ],
+ },
+ });
+ backendApp.CheckDriverNetworkStatus.mockResolvedValue({
+ success: true,
+ data: {
+ reachable: true,
+ summary: 'ok',
+ recommendedProxy: false,
+ proxyConfigured: false,
+ checks: [],
+ },
+ });
+ backendApp.GetDriverVersionList.mockResolvedValue({
+ success: true,
+ data: {
+ versions: [{ version: '2.5.6', downloadUrl: 'builtin://activate/duckdb', recommended: true }],
+ },
+ });
+ backendApp.DownloadDriverPackage.mockImplementation(() => new Promise(() => {}));
+ backendApp.OpenDriverDownloadDirectory.mockResolvedValue({ success: true });
+ backendApp.SelectDriverPackageDirectory.mockResolvedValue({ success: true, data: { path: 'D:/drivers/import' } });
+ });
+
+ it('keeps directory tools enabled while a single driver install is running', async () => {
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await flushPromises();
+
+ const installButton = findButton(renderer!, '安装启用');
+ const openDirButtonBefore = findButton(renderer!, '打开驱动目录');
+ const importDirButtonBefore = findButton(renderer!, '导入驱动目录');
+ const installAllButtonBefore = findButton(renderer!, '安装所有驱动');
+
+ expect(openDirButtonBefore.props.disabled).toBeFalsy();
+ expect(importDirButtonBefore.props.disabled).toBeFalsy();
+ expect(installAllButtonBefore.props.disabled).toBeFalsy();
+
+ await act(async () => {
+ installButton.props.onClick();
+ await Promise.resolve();
+ });
+
+ const openDirButtonAfter = findButton(renderer!, '打开驱动目录');
+ const importDirButtonAfter = findButton(renderer!, '导入驱动目录');
+ const installAllButtonAfter = findButton(renderer!, '安装所有驱动');
+
+ expect(openDirButtonAfter.props.disabled).toBeFalsy();
+ expect(importDirButtonAfter.props.disabled).toBeFalsy();
+ expect(installAllButtonAfter.props.disabled).toBe(true);
+ });
+});
diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx
index 8efb601..477cdae 100644
--- a/frontend/src/components/DriverManagerModal.tsx
+++ b/frontend/src/components/DriverManagerModal.tsx
@@ -234,7 +234,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({});
const downloadDirRef = useRef(downloadDir);
const progressMapRef = useRef>({});
- const batchBusy = batchDirectoryImporting || batchAction !== '' || actionState.kind !== '';
+ const batchBusy = batchDirectoryImporting || batchAction !== '';
+ const installMutatingBusy = batchBusy || actionState.kind !== '';
useEffect(() => {
downloadDirRef.current = downloadDir;
@@ -1436,7 +1437,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
网络检测
)}
@@ -1584,12 +1585,12 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
setForceOverwriteInstalled(checked)}
- disabled={batchBusy}
+ disabled={batchDirectoryImporting}
/>
}
- disabled={batchBusy || installableRows.length === 0}
+ disabled={installMutatingBusy || installableRows.length === 0}
loading={batchAction === 'install-all'}
onClick={() => void installAllDrivers()}
>
@@ -1598,7 +1599,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
- disabled={batchBusy || reinstallableRows.length === 0}
+ disabled={installMutatingBusy || reinstallableRows.length === 0}
loading={batchAction === 'reinstall-updates'}
onClick={() => void reinstallNeededDrivers()}
>
@@ -1607,7 +1608,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
- disabled={batchBusy || removableRows.length === 0}
+ disabled={installMutatingBusy || removableRows.length === 0}
loading={batchAction === 'remove-all'}
onClick={() => void removeAllDrivers()}
>
@@ -1615,7 +1616,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
- disabled={batchBusy}
onClick={() => void openDriverDirectory()}
>
打开驱动目录
@@ -1623,7 +1623,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
}
loading={batchDirectoryImporting}
- disabled={batchBusy && !batchDirectoryImporting}
+ disabled={batchDirectoryImporting}
onClick={() => void installDriversFromDirectory()}
>
导入驱动目录
diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go
index ba845d3..3cc6857 100644
--- a/internal/app/methods_driver.go
+++ b/internal/app/methods_driver.go
@@ -15,6 +15,7 @@ import (
"net/url"
"os"
"os/exec"
+ "path"
"path/filepath"
"regexp"
stdRuntime "runtime"
@@ -298,6 +299,7 @@ const (
driverReleaseLatestAPIURL = "https://api.github.com/repos/" + driverReleaseRepo + "/releases/latest"
driverReleaseDevTag = "dev-latest"
optionalDriverBundleAssetName = "GoNavi-DriverAgents.zip"
+ duckDBWindowsDriverZipAssetName = "duckdb-driver.zip"
optionalDriverBundleIndexAssetName = "GoNavi-DriverAgents-Index.json"
optionalDriverBundleDownloadTimeout = 45 * time.Minute
optionalDriverBundleCacheMaxAge = 7 * 24 * time.Hour
@@ -371,6 +373,8 @@ var (
errLocalDriverDirScanLimit = errors.New("local_driver_directory_scan_limit_exceeded")
)
+var validateOptionalDriverAgentExecutableFunc = db.ValidateOptionalDriverAgentExecutable
+
type driverVersionWarmupState struct {
Running bool
LastStarted time.Time
@@ -1914,6 +1918,27 @@ func resolvePublishedDriverDownloadURLForTag(definition driverDefinition, select
}
func resolvePublishedDriverReleaseAssetName(driverType string, version string, tag string) (string, bool) {
+ if shouldUseDuckDBWindowsDynamicLibrary(driverType) {
+ cacheKey := "tag:" + strings.TrimSpace(tag)
+ if sizeByAsset, publishedAssets, ok := readReleaseAssetSizesFromCache(cacheKey); ok {
+ if publishedAssets[duckDBWindowsDriverZipAssetName] && sizeByAsset[duckDBWindowsDriverZipAssetName] > 0 {
+ return duckDBWindowsDriverZipAssetName, true
+ }
+ return "", false
+ }
+
+ sizeByAsset, publishedAssets, err := loadReleaseAssetSizesCached(cacheKey, func() (*githubRelease, error) {
+ return fetchReleaseByTag(tag)
+ })
+ if err != nil {
+ return "", false
+ }
+ if publishedAssets[duckDBWindowsDriverZipAssetName] && sizeByAsset[duckDBWindowsDriverZipAssetName] > 0 {
+ return duckDBWindowsDriverZipAssetName, true
+ }
+ return "", false
+ }
+
assetNames := optionalDriverReleaseAssetNamesForVersion(driverType, version)
if len(assetNames) == 0 {
return "", false
@@ -3006,7 +3031,7 @@ func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePa
return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理运行时依赖失败:%w", supportErr)
}
}
- if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
+ if validateErr := validateOptionalDriverAgentExecutableFunc(driverType, executablePath); validateErr != nil {
return installedDriverPackage{}, validateErr
}
@@ -3355,7 +3380,7 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
info, err := os.Stat(executablePath)
if err == nil && !info.IsDir() {
- if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
+ if validateErr := validateOptionalDriverAgentExecutableFunc(driverType, executablePath); validateErr != nil {
_ = os.Remove(executablePath)
} else {
// 用户点击“安装/重装”时应强制刷新驱动代理,避免沿用旧二进制导致修复不生效。
@@ -3389,7 +3414,7 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut
if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil {
return "", "", fmt.Errorf("复制预置 %s 驱动代理失败:%w", displayName, copyErr)
}
- if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
+ if validateErr := validateOptionalDriverAgentExecutableFunc(driverType, executablePath); validateErr != nil {
_ = os.Remove(executablePath)
return "", "", validateErr
}
@@ -3540,6 +3565,17 @@ func shouldUseOptionalDriverBundleFallback(driverType string, restrictToExplicit
return directURLCount == 0
}
+func isOptionalDriverDownloadZipURL(urlText string) bool {
+ trimmedURL := strings.TrimSpace(urlText)
+ if trimmedURL == "" {
+ return false
+ }
+ if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Path) != "" {
+ return strings.EqualFold(path.Ext(parsed.Path), ".zip")
+ }
+ return strings.EqualFold(filepath.Ext(trimmedURL), ".zip")
+}
+
func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlText string, executablePath string) (string, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)
@@ -3547,8 +3583,46 @@ func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlT
if trimmedURL == "" {
return "", fmt.Errorf("下载地址为空")
}
+ if isOptionalDriverDownloadZipURL(trimmedURL) {
+ tempPath := executablePath + ".download.zip"
+ _ = os.Remove(tempPath)
+
+ if _, err := downloadFileWithHash(trimmedURL, tempPath, func(downloaded, total int64) {
+ if a == nil {
+ return
+ }
+ scaledDownloaded, scaledTotal := scaleProgress(downloaded, total, 20, 90)
+ a.emitDriverDownloadProgress(driverType, "downloading", scaledDownloaded, scaledTotal, fmt.Sprintf("下载预编译 %s 驱动包", displayName))
+ }); err != nil {
+ _ = os.Remove(tempPath)
+ return "", fmt.Errorf("下载失败:%w", err)
+ }
+
+ if _, err := installOptionalDriverAgentFromLocalZip(tempPath, definition, executablePath, ""); err != nil {
+ _ = os.Remove(tempPath)
+ _ = os.Remove(executablePath)
+ for _, supportName := range optionalDriverSupportFileNames(driverType) {
+ _ = os.Remove(filepath.Join(filepath.Dir(executablePath), supportName))
+ }
+ return "", fmt.Errorf("安装预编译驱动包失败:%w", err)
+ }
+ _ = os.Remove(tempPath)
+
+ if validateErr := validateOptionalDriverAgentExecutableFunc(driverType, executablePath); validateErr != nil {
+ _ = os.Remove(executablePath)
+ for _, supportName := range optionalDriverSupportFileNames(driverType) {
+ _ = os.Remove(filepath.Join(filepath.Dir(executablePath), supportName))
+ }
+ return "", validateErr
+ }
+ hash, hashErr := hashFileSHA256(executablePath)
+ if hashErr != nil {
+ return "", fmt.Errorf("计算驱动代理摘要失败:%w", hashErr)
+ }
+ return hash, nil
+ }
if len(optionalDriverSupportFileNames(driverType)) > 0 {
- return "", fmt.Errorf("%s 当前平台需要随包提供运行时依赖(%s),不能安装单文件代理;请使用驱动总包或本地源码构建", displayName, strings.Join(optionalDriverSupportFileNames(driverType), ", "))
+ return "", fmt.Errorf("%s 当前平台需要随包提供运行时依赖(%s),不能安装单文件代理;请使用驱动总包、驱动专属 zip 或本地源码构建", displayName, strings.Join(optionalDriverSupportFileNames(driverType), ", "))
}
tempPath := executablePath + ".tmp"
_ = os.Remove(tempPath)
@@ -3576,7 +3650,7 @@ func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlT
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
return "", fmt.Errorf("设置代理权限失败:%w", chmodErr)
}
- if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
+ if validateErr := validateOptionalDriverAgentExecutableFunc(driverType, executablePath); validateErr != nil {
_ = os.Remove(executablePath)
return "", validateErr
}
@@ -3693,7 +3767,7 @@ func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition,
_ = os.Remove(executablePath)
return "", "", supportErr
}
- if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, executablePath); validateErr != nil {
+ if validateErr := validateOptionalDriverAgentExecutableFunc(driverType, executablePath); validateErr != nil {
_ = os.Remove(executablePath)
return "", "", validateErr
}
@@ -3940,6 +4014,10 @@ func shouldUseDuckDBWindowsDynamicLibrary(driverType string) bool {
return normalizeDriverType(driverType) == "duckdb" && stdRuntime.GOOS == "windows" && stdRuntime.GOARCH == "amd64"
}
+func shouldPreferPublishedOptionalDriverDownloads(driverType string) bool {
+ return shouldUseDuckDBWindowsDynamicLibrary(driverType)
+}
+
func shouldSkipDirectOptionalDriverDownloads(driverType string) bool {
return shouldUseDuckDBWindowsDynamicLibrary(driverType)
}
@@ -4698,6 +4776,7 @@ func acquireOptionalDriverBundlePath(bundleURL string, onProgress func(downloade
func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL string, selectedVersion string) []string {
candidates := make([]string, 0, 3)
seen := make(map[string]struct{}, 3)
+ driverType := normalizeDriverType(definition.Type)
appendURL := func(value string) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
@@ -4710,8 +4789,20 @@ func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL
candidates = append(candidates, trimmed)
}
- if shouldSkipDirectOptionalDriverDownloads(definition.Type) {
- return candidates
+ restrictToExplicitArtifact := shouldRestrictToExplicitVersionArtifact(definition, selectedVersion)
+ appendPublishedURLs := func() {
+ if tag := currentDriverReleaseTag(); tag != "" {
+ if publishedURL, ok := resolvePublishedDriverDownloadURLForTag(definition, selectedVersion, tag); ok {
+ appendURL(publishedURL)
+ }
+ }
+ if publishedURL, ok := resolveLatestPublishedDriverDownloadURL(definition); ok {
+ appendURL(publishedURL)
+ }
+ }
+
+ if !restrictToExplicitArtifact && shouldPreferPublishedOptionalDriverDownloads(driverType) {
+ appendPublishedURLs()
}
if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil {
@@ -4720,17 +4811,12 @@ func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL
appendURL(parsed.String())
}
}
- if shouldRestrictToExplicitVersionArtifact(definition, selectedVersion) {
+ if restrictToExplicitArtifact {
return candidates
}
- if tag := currentDriverReleaseTag(); tag != "" {
- if publishedURL, ok := resolvePublishedDriverDownloadURLForTag(definition, selectedVersion, tag); ok {
- appendURL(publishedURL)
- }
- }
- if publishedURL, ok := resolveLatestPublishedDriverDownloadURL(definition); ok {
- appendURL(publishedURL)
+ if !shouldPreferPublishedOptionalDriverDownloads(driverType) {
+ appendPublishedURLs()
}
return candidates
}
@@ -4755,7 +4841,7 @@ func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targe
if statErr != nil || info.IsDir() {
continue
}
- if validateErr := db.ValidateOptionalDriverAgentExecutable(driverType, absPath); validateErr != nil {
+ if validateErr := validateOptionalDriverAgentExecutableFunc(driverType, absPath); validateErr != nil {
continue
}
if !isReusableOptionalDriverAgentRevisionCurrent(driverType, absPath) {
@@ -5342,6 +5428,24 @@ func resolveLatestPublishedDriverDownloadURL(definition driverDefinition) (strin
if driverType == "" {
return "", false
}
+ if shouldUseDuckDBWindowsDynamicLibrary(driverType) {
+ if sizeByAsset, publishedAssets, ok := readReleaseAssetSizesFromCache("latest"); ok {
+ if publishedAssets[duckDBWindowsDriverZipAssetName] && sizeByAsset[duckDBWindowsDriverZipAssetName] > 0 {
+ return driverReleaseLatestDownloadURL(duckDBWindowsDriverZipAssetName), true
+ }
+ return "", false
+ }
+
+ sizeByAsset, publishedAssets, err := loadReleaseAssetSizesCached("latest", fetchLatestReleaseForDriverAssets)
+ if err != nil {
+ return "", false
+ }
+ if publishedAssets[duckDBWindowsDriverZipAssetName] && sizeByAsset[duckDBWindowsDriverZipAssetName] > 0 {
+ return driverReleaseLatestDownloadURL(duckDBWindowsDriverZipAssetName), true
+ }
+ return "", false
+ }
+
assetNames := optionalDriverReleaseAssetNames(driverType)
if len(assetNames) == 0 {
return "", false
diff --git a/internal/app/methods_driver_version_test.go b/internal/app/methods_driver_version_test.go
index 9432fbd..36f852f 100644
--- a/internal/app/methods_driver_version_test.go
+++ b/internal/app/methods_driver_version_test.go
@@ -380,9 +380,21 @@ func TestDuckDBWindowsBuildUsesDynamicLibraryTag(t *testing.T) {
if !shouldSkipReusableAgentCandidate("duckdb", "") {
t.Fatal("expected DuckDB Windows install to skip reusable static agent candidates")
}
- urls := resolveOptionalDriverAgentDownloadURLs(driverDefinition{Type: "duckdb"}, "https://example.com/duckdb-driver-agent-windows-amd64.exe", "")
- if len(urls) != 0 {
- t.Fatalf("expected DuckDB Windows install to skip single-file direct downloads, got %v", urls)
+ seedReleaseAssetCacheEntry(t, "latest", map[string]int64{
+ duckDBWindowsDriverZipAssetName: 19 << 20,
+ }, map[string]int64{
+ duckDBWindowsDriverZipAssetName: 19 << 20,
+ })
+ legacyDirectURL := "https://example.com/duckdb-driver-agent-windows-amd64.exe"
+ urls := resolveOptionalDriverAgentDownloadURLs(driverDefinition{Type: "duckdb"}, legacyDirectURL, "")
+ if len(urls) < 2 {
+ t.Fatalf("expected DuckDB Windows install to keep dedicated zip ahead of legacy direct candidate, got %v", urls)
+ }
+ if urls[0] != driverReleaseLatestDownloadURL(duckDBWindowsDriverZipAssetName) {
+ t.Fatalf("expected DuckDB Windows dedicated zip candidate first, got %v", urls)
+ }
+ if urls[1] != legacyDirectURL {
+ t.Fatalf("expected DuckDB Windows to keep legacy direct candidate after dedicated zip, got %v", urls)
}
}
@@ -437,6 +449,83 @@ func TestInstallOptionalDriverAgentFromLocalZipExtractsDuckDBDLL(t *testing.T) {
}
}
+func TestDownloadOptionalDriverAgentBinaryInstallsDuckDBDedicatedZip(t *testing.T) {
+ if runtime.GOOS != "windows" || runtime.GOARCH != "amd64" {
+ t.Skip("DuckDB dedicated zip flow only applies on windows/amd64")
+ }
+
+ originalValidateFunc := validateOptionalDriverAgentExecutableFunc
+ validateOptionalDriverAgentExecutableFunc = func(driverType string, executablePath string) error {
+ return nil
+ }
+ t.Cleanup(func() {
+ validateOptionalDriverAgentExecutableFunc = originalValidateFunc
+ })
+
+ tmpDir := t.TempDir()
+ zipPath := filepath.Join(tmpDir, duckDBWindowsDriverZipAssetName)
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ t.Fatalf("create zip failed: %v", err)
+ }
+ zw := zip.NewWriter(zipFile)
+ for name, content := range map[string]string{
+ "Windows/duckdb-driver-agent-windows-amd64.exe": "agent",
+ "Windows/duckdb.dll": "dll",
+ } {
+ w, err := zw.Create(name)
+ if err != nil {
+ t.Fatalf("create zip entry %s failed: %v", name, err)
+ }
+ if _, err := w.Write([]byte(content)); err != nil {
+ t.Fatalf("write zip entry %s failed: %v", name, err)
+ }
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatalf("close zip writer failed: %v", err)
+ }
+ if err := zipFile.Close(); err != nil {
+ t.Fatalf("close zip file failed: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.ServeFile(w, r, zipPath)
+ }))
+ defer server.Close()
+
+ target := filepath.Join(tmpDir, "install", "duckdb-driver-agent.exe")
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ t.Fatalf("create install dir failed: %v", err)
+ }
+
+ hash, err := downloadOptionalDriverAgentBinary(nil, driverDefinition{Type: "duckdb", Name: "DuckDB"}, server.URL+"/"+duckDBWindowsDriverZipAssetName+"?source=release", target)
+ if err != nil {
+ t.Fatalf("download dedicated zip failed: %v", err)
+ }
+ if strings.TrimSpace(hash) == "" {
+ t.Fatal("expected hash for installed duckdb agent")
+ }
+ if _, err := os.Stat(target); err != nil {
+ t.Fatalf("expected duckdb agent to be installed: %v", err)
+ }
+ dllBytes, err := os.ReadFile(filepath.Join(filepath.Dir(target), "duckdb.dll"))
+ if err != nil {
+ t.Fatalf("expected duckdb.dll to be installed: %v", err)
+ }
+ if string(dllBytes) != "dll" {
+ t.Fatalf("unexpected duckdb.dll content: %q", string(dllBytes))
+ }
+}
+
+func TestOptionalDriverDownloadZipURLAcceptsQueryString(t *testing.T) {
+ if !isOptionalDriverDownloadZipURL("https://example.com/duckdb-driver.zip?token=abc") {
+ t.Fatal("expected signed zip URL to be treated as zip download")
+ }
+ if isOptionalDriverDownloadZipURL("https://example.com/duckdb-driver-agent.exe?token=abc") {
+ t.Fatal("expected exe URL with query to remain non-zip download")
+ }
+}
+
func TestDownloadDriverPackageRejectsUnsupportedMongoVersion(t *testing.T) {
app := &App{}