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} />