From 98c1600e1301f0bea2f2fcdfeddd2fc31a251cb5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 11:33:21 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(driver-manager):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=A9=B1=E5=8A=A8=E7=AE=A1=E7=90=86=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增驱动目录批量导入入口,支持覆盖已安装开关与去重处理 - 行内本地导入聚焦单文件场景,目录导入与单文件导入流程统一 - 已安装驱动版本选择锁定,避免安装后误改版本 - 补充驱动下载网络检测与日志可见性,提升问题定位效率 - 重构驱动管理横向滚动条实现,修复双滚动条/消失/位置异常问题 --- frontend/src/App.css | 50 ++ .../src/components/DriverManagerModal.tsx | 470 +++++++++++++++--- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/methods_driver.go | 227 ++++++++- 5 files changed, 656 insertions(+), 97 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index e532c84..713d6b9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -92,3 +92,53 @@ body[data-theme='dark'] { background-color: #ff4d4f !important; color: #fff !important; } + +/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */ +.driver-manager-table .ant-table-sticky-scroll { + display: none !important; +} + +/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */ +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content, +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body { + overflow-x: auto !important; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal, +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal { + height: 0 !important; +} + +.driver-manager-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: hidden; +} + +.driver-manager-footer { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} + +.driver-manager-footer-actions { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.driver-manager-hscroll { + width: 100%; + height: 12px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-gutter: stable; + background: transparent; +} + +.driver-manager-hscroll-inner { + height: 1px; +} diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index d4e7190..82a57df 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd'; -import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Button, Collapse, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'; +import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { CheckDriverNetworkStatus, @@ -10,6 +10,7 @@ import { GetDriverStatusList, InstallLocalDriverPackage, RemoveDriverPackage, + SelectDriverPackageDirectory, SelectDriverPackageFile, } from '../../wailsjs/go/app/App'; @@ -46,6 +47,8 @@ type ProgressState = { percent: number; }; +type DriverActionKind = '' | 'install' | 'remove' | 'local'; + type DriverLogEntry = { time: string; text: string; @@ -84,6 +87,7 @@ type DriverVersionOption = { const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`; const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`; +const DRIVER_TABLE_SCROLL_X = 1450; const buildVersionSelectOptions = (options: DriverVersionOption[]) => { type SelectOption = { value: string; label: string }; @@ -132,20 +136,28 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => { }; const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { + const modalContentRef = useRef(null); + const tableContainerRef = useRef(null); + const tableScrollTargetsRef = useRef([]); + const externalHScrollRef = useRef(null); + const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>(''); const [loading, setLoading] = useState(false); const [downloadDir, setDownloadDir] = useState(''); const [networkChecking, setNetworkChecking] = useState(false); const [networkStatus, setNetworkStatus] = useState(null); const [rows, setRows] = useState([]); - const [actionDriver, setActionDriver] = useState(''); + const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' }); const [progressMap, setProgressMap] = useState>({}); const [operationLogMap, setOperationLogMap] = useState>({}); const [logDriverType, setLogDriverType] = useState(''); const [logModalOpen, setLogModalOpen] = useState(false); + const [batchDirectoryImporting, setBatchDirectoryImporting] = useState(false); + const [forceOverwriteInstalled, setForceOverwriteInstalled] = useState(false); const [versionMap, setVersionMap] = useState>({}); const [selectedVersionMap, setSelectedVersionMap] = useState>({}); const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); + const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X); const appendOperationLog = useCallback(( driverType: string, @@ -193,6 +205,76 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }); }, []); + const refreshHorizontalScrollState = useCallback(() => { + const tableContainer = tableContainerRef.current; + const targets = tableContainer + ? [ + ...new Set( + [ + ...Array.from(tableContainer.querySelectorAll('.ant-table-content')), + ...Array.from(tableContainer.querySelectorAll('.ant-table-body')), + ].filter((node): node is HTMLElement => node instanceof HTMLElement), + ), + ] + : tableScrollTargetsRef.current; + if (!targets || targets.length === 0) { + setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X); + return; + } + + const nextWidth = Math.max( + DRIVER_TABLE_SCROLL_X, + ...targets.map((target) => Math.max(0, target.scrollWidth)), + ); + setHorizontalScrollWidth((prev) => (prev === nextWidth ? prev : nextWidth)); + + const externalScroll = externalHScrollRef.current; + if (!externalScroll || horizontalSyncSourceRef.current === 'external') { + return; + } + const preferredTarget = + targets.find((target) => target.scrollWidth > target.clientWidth + 1) || + targets[0]; + const targetScrollLeft = preferredTarget?.scrollLeft || 0; + if (Math.abs(externalScroll.scrollLeft - targetScrollLeft) > 1) { + externalScroll.scrollLeft = targetScrollLeft; + } + }, []); + + const applyExternalScrollToTableTargets = useCallback(() => { + const tableContainer = tableContainerRef.current; + const externalScroll = externalHScrollRef.current; + if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) { + return; + } + if (horizontalSyncSourceRef.current === 'table') { + return; + } + + const liveTargets = [ + ...new Set( + [ + ...Array.from(tableContainer.querySelectorAll('.ant-table-content')), + ...Array.from(tableContainer.querySelectorAll('.ant-table-body')), + ].filter((node): node is HTMLElement => node instanceof HTMLElement), + ), + ]; + if (liveTargets.length === 0) { + return; + } + + horizontalSyncSourceRef.current = 'external'; + liveTargets.forEach((target) => { + if (target.scrollWidth <= target.clientWidth + 1) { + return; + } + if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) { + target.scrollLeft = externalScroll.scrollLeft; + } + }); + horizontalSyncSourceRef.current = ''; + }, []); + const refreshStatus = useCallback(async (toastOnError = true) => { setLoading(true); try { @@ -429,12 +511,125 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ useEffect(() => { if (!open) { + setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X); + tableScrollTargetsRef.current = []; return; } refreshStatus(false); checkNetworkStatus(false); }, [checkNetworkStatus, open, refreshStatus]); + useEffect(() => { + if (!open) { + return; + } + const tableContainer = tableContainerRef.current; + const externalScroll = externalHScrollRef.current; + if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) { + return; + } + + let currentTargets: HTMLElement[] = []; + let rafId: number | null = null; + let bodyResizeObserver: ResizeObserver | null = null; + let containerResizeObserver: ResizeObserver | null = null; + + const pickSyncTarget = () => { + if (currentTargets.length === 0) { + return null; + } + return currentTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || currentTargets[0]; + }; + + const syncFromTableTarget = (event?: Event) => { + const source = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null; + const activeTarget = source || pickSyncTarget(); + if (!activeTarget) { + return; + } + if (horizontalSyncSourceRef.current === 'external') { + return; + } + horizontalSyncSourceRef.current = 'table'; + if (Math.abs(externalScroll.scrollLeft - activeTarget.scrollLeft) > 1) { + externalScroll.scrollLeft = activeTarget.scrollLeft; + } + horizontalSyncSourceRef.current = ''; + }; + + const bindCurrentTableTargets = () => { + const nextTargets = [ + ...new Set( + [ + ...Array.from(tableContainer.querySelectorAll('.ant-table-content')), + ...Array.from(tableContainer.querySelectorAll('.ant-table-body')), + ].filter((node): node is HTMLElement => node instanceof HTMLElement), + ), + ]; + + const sameTargets = + nextTargets.length === currentTargets.length && + nextTargets.every((target, index) => target === currentTargets[index]); + if (sameTargets) { + return; + } + + currentTargets.forEach((target) => { + target.removeEventListener('scroll', syncFromTableTarget); + bodyResizeObserver?.unobserve(target); + }); + + currentTargets = nextTargets; + tableScrollTargetsRef.current = nextTargets; + currentTargets.forEach((target) => { + target.addEventListener('scroll', syncFromTableTarget, { passive: true }); + bodyResizeObserver?.observe(target); + }); + + refreshHorizontalScrollState(); + syncFromTableTarget(); + }; + + const scheduleRefresh = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + bindCurrentTableTargets(); + refreshHorizontalScrollState(); + }); + }; + + const mutationObserver = new MutationObserver(scheduleRefresh); + mutationObserver.observe(tableContainer, { childList: true, subtree: true }); + + bodyResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null; + containerResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null; + containerResizeObserver?.observe(tableContainer); + if (typeof ResizeObserver !== 'undefined') { + modalContentRef.current && containerResizeObserver?.observe(modalContentRef.current); + } + window.addEventListener('resize', scheduleRefresh); + + scheduleRefresh(); + return () => { + mutationObserver.disconnect(); + window.removeEventListener('resize', scheduleRefresh); + currentTargets.forEach((target) => { + target.removeEventListener('scroll', syncFromTableTarget); + }); + if (bodyResizeObserver) { + bodyResizeObserver.disconnect(); + } + if (containerResizeObserver) { + containerResizeObserver.disconnect(); + } + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [open, refreshHorizontalScrollState]); + useEffect(() => { if (!open) { return; @@ -470,7 +665,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }, [appendOperationLog, open]); const installDriver = useCallback(async (row: DriverStatusRow) => { - setActionDriver(row.type); + setActionState({ driverType: row.type, kind: 'install' }); setProgressMap((prev) => ({ ...prev, [row.type]: { @@ -505,25 +700,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ message.success(`${row.name}${versionTip} 已安装启用`); refreshStatus(false); } finally { - setActionDriver(''); + setActionState({ driverType: '', kind: '' }); } }, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]); - const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { - const fileRes = await SelectDriverPackageFile(downloadDir); - if (!fileRes?.success) { - if (String(fileRes?.message || '') !== 'Cancelled') { - message.error(fileRes?.message || '选择本地驱动包失败'); + const installDriverFromLocalPath = useCallback(async ( + row: DriverStatusRow, + sourcePath: string, + sourceLabel: '文件' | '目录', + options?: { silentToast?: boolean; skipRefresh?: boolean }, + ) => { + const pathText = String(sourcePath || '').trim(); + if (!pathText) { + if (!options?.silentToast) { + message.error(`未选择有效的本地导入${sourceLabel}`); } - return; - } - const filePath = String((fileRes?.data as any)?.path || '').trim(); - if (!filePath) { - message.error('未选择有效的驱动包文件'); - return; + return false; } - setActionDriver(row.type); + setActionState({ driverType: row.type, kind: 'local' }); setProgressMap((prev) => ({ ...prev, [row.type]: { @@ -532,23 +727,122 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ percent: 0, }, })); - appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`); + appendOperationLog(row.type, `[START] 开始本地导入(${sourceLabel}):${pathText}`); try { - const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir); + const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir); if (!result?.success) { const errText = result?.message || `导入 ${row.name} 本地驱动包失败`; appendOperationLog(row.type, `[ERROR] ${errText}`); - message.error(errText); - return; + if (!options?.silentToast) { + message.error(errText); + } + return false; } appendOperationLog(row.type, '[DONE] 本地导入安装完成'); - message.success(`${row.name} 本地驱动包已安装启用`); - refreshStatus(false); + if (!options?.silentToast) { + message.success(`${row.name} 本地驱动包已安装启用`); + } + if (!options?.skipRefresh) { + await refreshStatus(false); + } + return true; } finally { - setActionDriver(''); + setActionState({ driverType: '', kind: '' }); } }, [appendOperationLog, downloadDir, refreshStatus]); + const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { + const fileRes = await SelectDriverPackageFile(downloadDir); + if (!fileRes?.success) { + if (String(fileRes?.message || '') !== 'Cancelled') { + message.error(fileRes?.message || '选择本地驱动包文件失败'); + } + return; + } + const filePath = String((fileRes?.data as any)?.path || '').trim(); + if (!filePath) { + message.error('未选择有效的驱动包文件'); + return; + } + await installDriverFromLocalPath(row, filePath, '文件'); + }, [downloadDir, installDriverFromLocalPath]); + + const installDriversFromDirectory = useCallback(async () => { + const directoryRes = await SelectDriverPackageDirectory(downloadDir); + if (!directoryRes?.success) { + if (String(directoryRes?.message || '') !== 'Cancelled') { + message.error(directoryRes?.message || '选择本地驱动包目录失败'); + } + return; + } + + const directoryPath = String((directoryRes?.data as any)?.path || '').trim(); + if (!directoryPath) { + message.error('未选择有效的驱动包目录'); + return; + } + const optionalRows = rows.filter((item) => !item.builtIn); + if (optionalRows.length === 0) { + message.info('当前没有可导入的外置驱动'); + return; + } + + let successCount = 0; + let failCount = 0; + let dedupeSkipCount = 0; + let slimSkipCount = 0; + + setBatchDirectoryImporting(true); + try { + for (const row of optionalRows) { + const alreadyInstalled = row.packageInstalled || row.connectable; + if (alreadyInstalled && !forceOverwriteInstalled) { + dedupeSkipCount += 1; + appendOperationLog(row.type, '[SKIP] 已检测到驱动已安装,目录导入去重跳过'); + continue; + } + if (alreadyInstalled && forceOverwriteInstalled) { + appendOperationLog(row.type, '[INFO] 已启用覆盖已安装模式,执行重装导入'); + } + const isSlimBuildUnavailable = (row.message || '').includes('精简构建') && !row.packageInstalled; + if (isSlimBuildUnavailable) { + slimSkipCount += 1; + appendOperationLog(row.type, '[WARN] 当前发行包为精简构建,已跳过目录导入'); + continue; + } + const ok = await installDriverFromLocalPath(row, directoryPath, '目录', { silentToast: true, skipRefresh: true }); + if (ok) { + successCount += 1; + } else { + failCount += 1; + } + } + await refreshStatus(false); + } finally { + setBatchDirectoryImporting(false); + } + + const skipParts: string[] = []; + if (dedupeSkipCount > 0) { + skipParts.push(`去重跳过 ${dedupeSkipCount}`); + } + if (slimSkipCount > 0) { + skipParts.push(`精简版跳过 ${slimSkipCount}`); + } + const skipTip = skipParts.length > 0 ? `,${skipParts.join(',')}` : ''; + + const forceTip = forceOverwriteInstalled ? '(覆盖已安装)' : ''; + if (failCount === 0) { + message.success(`目录导入完成${forceTip}:成功 ${successCount}${skipTip}`); + return; + } + if (successCount > 0) { + message.warning(`目录导入完成${forceTip}:成功 ${successCount},失败 ${failCount}${skipTip}`); + return; + } + message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`); + }, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]); + const openDriverLog = useCallback((driverType: string) => { const normalized = String(driverType || '').trim().toLowerCase(); if (!normalized) { @@ -559,7 +853,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }, []); const removeDriver = useCallback(async (row: DriverStatusRow) => { - setActionDriver(row.type); + setActionState({ driverType: row.type, kind: 'remove' }); appendOperationLog(row.type, '[START] 开始移除驱动'); try { const result = await RemoveDriverPackage(row.type, downloadDir); @@ -578,7 +872,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }); refreshStatus(false); } finally { - setActionDriver(''); + setActionState({ driverType: '', kind: '' }); } }, [appendOperationLog, downloadDir, refreshStatus]); @@ -590,25 +884,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ key: 'name', width: 150, }, - { - title: '安装位置', - key: 'installPath', - width: 260, - render: (_: string, row: DriverStatusRow) => { - if (row.builtIn) { - return 内置; - } - const installPath = row.executablePath || row.installDir || '-'; - if (installPath === '-') { - return -; - } - return ( - - {installPath} - - ); - }, - }, { title: '安装包大小', dataIndex: 'packageSizeText', @@ -688,6 +963,14 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ if (row.builtIn) { return -; } + const versionLocked = row.packageInstalled || row.connectable; + if (versionLocked) { + const installedVersion = String(row.installedVersion || '').trim(); + if (installedVersion) { + return {installedVersion}(已安装,移除后可更换); + } + return 已安装(移除后可更换); + } const options = versionMap[row.type] || []; const selectedKey = selectedVersionMap[row.type]; const selectOptions = buildVersionSelectOptions(options); @@ -696,7 +979,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ size="small" style={{ width: '100%' }} loading={!!versionLoadingMap[row.type]} - disabled={actionDriver === row.type} + disabled={actionState.driverType === row.type} placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'} value={selectedKey} options={selectOptions as any} @@ -726,7 +1009,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ return -; } const isSlimBuildUnavailable = (row.message || '').includes('精简构建'); - const loadingAction = actionDriver === row.type; + const loadingInstallOrRemove = + actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove'); + const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local'; if (isSlimBuildUnavailable && !row.packageInstalled) { return 需 Full 版; } @@ -738,7 +1023,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ , - , - , - ]} + footer={( +
+
+
+
+ + + + + +
+ )} > +
除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。 {networkStatus ? ( @@ -868,7 +1166,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ description={( 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 - 手动导入支持单个驱动代理文件(如 `mariadb-driver-agent` / `mariadb-driver-agent.exe`)或驱动总包 `GoNavi-DriverAgents.zip`。 + 行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。 驱动根目录:{downloadDir || '-'} @@ -881,16 +1179,42 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ )} /> - + + + 覆盖已安装 + setForceOverwriteInstalled(checked)} + disabled={batchDirectoryImporting} + /> + + + + +
+
+ + ; +export function SelectDriverPackageDirectory(arg1:string):Promise; + export function SelectDriverPackageFile(arg1:string):Promise; export function SelectSSHKeyFile(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 6dba529..6879fc2 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -322,6 +322,10 @@ export function SelectDriverDownloadDirectory(arg1) { return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1); } +export function SelectDriverPackageDirectory(arg1) { + return window['go']['app']['App']['SelectDriverPackageDirectory'](arg1); +} + export function SelectDriverPackageFile(arg1) { return window['go']['app']['App']['SelectDriverPackageFile'](arg1); } diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 0c3a3e9..350a21c 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net" "net/http" "net/url" @@ -200,6 +201,7 @@ const ( driverBundleIndexMaxSize = 1 << 20 driverManifestMaxSize = 2 << 20 driverNetworkProbeTimeout = 4 * time.Second + localDriverDirectoryScanMaxEntries = 20000 driverChecksumPolicyStrict = "strict" driverChecksumPolicyWarn = "warn" driverChecksumPolicyOff = "off" @@ -228,18 +230,19 @@ const builtinDriverManifestJSON = `{ }` var ( - driverManifestCacheMu sync.RWMutex - driverManifestCache = make(map[string]driverManifestCacheEntry) - driverReleaseSizeMu sync.RWMutex - driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry) - driverReleaseListMu sync.RWMutex - driverReleaseList = driverManifestReleaseListCache{} - driverModuleLatestMu sync.RWMutex - driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry) - driverModuleVersionMu sync.RWMutex - driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry) - driverVersionWarmupMu sync.Mutex - driverVersionWarmup = driverVersionWarmupState{} + driverManifestCacheMu sync.RWMutex + driverManifestCache = make(map[string]driverManifestCacheEntry) + driverReleaseSizeMu sync.RWMutex + driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry) + driverReleaseListMu sync.RWMutex + driverReleaseList = driverManifestReleaseListCache{} + driverModuleLatestMu sync.RWMutex + driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry) + driverModuleVersionMu sync.RWMutex + driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry) + driverVersionWarmupMu sync.Mutex + driverVersionWarmup = driverVersionWarmupState{} + errLocalDriverDirScanLimit = errors.New("local_driver_directory_scan_limit_exceeded") ) type driverVersionWarmupState struct { @@ -360,9 +363,6 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: "选择驱动包文件", DefaultDirectory: defaultDir, - Filters: []runtime.FileFilter{ - {DisplayName: "所有文件", Pattern: "*"}, - }, }) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} @@ -377,6 +377,36 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} } +func (a *App) SelectDriverPackageDirectory(currentPath string) connection.QueryResult { + defaultDir := strings.TrimSpace(currentPath) + if defaultDir == "" { + defaultDir = defaultDriverDownloadDirectory() + } + if filepath.Ext(defaultDir) != "" { + defaultDir = filepath.Dir(defaultDir) + } + if !filepath.IsAbs(defaultDir) { + if abs, err := filepath.Abs(defaultDir); err == nil { + defaultDir = abs + } + } + + selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "选择驱动包目录", + DefaultDirectory: defaultDir, + }) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + if abs, err := filepath.Abs(selection); err == nil { + selection = abs + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} +} + func (a *App) ResolveDriverDownloadDirectory(directory string) connection.QueryResult { resolved, err := resolveDriverDownloadDirectory(directory) if err != nil { @@ -684,7 +714,7 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包") selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition) - meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion) + meta, installErr := installOptionalDriverAgentFromLocalPath(definition, filePath, resolvedDir, selectedVersion) if installErr != nil { errText := normalizeErrorMessage(installErr) a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) @@ -2194,7 +2224,7 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele }, nil } -func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) { +func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) { driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition) pathText := strings.TrimSpace(filePath) @@ -2208,9 +2238,6 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa if statErr != nil { return installedDriverPackage{}, fmt.Errorf("读取本地驱动包失败:%w", statErr) } - if info.IsDir() { - return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为目录:%s", pathText) - } executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType) if err != nil { @@ -2220,8 +2247,23 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa return installedDriverPackage{}, fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr) } + sourcePath := pathText + sourceName := filepath.Base(pathText) downloadSource := fmt.Sprintf("local://manual/%s", filepath.Base(pathText)) - if strings.EqualFold(filepath.Ext(pathText), ".zip") { + if info.IsDir() { + matchedPath, matchedEntry, resolveErr := resolveLocalDriverAgentFromDirectory(pathText, driverType) + if resolveErr != nil { + return installedDriverPackage{}, resolveErr + } + sourcePath = matchedPath + sourceName = filepath.Base(matchedPath) + downloadSource = fmt.Sprintf("local://manual-dir/%s", filepath.Base(pathText)) + if strings.TrimSpace(matchedEntry) != "" { + downloadSource = downloadSource + "#" + matchedEntry + } + } + + if !info.IsDir() && strings.EqualFold(filepath.Ext(pathText), ".zip") { entryName, extractErr := installOptionalDriverAgentFromLocalZip(pathText, definition, executablePath) if extractErr != nil { return installedDriverPackage{}, extractErr @@ -2230,7 +2272,7 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa downloadSource = downloadSource + "#" + entryName } } else { - if copyErr := copyAgentBinary(pathText, executablePath); copyErr != nil { + if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil { return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr) } } @@ -2242,8 +2284,8 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa return installedDriverPackage{ DriverType: driverType, Version: strings.TrimSpace(selectedVersion), - FilePath: pathText, - FileName: filepath.Base(pathText), + FilePath: sourcePath, + FileName: sourceName, ExecutablePath: executablePath, DownloadURL: downloadSource, SHA256: hash, @@ -2251,6 +2293,143 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa }, nil } +type localDriverCandidate struct { + absPath string + relativePath string + depth int + inPlatformDir bool +} + +func resolveLocalDriverAgentFromDirectory(directoryPath string, driverType string) (string, string, error) { + root := strings.TrimSpace(directoryPath) + if root == "" { + return "", "", fmt.Errorf("本地驱动目录路径为空") + } + if absPath, absErr := filepath.Abs(root); absErr == nil { + root = absPath + } + info, statErr := os.Stat(root) + if statErr != nil { + return "", "", fmt.Errorf("读取本地驱动目录失败:%w", statErr) + } + if !info.IsDir() { + return "", "", fmt.Errorf("本地驱动目录路径不是目录:%s", root) + } + + normalizedType := normalizeDriverType(driverType) + displayDefinition, found := resolveDriverDefinition(normalizedType) + if !found { + displayDefinition = driverDefinition{Type: normalizedType, Name: normalizedType} + } + displayName := resolveDriverDisplayName(displayDefinition) + platformDir := optionalDriverBundlePlatformDir(stdRuntime.GOOS) + assetName := optionalDriverReleaseAssetName(normalizedType) + baseName := optionalDriverExecutableBaseName(normalizedType) + + exactRelativePath := filepath.ToSlash(filepath.Join(platformDir, assetName)) + exactPath := filepath.Join(root, platformDir, assetName) + if exactInfo, err := os.Stat(exactPath); err == nil && !exactInfo.IsDir() { + return exactPath, exactRelativePath, nil + } + + rootAssetPath := filepath.Join(root, assetName) + if rootAssetInfo, err := os.Stat(rootAssetPath); err == nil && !rootAssetInfo.IsDir() { + return rootAssetPath, filepath.ToSlash(assetName), nil + } + + assetCandidates := make([]localDriverCandidate, 0, 8) + baseCandidates := make([]localDriverCandidate, 0, 8) + visited := 0 + walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + visited++ + if visited > localDriverDirectoryScanMaxEntries { + return errLocalDriverDirScanLimit + } + if d.IsDir() { + return nil + } + name := strings.TrimSpace(d.Name()) + if name == "" { + return nil + } + + relative, relErr := filepath.Rel(root, path) + if relErr != nil { + relative = name + } + normalizedRelative := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(relative), "./")) + if normalizedRelative == "" { + normalizedRelative = name + } + normalizedLower := strings.ToLower(normalizedRelative) + platformPrefix := strings.ToLower(platformDir) + "/" + inPlatformDir := normalizedLower == strings.ToLower(platformDir) || strings.HasPrefix(normalizedLower, platformPrefix) + depth := strings.Count(normalizedRelative, "/") + candidate := localDriverCandidate{ + absPath: path, + relativePath: normalizedRelative, + depth: depth, + inPlatformDir: inPlatformDir, + } + + if strings.EqualFold(name, assetName) { + assetCandidates = append(assetCandidates, candidate) + return nil + } + if strings.EqualFold(name, baseName) { + baseCandidates = append(baseCandidates, candidate) + } + return nil + }) + if errors.Is(walkErr, errLocalDriverDirScanLimit) { + return "", "", fmt.Errorf("本地驱动目录条目过多(超过 %d),请缩小目录范围或直接选择 zip/单文件", localDriverDirectoryScanMaxEntries) + } + if walkErr != nil { + return "", "", fmt.Errorf("扫描本地驱动目录失败:%w", walkErr) + } + + selectBest := func(candidates []localDriverCandidate) (localDriverCandidate, bool) { + if len(candidates) == 0 { + return localDriverCandidate{}, false + } + sort.Slice(candidates, func(i, j int) bool { + left := candidates[i] + right := candidates[j] + if left.inPlatformDir != right.inPlatformDir { + return left.inPlatformDir + } + if left.depth != right.depth { + return left.depth < right.depth + } + leftRelative := strings.ToLower(left.relativePath) + rightRelative := strings.ToLower(right.relativePath) + if leftRelative != rightRelative { + return leftRelative < rightRelative + } + return strings.ToLower(left.absPath) < strings.ToLower(right.absPath) + }) + return candidates[0], true + } + + if candidate, ok := selectBest(assetCandidates); ok { + return candidate.absPath, candidate.relativePath, nil + } + if candidate, ok := selectBest(baseCandidates); ok { + return candidate.absPath, candidate.relativePath, nil + } + + return "", "", fmt.Errorf( + "目录中未找到 %s 代理文件(优先路径 %s,候选文件名 %s / %s)", + displayName, + exactRelativePath, + assetName, + baseName, + ) +} + func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDefinition, executablePath string) (string, error) { driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition)