From 65567221ac4474548ad429a178d3f51cbf092ec5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 12 May 2026 07:17:28 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(driver):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E6=89=B9=E9=87=8F=E7=AE=A1=E7=90=86=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=80=BB=E5=8C=85=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 驱动管理支持批量安装、重装需更新和删除外置驱动 - 批量任务增加总进度展示,并实时刷新已完成驱动状态 - 后端复用驱动总包下载缓存,支持并发等待和长超时下载 - 开发态优先本地构建 driver-agent,避免发布包 revision 不匹配 - DuckDB 构建自动探测 UCRT64 gcc 工具链 - 驱动总包构建接入 UPX 压缩以降低发布体积 --- .github/workflows/dev-build.yml | 2 + .github/workflows/release.yml | 2 + build-driver-agents.sh | 15 +- frontend/src/App.css | 28 ++ .../src/components/DriverManagerModal.tsx | 410 +++++++++++++++--- internal/app/methods_driver.go | 366 +++++++++++++++- internal/app/methods_driver_version_test.go | 186 +++++++- internal/app/methods_update.go | 15 +- tools/compress-driver-artifact.sh | 133 ++++++ 9 files changed, 1081 insertions(+), 76 deletions(-) create mode 100644 tools/compress-driver-artifact.sh diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 4f70753..39de9ea 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -313,6 +313,7 @@ jobs: -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll" + bash ./tools/compress-driver-artifact.sh "$OUTDIR/duckdb.dll" "$TARGET_PLATFORM" "${{ matrix.os_name }}/duckdb.dll" else CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ -tags "${BUILD_TAGS}" \ @@ -329,6 +330,7 @@ jobs: -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent fi + bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" done # macOS Packaging diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dddbd9d..5f7b910 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -304,6 +304,7 @@ jobs: -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll" + bash ./tools/compress-driver-artifact.sh "$OUTDIR/duckdb.dll" "$TARGET_PLATFORM" "${{ matrix.os_name }}/duckdb.dll" else CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ -tags "${BUILD_TAGS}" \ @@ -320,6 +321,7 @@ jobs: -o "${OUTPUT_PATH}" \ ./cmd/optional-driver-agent fi + bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}" done # macOS Packaging diff --git a/build-driver-agents.sh b/build-driver-agents.sh index f207b4f..3324a10 100755 --- a/build-driver-agents.sh +++ b/build-driver-agents.sh @@ -23,6 +23,8 @@ usage() { --out-dir <目录> 输出目录根路径,默认:dist/driver-agents --bundle-name <文件名> 驱动总包 zip 名称,默认:GoNavi-DriverAgents.zip --strict 任一驱动构建失败即中断(默认失败后继续,最后汇总) + --upx 要求使用 UPX 压缩支持的平台产物(默认 auto:有 upx 则压缩) + --no-upx 禁用 UPX 压缩 -h, --help 显示帮助 示例: @@ -200,6 +202,7 @@ target_platform="" out_root="dist/driver-agents" bundle_name="GoNavi-DriverAgents.zip" strict_mode="false" +upx_mode="${GONAVI_DRIVER_AGENT_UPX:-auto}" while [[ $# -gt 0 ]]; do case "$1" in @@ -223,6 +226,14 @@ while [[ $# -gt 0 ]]; do strict_mode="true" shift ;; + --upx) + upx_mode="required" + shift + ;; + --no-upx) + upx_mode="off" + shift + ;; -h|--help) usage exit 0 @@ -372,10 +383,12 @@ for platform in "${platforms[@]}"; do continue fi + GONAVI_DRIVER_AGENT_UPX="$upx_mode" "$SCRIPT_DIR/tools/compress-driver-artifact.sh" "$output_path" "$platform" "$platform_dir/$asset_name" cp "$output_path" "$bundle_platform_dir/$asset_name" if [[ -n "$duckdb_lib_dir" ]]; then cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL" - cp "$duckdb_lib_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" "$bundle_platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" + GONAVI_DRIVER_AGENT_UPX="$upx_mode" "$SCRIPT_DIR/tools/compress-driver-artifact.sh" "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL" "$platform" "$platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" + cp "$output_dir_abs/$DUCKDB_WINDOWS_SUPPORT_DLL" "$bundle_platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL" built_assets+=("$platform_dir/$DUCKDB_WINDOWS_SUPPORT_DLL") fi built_assets+=("$platform_dir/$asset_name") diff --git a/frontend/src/App.css b/frontend/src/App.css index 8bc0a44..9896bde 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -425,6 +425,29 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check justify-content: flex-end; } +.driver-manager-batch-progress-panel { + display: grid; + gap: 8px; + padding: 12px 14px; + border-radius: 8px; +} + +.driver-manager-batch-progress-header, +.driver-manager-batch-progress-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; + justify-content: space-between; + min-width: 0; +} + +.driver-manager-batch-progress-header span, +.driver-manager-batch-progress-meta span { + min-width: 0; + word-break: break-word; +} + .driver-manager-list-head { display: flex; justify-content: space-between; @@ -555,6 +578,11 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check .driver-manager-card-actions { justify-content: flex-start; } + + .driver-manager-batch-progress-header, + .driver-manager-batch-progress-meta { + justify-content: flex-start; + } } .security-update-action-btn.ant-btn, diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index fd90798..6a71d4b 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -62,6 +62,18 @@ type ProgressState = { }; type DriverActionKind = '' | 'install' | 'remove' | 'local'; +type DriverBatchActionKind = '' | 'install-all' | 'reinstall-updates' | 'remove-all'; + +type DriverBatchProgressState = { + total: number; + completed: number; + success: number; + failed: number; + skipped: number; + currentDriverType: string; + currentDriverName: string; + currentMessage: string; +}; type DriverLogEntry = { time: string; @@ -117,6 +129,29 @@ const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `$ const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000; const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000; const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase(); +const isSlimBuildInstallUnavailable = (row: DriverStatusRow) => (row.message || '').includes('精简构建') && !row.packageInstalled; +const resolveDriverBatchActionLabel = (actionKind: DriverBatchActionKind) => { + switch (actionKind) { + case 'install-all': + return '安装所有驱动'; + case 'reinstall-updates': + return '重装需更新驱动'; + case 'remove-all': + return '删除所有驱动'; + default: + return '批量操作'; + } +}; +const createDriverBatchProgress = (total: number, currentMessage: string): DriverBatchProgressState => ({ + total, + completed: 0, + success: 0, + failed: 0, + skipped: 0, + currentDriverType: '', + currentDriverName: '', + currentMessage, +}); let driverStatusSnapshotCache: { rows: DriverStatusRow[]; downloadDir: string; cachedAt: number } | null = null; let driverNetworkSnapshotCache: { status: DriverNetworkStatus; cachedAt: number } | null = null; @@ -190,6 +225,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const [searchKeyword, setSearchKeyword] = useState(''); const [rows, setRows] = useState([]); const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' }); + const [batchAction, setBatchAction] = useState(''); + const [batchProgress, setBatchProgress] = useState(null); const [progressMap, setProgressMap] = useState>({}); const [operationLogMap, setOperationLogMap] = useState>({}); const [logDriverType, setLogDriverType] = useState(''); @@ -201,6 +238,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); const downloadDirRef = useRef(downloadDir); + const batchBusy = batchDirectoryImporting || batchAction !== '' || actionState.kind !== ''; useEffect(() => { downloadDirRef.current = downloadDir; @@ -584,38 +622,42 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG }, [checkNetworkStatus, open, refreshStatus]); useEffect(() => { - if (!open) { - return; + let off: (() => void) | undefined; + try { + off = EventsOn('driver:download-progress', (event: DriverProgressEvent) => { + if (!event) { + return; + } + const driverType = String(event.driverType || '').trim().toLowerCase(); + const status = event.status; + if (!driverType || !status) { + return; + } + const messageText = String(event.message || '').trim(); + const percent = Math.max(0, Math.min(100, Number(event.percent || 0))); + setProgressMap((prev) => ({ + ...prev, + [driverType]: { + status, + message: messageText, + percent, + }, + })); + const progressText = `${Math.round(percent)}%`; + const statusText = String(status || '').toUpperCase(); + const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`; + const lineSignature = `${statusText}|${messageText || '-'}`; + appendOperationLog(driverType, lineText, lineSignature, 'update-last'); + }); + } catch (error) { + console.warn('Wails API: EventsOn unavailable', error); } - const off = EventsOn('driver:download-progress', (event: DriverProgressEvent) => { - if (!event) { - return; - } - const driverType = String(event.driverType || '').trim().toLowerCase(); - const status = event.status; - if (!driverType || !status) { - return; - } - const messageText = String(event.message || '').trim(); - const percent = Math.max(0, Math.min(100, Number(event.percent || 0))); - setProgressMap((prev) => ({ - ...prev, - [driverType]: { - status, - message: messageText, - percent, - }, - })); - const progressText = `${Math.round(percent)}%`; - const statusText = String(status || '').toUpperCase(); - const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`; - const lineSignature = `${statusText}|${messageText || '-'}`; - appendOperationLog(driverType, lineText, lineSignature, 'update-last'); - }); return () => { - off(); + if (off) { + off(); + } }; - }, [appendOperationLog, open]); + }, [appendOperationLog]); const resolveLocalImportVersion = useCallback((row: DriverStatusRow) => { const options = versionMap[row.type] || []; @@ -627,7 +669,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG return selectedOption?.version || row.pinnedVersion || ''; }, [selectedVersionMap, versionMap]); - const installDriver = useCallback(async (row: DriverStatusRow) => { + const installDriver = useCallback(async ( + row: DriverStatusRow, + actionOptions?: { silentToast?: boolean; skipRefresh?: boolean }, + ) => { setActionState({ driverType: row.type, kind: 'install' }); setProgressMap((prev) => ({ ...prev, @@ -639,15 +684,15 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG })); appendOperationLog(row.type, '[START] 开始自动安装'); try { - let options = versionMap[row.type] || []; - if (options.length === 0) { - options = await loadVersionOptions(row, true); + let versionOptions = versionMap[row.type] || []; + if (versionOptions.length === 0) { + versionOptions = await loadVersionOptions(row, true); } const selectedKey = selectedVersionMap[row.type]; const selectedOption = - options.find((item) => buildVersionOptionKey(item) === selectedKey) || - options.find((item) => item.recommended) || - options[0]; + versionOptions.find((item) => buildVersionOptionKey(item) === selectedKey) || + versionOptions.find((item) => item.recommended) || + versionOptions[0]; const selectedVersion = selectedOption?.version || row.pinnedVersion || ''; const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || ''; @@ -655,13 +700,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG if (!result?.success) { const errText = result?.message || `安装 ${row.name} 失败`; appendOperationLog(row.type, `[ERROR] ${errText}`); - message.error(errText); - return; + if (!actionOptions?.silentToast) { + message.error(errText); + } + return false; } const versionTip = selectedVersion ? `(${selectedVersion})` : ''; appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`); - message.success(`${row.name}${versionTip} 已安装启用`); - refreshStatus(false); + if (!actionOptions?.silentToast) { + message.success(`${row.name}${versionTip} 已安装启用`); + } + if (!actionOptions?.skipRefresh) { + await refreshStatus(false); + } + return true; } finally { setActionState({ driverType: '', kind: '' }); } @@ -829,7 +881,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG setLogModalOpen(true); }, []); - const removeDriver = useCallback(async (row: DriverStatusRow) => { + const removeDriver = useCallback(async ( + row: DriverStatusRow, + options?: { silentToast?: boolean; skipRefresh?: boolean }, + ) => { setActionState({ driverType: row.type, kind: 'remove' }); appendOperationLog(row.type, '[START] 开始移除驱动'); try { @@ -837,17 +892,24 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG 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} 已移除`); + if (!options?.silentToast) { + message.success(`${row.name} 已移除`); + } setProgressMap((prev) => { const next = { ...prev }; delete next[row.type]; return next; }); - refreshStatus(false); + if (!options?.skipRefresh) { + await refreshStatus(false); + } + return true; } finally { setActionState({ driverType: '', kind: '' }); } @@ -938,7 +1000,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG size="small" style={{ width: '100%' }} loading={!!versionLoadingMap[row.type]} - disabled={actionState.driverType === row.type} + disabled={batchBusy || actionState.driverType === row.type} placeholder={options.length > 0 ? '选择驱动版本' : '点击加载版本'} value={selectedKey} options={selectOptions as any} @@ -965,7 +1027,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG if (row.builtIn) { return null; } - const isSlimBuildUnavailable = (row.message || '').includes('精简构建'); + const isSlimBuildUnavailable = isSlimBuildInstallUnavailable(row); const loadingInstallOrRemove = actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove'); const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local'; @@ -977,15 +1039,15 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG } const mainAction = row.needsUpdate ? ( - ) : row.connectable ? ( - ) : ( - ); @@ -993,7 +1055,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG return ( {mainAction} - )} @@ -1313,10 +1572,38 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG setForceOverwriteInstalled(checked)} - disabled={batchDirectoryImporting} + disabled={batchBusy} /> + + + + {batchProgress ? ( +
+
+ {resolveDriverBatchActionLabel(batchAction)} + {batchProgressMessage || '批量任务运行中'} +
+ +
+ 已处理 {batchProgress.completed} / {batchProgress.total} + 成功 {batchProgress.success} + {batchProgress.failed > 0 ? 失败 {batchProgress.failed} : null} + {batchProgress.skipped > 0 ? 跳过 {batchProgress.skipped} : null} + {batchProgress.currentDriverName ? 当前:{batchProgress.currentDriverName} : null} +
+
+ ) : null}
{filterSummaryText} {loading ? 正在刷新状态... : null} diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 477c3f4..ba65d52 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -42,6 +42,18 @@ var ( optionalDriverAgentMetadataProbe = db.ProbeOptionalDriverAgentMetadata ) +type optionalDriverBundleDownloadState struct { + done chan struct{} + path string + err error + finished bool +} + +var ( + optionalDriverBundleDownloadMu sync.Mutex + optionalDriverBundleDownloads = make(map[string]*optionalDriverBundleDownloadState) +) + var errOptionalDriverAgentMetadataUnavailable = errors.New("driver-agent metadata unavailable") // resolveGoBinaryPath 定位 Go 可执行文件,兼容 macOS 图形应用未继承 shell PATH 的场景 by AI.Coding @@ -283,6 +295,9 @@ const ( defaultDriverManifestURLValue = "builtin://manifest" optionalDriverBundleAssetName = "GoNavi-DriverAgents.zip" optionalDriverBundleIndexAssetName = "GoNavi-DriverAgents-Index.json" + optionalDriverBundleDownloadTimeout = 45 * time.Minute + optionalDriverBundleCacheMaxAge = 7 * 24 * time.Hour + optionalDriverBundleCacheMaxFiles = 4 driverManifestCacheTTL = 5 * time.Minute driverReleaseAssetSizeCacheTTL = 30 * time.Minute driverReleaseAssetSizeErrorCacheTTL = 30 * time.Second @@ -3238,7 +3253,7 @@ func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDef return filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(entry.Name), "./")), nil } -func buildOptionalDriverInstallPlanMessage(displayName string, selectedVersion string, forceSourceBuild bool, preferSourceBuildBeforeDownload bool, restrictToExplicitArtifact bool, directURLCount int, bundleURLCount int) string { +func buildOptionalDriverInstallPlanMessage(displayName string, selectedVersion string, forceSourceBuild bool, preferSourceBuildBeforeDownload bool, requireSourceBuildBeforeDownload bool, restrictToExplicitArtifact bool, directURLCount int, bundleURLCount int) string { name := strings.TrimSpace(displayName) if name == "" { name = "驱动" @@ -3251,6 +3266,9 @@ func buildOptionalDriverInstallPlanMessage(displayName string, selectedVersion s if forceSourceBuild { return fmt.Sprintf("准备安装 %s 驱动代理(版本 %s);当前版本仅允许本地源码构建", name, versionText) } + if requireSourceBuildBeforeDownload { + return fmt.Sprintf("准备安装 %s 驱动代理(版本 %s);开发态使用本地源码构建,失败后不使用发布包兜底", name, versionText) + } if preferSourceBuildBeforeDownload { return fmt.Sprintf("准备安装 %s 驱动代理(版本 %s);先尝试本地源码构建,失败后继续下载兜底", name, versionText) } @@ -3290,7 +3308,12 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition) forceSourceBuild := shouldForceSourceBuildForResolvedDownload(driverType, selectedVersion, downloadURL) - preferSourceBuildBeforeDownload := shouldPreferSourceBuildBeforeDownload(driverType, selectedVersion) + buildType := "" + if a != nil { + buildType = currentBuildType(a.ctx) + } + preferSourceBuildBeforeDownload := shouldPreferSourceBuildBeforeDownloadForBuildType(buildType, driverType, selectedVersion) + requireSourceBuildBeforeDownload := shouldRequireSourceBuildBeforeDownloadForBuildType(buildType, driverType, selectedVersion) skipReuseCandidate := shouldSkipReusableAgentCandidate(driverType, selectedVersion) restrictToExplicitArtifact := shouldRestrictToExplicitVersionArtifact(definition, selectedVersion) downloadURLs := []string{} @@ -3301,8 +3324,8 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut bundleURLs = resolveOptionalDriverBundleDownloadURLs() } } - planMessage := buildOptionalDriverInstallPlanMessage(displayName, selectedVersion, forceSourceBuild, preferSourceBuildBeforeDownload, restrictToExplicitArtifact, len(downloadURLs), len(bundleURLs)) - logger.Infof("%s,driver=%s version=%s direct_candidates=%d bundle_candidates=%d force_source_build=%v restrict_explicit=%v prefer_source_first=%v", planMessage, driverType, normalizeVersion(selectedVersion), len(downloadURLs), len(bundleURLs), forceSourceBuild, restrictToExplicitArtifact, preferSourceBuildBeforeDownload) + planMessage := buildOptionalDriverInstallPlanMessage(displayName, selectedVersion, forceSourceBuild, preferSourceBuildBeforeDownload, requireSourceBuildBeforeDownload, restrictToExplicitArtifact, len(downloadURLs), len(bundleURLs)) + logger.Infof("%s,driver=%s version=%s direct_candidates=%d bundle_candidates=%d force_source_build=%v require_source_build=%v restrict_explicit=%v prefer_source_first=%v", planMessage, driverType, normalizeVersion(selectedVersion), len(downloadURLs), len(bundleURLs), forceSourceBuild, requireSourceBuildBeforeDownload, restrictToExplicitArtifact, preferSourceBuildBeforeDownload) info, err := os.Stat(executablePath) if err == nil && !info.IsDir() { @@ -3356,6 +3379,11 @@ func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, execut return fmt.Sprintf("local://go-build/%s-driver-agent", driverType), hash, nil } sourceBuildErr = buildErr + if requireSourceBuildBeforeDownload { + _ = os.Remove(executablePath) + logger.Warnf("开发态本地构建 %s 驱动代理失败,跳过发布包兜底:%v", displayName, buildErr) + return "", "", fmt.Errorf("本地构建失败:%w", buildErr) + } logger.Warnf("预先本地构建 %s 驱动代理失败,将继续尝试下载预编译包:%v", displayName, buildErr) } @@ -3472,22 +3500,23 @@ func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition, return "", "", fmt.Errorf("驱动总包下载地址为空") } - bundleTempPath := executablePath + ".bundle.zip.tmp" - _ = os.Remove(bundleTempPath) - _, err := downloadFileWithHash(trimmedURL, bundleTempPath, func(downloaded, total int64) { + bundlePath, err := acquireOptionalDriverBundlePath(trimmedURL, func(downloaded, total int64) { if a == nil { return } scaledDownloaded, scaledTotal := scaleProgress(downloaded, total, 20, 78) a.emitDriverDownloadProgress(driverType, "downloading", scaledDownloaded, scaledTotal, fmt.Sprintf("下载 %s 驱动总包", displayName)) + }, func() { + if a == nil { + return + } + a.emitDriverDownloadProgress(driverType, "downloading", 20, 100, fmt.Sprintf("等待 %s 驱动总包下载完成", displayName)) }) if err != nil { - _ = os.Remove(bundleTempPath) return "", "", fmt.Errorf("下载驱动总包失败:%w", err) } - defer func() { _ = os.Remove(bundleTempPath) }() - reader, err := zip.OpenReader(bundleTempPath) + reader, err := zip.OpenReader(bundlePath) if err != nil { return "", "", fmt.Errorf("打开驱动总包失败:%w", err) } @@ -3610,6 +3639,11 @@ func buildOptionalDriverAgentFromSource(definition driverDefinition, executableP env = withEnvValue(env, "CGO_ENABLED", "1") } if shouldUseDuckDBWindowsDynamicLibrary(driverType) { + var toolchainErr error + env, toolchainErr = configureDuckDBWindowsCGOToolchainEnv(env) + if toolchainErr != nil { + return "", fmt.Errorf("准备 DuckDB Windows CGO 编译器失败:%w", toolchainErr) + } libDir, cleanup, prepErr := prepareDuckDBWindowsDynamicLibraryForBuild() if prepErr != nil { return "", fmt.Errorf("准备 DuckDB Windows 动态库失败:%w", prepErr) @@ -3676,10 +3710,30 @@ func shouldForceSourceBuildForResolvedDownload(driverType string, selectedVersio } func shouldPreferSourceBuildBeforeDownload(driverType string, selectedVersion string) bool { + return shouldPreferSourceBuildBeforeDownloadForBuildType("", driverType, selectedVersion) +} + +func shouldPreferSourceBuildBeforeDownloadForBuildType(buildType string, driverType string, selectedVersion string) bool { _ = selectedVersion + if shouldPreferDevelopmentDriverAgentSourceBuild(buildType, driverType) { + return true + } return shouldUseDuckDBWindowsDynamicLibrary(driverType) } +func shouldRequireSourceBuildBeforeDownloadForBuildType(buildType string, driverType string, selectedVersion string) bool { + _ = selectedVersion + return shouldPreferDevelopmentDriverAgentSourceBuild(buildType, driverType) +} + +func shouldPreferDevelopmentDriverAgentSourceBuild(buildType string, driverType string) bool { + normalizedBuildType := strings.ToLower(strings.TrimSpace(buildType)) + if normalizedBuildType != "dev" && normalizedBuildType != "development" { + return false + } + return db.IsOptionalGoDriver(driverType) +} + func shouldSkipReusableAgentCandidate(driverType string, selectedVersion string) bool { _ = selectedVersion switch normalizeDriverType(driverType) { @@ -3795,15 +3849,120 @@ func withEnvValue(env []string, key string, value string) []string { return append(env, entry) } +func envValue(env []string, key string) string { + normalizedKey := strings.ToUpper(strings.TrimSpace(key)) + for _, item := range env { + name, value, ok := strings.Cut(item, "=") + if ok && strings.ToUpper(strings.TrimSpace(name)) == normalizedKey { + return value + } + } + return "" +} + func prependPathEnv(env []string, dir string) []string { trimmedDir := strings.TrimSpace(dir) if trimmedDir == "" { return env } - currentPath := os.Getenv("PATH") + currentPath := envValue(env, "PATH") return withEnvValue(env, "PATH", trimmedDir+string(os.PathListSeparator)+currentPath) } +func configureDuckDBWindowsCGOToolchainEnv(env []string) ([]string, error) { + if stdRuntime.GOOS != "windows" || stdRuntime.GOARCH != "amd64" { + return env, nil + } + binDir, err := resolveDuckDBWindowsCGOToolchainBin() + if err != nil { + return env, err + } + env = withEnvValue(env, "CC", filepath.Join(binDir, "gcc.exe")) + env = withEnvValue(env, "CXX", filepath.Join(binDir, "g++.exe")) + env = prependPathEnv(env, binDir) + return env, nil +} + +func resolveDuckDBWindowsCGOToolchainBin() (string, error) { + candidates := duckDBWindowsCGOToolchainBinCandidates() + return resolveDuckDBWindowsCGOToolchainBinFromCandidates(candidates) +} + +func resolveDuckDBWindowsCGOToolchainBinFromCandidates(candidates []string) (string, error) { + seen := make(map[string]struct{}, len(candidates)) + checked := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + binDir := strings.TrimSpace(candidate) + if binDir == "" { + continue + } + cleaned := filepath.Clean(binDir) + key := strings.ToLower(cleaned) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + checked = append(checked, cleaned) + if fileExists(filepath.Join(cleaned, "gcc.exe")) && fileExists(filepath.Join(cleaned, "g++.exe")) { + return cleaned, nil + } + } + + installHint := `请先安装 MSYS2 UCRT64 工具链:winget install --id MSYS2.MSYS2 -e;然后执行 C:\msys64\usr\bin\bash.exe -lc "pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc"` + if len(checked) == 0 { + return "", fmt.Errorf("未找到可用的 gcc.exe/g++.exe;%s", installHint) + } + return "", fmt.Errorf("未找到可用的 gcc.exe/g++.exe,已检查:%s;%s", strings.Join(checked, ", "), installHint) +} + +func duckDBWindowsCGOToolchainBinCandidates() []string { + candidates := make([]string, 0, 12) + if ccDir := executableEnvDir("CC"); ccDir != "" { + candidates = append(candidates, ccDir) + } + if cxxDir := executableEnvDir("CXX"); cxxDir != "" { + candidates = append(candidates, cxxDir) + } + if gccPath, err := exec.LookPath("gcc"); err == nil { + candidates = append(candidates, filepath.Dir(gccPath)) + } + if gxxPath, err := exec.LookPath("g++"); err == nil { + candidates = append(candidates, filepath.Dir(gxxPath)) + } + if prefix := strings.TrimSpace(os.Getenv("MSYSTEM_PREFIX")); prefix != "" { + candidates = append(candidates, filepath.Join(prefix, "bin")) + } + if msys2Location := strings.TrimSpace(os.Getenv("MSYS2_LOCATION")); msys2Location != "" { + candidates = append(candidates, filepath.Join(msys2Location, "ucrt64", "bin")) + } + candidates = append(candidates, `C:\msys64\ucrt64\bin`, `C:\tools\msys64\ucrt64\bin`) + if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" { + candidates = append(candidates, filepath.Join(localAppData, "Programs", "msys64", "ucrt64", "bin")) + } + if programFiles := strings.TrimSpace(os.Getenv("ProgramFiles")); programFiles != "" { + candidates = append(candidates, filepath.Join(programFiles, "msys64", "ucrt64", "bin")) + } + if programFilesX86 := strings.TrimSpace(os.Getenv("ProgramFiles(x86)")); programFilesX86 != "" { + candidates = append(candidates, filepath.Join(programFilesX86, "msys64", "ucrt64", "bin")) + } + return candidates +} + +func executableEnvDir(key string) string { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return "" + } + if filepath.IsAbs(raw) { + return filepath.Dir(raw) + } + resolved, err := exec.LookPath(raw) + if err != nil { + return "" + } + return filepath.Dir(resolved) +} + func prepareDuckDBWindowsDynamicLibraryForBuild() (string, func(), error) { workDir, err := os.MkdirTemp("", "gonavi-duckdb-lib-*") if err != nil { @@ -4110,6 +4269,191 @@ func resolveOptionalDriverBundleDownloadURLs() []string { return candidates } +func optionalDriverBundleCacheDir() (string, error) { + cacheDir := filepath.Join(os.TempDir(), "gonavi-driver-bundle-cache") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", err + } + return cacheDir, nil +} + +func optionalDriverBundleCachePath(bundleURL string) (string, error) { + cacheDir, err := optionalDriverBundleCacheDir() + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(strings.TrimSpace(bundleURL))) + return filepath.Join(cacheDir, hex.EncodeToString(sum[:])+".zip"), nil +} + +func cleanupOptionalDriverBundleCache(keepPaths ...string) { + cacheDir, err := optionalDriverBundleCacheDir() + if err != nil { + return + } + + keep := make(map[string]struct{}, len(keepPaths)+4) + for _, path := range keepPaths { + if strings.TrimSpace(path) != "" { + keep[filepath.Clean(path)] = struct{}{} + } + } + optionalDriverBundleDownloadMu.Lock() + for _, state := range optionalDriverBundleDownloads { + if state != nil && strings.TrimSpace(state.path) != "" { + keep[filepath.Clean(state.path)] = struct{}{} + } + } + optionalDriverBundleDownloadMu.Unlock() + + type cacheFile struct { + path string + modTime time.Time + } + cacheFiles := make([]cacheFile, 0) + now := time.Now() + entries, err := os.ReadDir(cacheDir) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(cacheDir, entry.Name()) + cleanPath := filepath.Clean(path) + if _, ok := keep[cleanPath]; ok { + continue + } + info, statErr := entry.Info() + if statErr != nil { + continue + } + name := strings.ToLower(strings.TrimSpace(entry.Name())) + if strings.HasSuffix(name, ".tmp") { + if now.Sub(info.ModTime()) > 24*time.Hour { + _ = os.Remove(path) + } + continue + } + if !strings.HasSuffix(name, ".zip") { + continue + } + if now.Sub(info.ModTime()) > optionalDriverBundleCacheMaxAge { + _ = os.Remove(path) + continue + } + cacheFiles = append(cacheFiles, cacheFile{path: path, modTime: info.ModTime()}) + } + if len(cacheFiles) <= optionalDriverBundleCacheMaxFiles { + return + } + sort.Slice(cacheFiles, func(i, j int) bool { + return cacheFiles[i].modTime.After(cacheFiles[j].modTime) + }) + for _, item := range cacheFiles[optionalDriverBundleCacheMaxFiles:] { + _ = os.Remove(item.path) + } +} + +func downloadOptionalDriverBundleToCache(bundleURL string, onProgress func(downloaded, total int64)) (string, error) { + cachePath, err := optionalDriverBundleCachePath(bundleURL) + if err != nil { + return "", err + } + tempPath := cachePath + fmt.Sprintf(".%d.tmp", time.Now().UnixNano()) + _ = os.Remove(tempPath) + if _, err := downloadFileWithHashWithTimeout(bundleURL, tempPath, onProgress, optionalDriverBundleDownloadTimeout); err != nil { + _ = os.Remove(tempPath) + return "", err + } + if err := os.Remove(cachePath); err != nil && !os.IsNotExist(err) { + _ = os.Remove(tempPath) + return "", err + } + if err := os.Rename(tempPath, cachePath); err != nil { + _ = os.Remove(tempPath) + return "", err + } + reader, err := zip.OpenReader(cachePath) + if err != nil { + _ = os.Remove(cachePath) + return "", fmt.Errorf("打开驱动总包失败:%w", err) + } + if err := reader.Close(); err != nil { + _ = os.Remove(cachePath) + return "", fmt.Errorf("关闭驱动总包失败:%w", err) + } + cleanupOptionalDriverBundleCache(cachePath) + return cachePath, nil +} + +func acquireOptionalDriverBundlePath(bundleURL string, onProgress func(downloaded, total int64), onWaiting func()) (string, error) { + trimmedURL := strings.TrimSpace(bundleURL) + if trimmedURL == "" { + return "", fmt.Errorf("驱动总包下载地址为空") + } + + for { + optionalDriverBundleDownloadMu.Lock() + state, ok := optionalDriverBundleDownloads[trimmedURL] + if ok { + if state.finished { + path := strings.TrimSpace(state.path) + err := state.err + if err == nil && path != "" && fileExists(path) { + optionalDriverBundleDownloadMu.Unlock() + return path, nil + } + delete(optionalDriverBundleDownloads, trimmedURL) + optionalDriverBundleDownloadMu.Unlock() + continue + } + done := state.done + optionalDriverBundleDownloadMu.Unlock() + if onWaiting != nil { + onWaiting() + } + <-done + optionalDriverBundleDownloadMu.Lock() + path := strings.TrimSpace(state.path) + err := state.err + if err == nil && path != "" && fileExists(path) { + optionalDriverBundleDownloadMu.Unlock() + return path, nil + } + if current, exists := optionalDriverBundleDownloads[trimmedURL]; exists && current == state { + delete(optionalDriverBundleDownloads, trimmedURL) + } + optionalDriverBundleDownloadMu.Unlock() + if err == nil { + err = fmt.Errorf("驱动总包缓存文件不可用") + } + return "", err + } + + state = &optionalDriverBundleDownloadState{done: make(chan struct{})} + optionalDriverBundleDownloads[trimmedURL] = state + optionalDriverBundleDownloadMu.Unlock() + + path, err := downloadOptionalDriverBundleToCache(trimmedURL, onProgress) + optionalDriverBundleDownloadMu.Lock() + state.path = path + state.err = err + state.finished = true + if err != nil { + delete(optionalDriverBundleDownloads, trimmedURL) + } + close(state.done) + optionalDriverBundleDownloadMu.Unlock() + + if err != nil { + return "", err + } + return path, nil + } +} + func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL string, selectedVersion string) []string { candidates := make([]string, 0, 3) seen := make(map[string]struct{}, 3) diff --git a/internal/app/methods_driver_version_test.go b/internal/app/methods_driver_version_test.go index 2535530..9370b57 100644 --- a/internal/app/methods_driver_version_test.go +++ b/internal/app/methods_driver_version_test.go @@ -3,10 +3,14 @@ package app import ( "archive/zip" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" "strings" + "sync" + "sync/atomic" "testing" "time" ) @@ -179,7 +183,7 @@ func TestBuiltinActivatePinnedVersionDoesNotRestrictBundleFallback(t *testing.T) } func TestBuildOptionalDriverInstallPlanMessagePrefersDirectThenBundle(t *testing.T) { - message := buildOptionalDriverInstallPlanMessage("SQL Server", "1.9.6", false, false, false, 1, 2) + message := buildOptionalDriverInstallPlanMessage("SQL Server", "1.9.6", false, false, false, false, 1, 2) if !strings.Contains(message, "先尝试 1 个预编译直链") { t.Fatalf("expected direct-download hint, got %q", message) } @@ -376,6 +380,68 @@ func TestShouldPreferSourceBuildBeforeDownloadDoesNotPreferKingbase(t *testing.T } } +func TestShouldPreferSourceBuildBeforeDownloadForDevelopmentBuild(t *testing.T) { + if !shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "mariadb", "1.9.3") { + t.Fatal("expected development build to prefer local driver-agent source build") + } + if !shouldPreferSourceBuildBeforeDownloadForBuildType("development", "clickhouse", "2.43.1") { + t.Fatal("expected development build alias to prefer local driver-agent source build") + } + if shouldPreferSourceBuildBeforeDownloadForBuildType("production", "mariadb", "1.9.3") { + t.Fatal("expected production build not to prefer source build for MariaDB") + } + if shouldPreferSourceBuildBeforeDownloadForBuildType("dev", "mysql", "") { + t.Fatal("expected built-in drivers not to prefer optional driver-agent source build") + } +} + +func TestShouldRequireSourceBuildBeforeDownloadForDevelopmentBuild(t *testing.T) { + if !shouldRequireSourceBuildBeforeDownloadForBuildType("dev", "duckdb", "2.5.6") { + t.Fatal("expected development build to require local DuckDB driver-agent source build") + } + if !shouldRequireSourceBuildBeforeDownloadForBuildType("development", "mariadb", "1.9.3") { + t.Fatal("expected development build alias to require local driver-agent source build") + } + if shouldRequireSourceBuildBeforeDownloadForBuildType("production", "duckdb", "2.5.6") { + t.Fatal("expected production build to allow DuckDB release bundle fallback") + } + if shouldRequireSourceBuildBeforeDownloadForBuildType("dev", "mysql", "") { + t.Fatal("expected built-in drivers not to require optional driver-agent source build") + } +} + +func TestResolveDuckDBWindowsCGOToolchainBinFromCandidates(t *testing.T) { + binDir := t.TempDir() + writeSelfExecutable(t, filepath.Join(binDir, "gcc.exe")) + writeSelfExecutable(t, filepath.Join(binDir, "g++.exe")) + + got, err := resolveDuckDBWindowsCGOToolchainBinFromCandidates([]string{ + filepath.Join(t.TempDir(), "missing"), + binDir, + }) + if err != nil { + t.Fatalf("expected toolchain bin to resolve: %v", err) + } + if got != filepath.Clean(binDir) { + t.Fatalf("expected %q, got %q", filepath.Clean(binDir), got) + } +} + +func TestPrependPathEnvUsesCurrentEnvPath(t *testing.T) { + basePath := "base-path" + firstPath := "first-path" + secondPath := "second-path" + env := []string{"PATH=" + basePath} + env = prependPathEnv(env, firstPath) + env = prependPathEnv(env, secondPath) + + got := envValue(env, "PATH") + want := strings.Join([]string{secondPath, firstPath, basePath}, string(os.PathListSeparator)) + if got != want { + t.Fatalf("expected PATH %q, got %q", want, got) + } +} + func TestResolveOptionalDriverAgentDownloadURLsIncludesPublishedKingbaseAsset(t *testing.T) { definition, ok := resolveDriverDefinition("kingbase") if !ok { @@ -460,6 +526,85 @@ func TestInstallOptionalDriverAgentFromLocalPathSupportsMongoV1ZipImport(t *test } } +func TestDownloadOptionalDriverAgentFromBundleSharesConcurrentDownload(t *testing.T) { + resetOptionalDriverBundleDownloadCacheForTest(t) + proxySnapshot := currentGlobalProxyConfig() + if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + t.Cleanup(func() { + _, _ = setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy) + }) + + bundlePath := filepath.Join(t.TempDir(), "GoNavi-DriverAgents.zip") + writeZipWithSelfExecutableEntries(t, bundlePath, []string{ + optionalDriverBundleEntryPath("clickhouse"), + optionalDriverBundleEntryPath("mongodb"), + }) + + var requestCount int32 + releaseDownload := make(chan struct{}) + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(func() { + close(releaseDownload) + }) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&requestCount, 1) + <-releaseDownload + http.ServeFile(w, r, bundlePath) + })) + defer server.Close() + defer release() + + errCh := make(chan error, 2) + clickhouseTarget := filepath.Join(t.TempDir(), optionalDriverExecutableBaseName("clickhouse")) + mongodbTarget := filepath.Join(t.TempDir(), optionalDriverExecutableBaseName("mongodb")) + go func() { + _, _, err := downloadOptionalDriverAgentFromBundle( + nil, + driverDefinition{Type: "clickhouse", Name: "ClickHouse"}, + server.URL, + clickhouseTarget, + ) + errCh <- err + }() + + deadline := time.Now().Add(2 * time.Second) + for atomic.LoadInt32(&requestCount) == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if atomic.LoadInt32(&requestCount) != 1 { + t.Fatalf("expected first bundle request to start, got %d", atomic.LoadInt32(&requestCount)) + } + + go func() { + _, _, err := downloadOptionalDriverAgentFromBundle( + nil, + driverDefinition{Type: "mongodb", Name: "MongoDB"}, + server.URL, + mongodbTarget, + ) + errCh <- err + }() + + time.Sleep(100 * time.Millisecond) + if got := atomic.LoadInt32(&requestCount); got != 1 { + t.Fatalf("expected concurrent bundle install to wait for first download, got %d requests", got) + } + release() + + for i := 0; i < 2; i++ { + if err := <-errCh; err != nil { + t.Fatalf("bundle install failed: %v", err) + } + } + if got := atomic.LoadInt32(&requestCount); got != 1 { + t.Fatalf("expected one shared bundle download, got %d requests", got) + } +} + func seedReleaseAssetSizeCache(t *testing.T, cacheKey string, sizeByKey map[string]int64) { t.Helper() @@ -617,6 +762,11 @@ func writeSelfExecutable(t *testing.T, targetPath string) { func writeZipWithSelfExecutable(t *testing.T, zipPath string, entryName string) { t.Helper() + writeZipWithSelfExecutableEntries(t, zipPath, []string{entryName}) +} + +func writeZipWithSelfExecutableEntries(t *testing.T, zipPath string, entryNames []string) { + t.Helper() selfPath, err := os.Executable() if err != nil { @@ -634,14 +784,36 @@ func writeZipWithSelfExecutable(t *testing.T, zipPath string, entryName string) defer file.Close() writer := zip.NewWriter(file) - entry, err := writer.Create(entryName) - if err != nil { - t.Fatalf("create zip entry failed: %v", err) - } - if _, err := entry.Write(content); err != nil { - t.Fatalf("write zip entry failed: %v", err) + for _, entryName := range entryNames { + entry, err := writer.Create(entryName) + if err != nil { + t.Fatalf("create zip entry failed: %v", err) + } + if _, err := entry.Write(content); err != nil { + t.Fatalf("write zip entry failed: %v", err) + } } if err := writer.Close(); err != nil { t.Fatalf("close zip writer failed: %v", err) } } + +func resetOptionalDriverBundleDownloadCacheForTest(t *testing.T) { + t.Helper() + reset := func() { + optionalDriverBundleDownloadMu.Lock() + paths := make([]string, 0, len(optionalDriverBundleDownloads)) + for _, state := range optionalDriverBundleDownloads { + if state != nil && strings.TrimSpace(state.path) != "" { + paths = append(paths, state.path) + } + } + optionalDriverBundleDownloads = make(map[string]*optionalDriverBundleDownloadState) + optionalDriverBundleDownloadMu.Unlock() + for _, path := range paths { + _ = os.Remove(path) + } + } + reset() + t.Cleanup(reset) +} diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 4dfd27c..2325498 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -31,9 +31,9 @@ const ( ) var ( - updateFetchLatestRelease = fetchLatestRelease - updateFetchReleaseSHA256 = fetchReleaseSHA256 - updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") } + updateFetchLatestRelease = fetchLatestRelease + updateFetchReleaseSHA256 = fetchReleaseSHA256 + updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") } ) type updateState struct { @@ -642,7 +642,14 @@ func (w *downloadProgressWriter) Write(p []byte) (int, error) { } func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) { - client := newHTTPClientWithGlobalProxy(10 * time.Minute) + return downloadFileWithHashWithTimeout(url, filePath, onProgress, 10*time.Minute) +} + +func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downloaded, total int64), timeout time.Duration) (string, error) { + if timeout <= 0 { + timeout = 10 * time.Minute + } + client := newHTTPClientWithGlobalProxy(timeout) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return "", err diff --git a/tools/compress-driver-artifact.sh b/tools/compress-driver-artifact.sh new file mode 100644 index 0000000..bbf2ee7 --- /dev/null +++ b/tools/compress-driver-artifact.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + tools/compress-driver-artifact.sh [label] + +Environment: + GONAVI_DRIVER_AGENT_UPX=auto|on|off|required + +The default mode is auto: compress supported driver artifacts when upx is +available, and skip cleanly otherwise. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +artifact_path="${1:-}" +platform="${2:-}" +label="${3:-$artifact_path}" +mode="$(printf '%s' "${GONAVI_DRIVER_AGENT_UPX:-auto}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + +if [[ -z "$artifact_path" || -z "$platform" ]]; then + usage >&2 + exit 2 +fi + +if [[ ! -f "$artifact_path" ]]; then + echo "⚠️ UPX 跳过:文件不存在:$artifact_path" + exit 0 +fi + +case "$mode" in + ""|auto) + mode="auto" + ;; + 1|true|yes|on|enabled) + mode="on" + ;; + required|strict) + mode="required" + ;; + 0|false|no|off|disabled) + echo "ℹ️ UPX 已关闭:$label" + exit 0 + ;; + *) + echo "❌ GONAVI_DRIVER_AGENT_UPX 参数无效:$mode" >&2 + exit 2 + ;; +esac + +goos="${platform%%/*}" +goarch="${platform##*/}" + +case "$goos/$goarch" in + linux/amd64|linux/arm64|windows/amd64) + ;; + *) + echo "ℹ️ UPX 跳过不支持的平台:$label ($platform)" + exit 0 + ;; +esac + +if ! command -v upx >/dev/null 2>&1; then + if [[ "$mode" == "required" ]]; then + echo "❌ 未找到 upx,无法压缩:$label" >&2 + exit 1 + fi + echo "⚠️ 未找到 upx,跳过压缩:$label" + exit 0 +fi + +file_size_bytes() { + local path="$1" + if stat -c%s "$path" >/dev/null 2>&1; then + stat -c%s "$path" + return + fi + if stat -f%z "$path" >/dev/null 2>&1; then + stat -f%z "$path" + return + fi + wc -c <"$path" | tr -d '[:space:]' +} + +format_size_mb() { + local bytes="${1:-0}" + awk -v b="$bytes" 'BEGIN { printf "%.2fMB", b / 1024 / 1024 }' +} + +backup_path="$(mktemp "${TMPDIR:-/tmp}/gonavi-upx-artifact.XXXXXX")" +cp "$artifact_path" "$backup_path" +cleanup() { + rm -f "$backup_path" +} +trap cleanup EXIT + +before_bytes="$(file_size_bytes "$artifact_path")" +echo "🗜️ UPX 压缩驱动产物:$label" + +if ! upx --best --lzma --force "$artifact_path" >/dev/null 2>&1; then + cp "$backup_path" "$artifact_path" + if [[ "$mode" == "required" ]]; then + echo "❌ UPX 压缩失败:$label" >&2 + exit 1 + fi + echo "⚠️ UPX 压缩失败,已恢复原文件:$label" + exit 0 +fi + +if ! upx -t "$artifact_path" >/dev/null 2>&1; then + cp "$backup_path" "$artifact_path" + if [[ "$mode" == "required" ]]; then + echo "❌ UPX 校验失败:$label" >&2 + exit 1 + fi + echo "⚠️ UPX 校验失败,已恢复原文件:$label" + exit 0 +fi + +after_bytes="$(file_size_bytes "$artifact_path")" +if [[ "$after_bytes" -lt "$before_bytes" ]]; then + saved_bytes=$((before_bytes - after_bytes)) + echo "✅ UPX 压缩完成:$(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes"),减少 $(format_size_mb "$saved_bytes")" +else + echo "ℹ️ UPX 压缩完成:$(format_size_mb "$before_bytes") -> $(format_size_mb "$after_bytes")" +fi