feat(driver): 完善驱动批量管理并优化总包安装

- 驱动管理支持批量安装、重装需更新和删除外置驱动

- 批量任务增加总进度展示,并实时刷新已完成驱动状态

- 后端复用驱动总包下载缓存,支持并发等待和长超时下载

- 开发态优先本地构建 driver-agent,避免发布包 revision 不匹配

- DuckDB 构建自动探测 UCRT64 gcc 工具链

- 驱动总包构建接入 UPX 压缩以降低发布体积
This commit is contained in:
Syngnat
2026-05-12 07:17:28 +08:00
parent 0f891be026
commit 65567221ac
9 changed files with 1081 additions and 76 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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,

View File

@@ -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<DriverStatusRow[]>([]);
const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' });
const [batchAction, setBatchAction] = useState<DriverBatchActionKind>('');
const [batchProgress, setBatchProgress] = useState<DriverBatchProgressState | null>(null);
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
const [logDriverType, setLogDriverType] = useState('');
@@ -201,6 +238,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
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 ? (
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
<Button type="primary" icon={<DownloadOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
) : row.connectable ? (
<Button danger icon={<DeleteOutlined />} loading={loadingInstallOrRemove} onClick={() => removeDriver(row)}>
<Button danger icon={<DeleteOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => removeDriver(row)}>
</Button>
) : (
<Button type="primary" icon={<DownloadOutlined />} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
<Button type="primary" icon={<DownloadOutlined />} disabled={batchBusy} loading={loadingInstallOrRemove} onClick={() => installDriver(row)}>
</Button>
);
@@ -993,7 +1055,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
return (
<Space size={8} wrap className="driver-manager-card-actions">
{mainAction}
<Button icon={<FileSearchOutlined />} loading={loadingLocal} onClick={() => installDriverFromLocalFile(row)}>
<Button icon={<FileSearchOutlined />} disabled={batchBusy} loading={loadingLocal} onClick={() => installDriverFromLocalFile(row)}>
{DRIVER_LOCAL_IMPORT_BUTTON_LABEL}
</Button>
<Button type={hasLogs ? 'default' : 'text'} disabled={!hasLogs} onClick={() => openDriverLog(row.type)}>
@@ -1044,6 +1106,203 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
notEnabled: optionalRows.filter((row) => !row.connectable && !row.packageInstalled).length,
};
}, [rows]);
const reinstallableRows = useMemo(() => rows.filter((row) => !row.builtIn && row.needsUpdate), [rows]);
const installableRows = useMemo(
() => rows.filter((row) => !row.builtIn && !row.connectable),
[rows],
);
const removableRows = useMemo(
() => rows.filter((row) => !row.builtIn && (row.connectable || row.packageInstalled)),
[rows],
);
const batchProgressPercent = useMemo(() => {
if (!batchProgress || batchProgress.total <= 0) {
return 0;
}
const currentProgress = batchProgress.currentDriverType
? progressMap[batchProgress.currentDriverType]
: undefined;
const shouldUseCurrentProgress = batchAction === 'install-all' || batchAction === 'reinstall-updates';
const currentContribution = shouldUseCurrentProgress && currentProgress && currentProgress.status !== 'error'
? Math.max(0, Math.min(100, Number(currentProgress.percent || 0))) / 100
: 0;
const completed = Math.max(0, Math.min(batchProgress.completed, batchProgress.total));
const percent = ((completed + currentContribution) / batchProgress.total) * 100;
return Math.max(0, Math.min(100, Math.round(percent)));
}, [batchAction, batchProgress, progressMap]);
const activeBatchDriverProgress = (batchAction === 'install-all' || batchAction === 'reinstall-updates') && batchProgress?.currentDriverType
? progressMap[batchProgress.currentDriverType]
: undefined;
const batchProgressMessage = activeBatchDriverProgress?.message || batchProgress?.currentMessage || '';
const runBatchInstall = useCallback(async (
targetRows: DriverStatusRow[],
actionKind: DriverBatchActionKind,
emptyMessage: string,
successLabel: string,
) => {
if (targetRows.length === 0) {
message.info(emptyMessage);
return;
}
setBatchAction(actionKind);
setBatchProgress(createDriverBatchProgress(targetRows.length, `准备${successLabel}`));
let successCount = 0;
let failCount = 0;
let slimSkipCount = 0;
try {
for (const row of targetRows) {
if (isSlimBuildInstallUnavailable(row)) {
slimSkipCount += 1;
appendOperationLog(row.type, '[WARN] 当前发行包为精简构建,已跳过自动安装');
setBatchProgress((prev) => {
if (!prev) {
return prev;
}
const completed = Math.min(prev.total, prev.completed + 1);
return {
...prev,
completed,
skipped: prev.skipped + 1,
currentDriverType: '',
currentDriverName: '',
currentMessage: `已跳过 ${row.name}`,
};
});
continue;
}
setBatchProgress((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
currentDriverType: row.type,
currentDriverName: row.name,
currentMessage: `正在${successLabel}${row.name}`,
};
});
const ok = await installDriver(row, { silentToast: true, skipRefresh: true });
if (ok) {
successCount += 1;
await refreshStatus(false, { showLoading: false });
} else {
failCount += 1;
}
setBatchProgress((prev) => {
if (!prev) {
return prev;
}
const completed = Math.min(prev.total, prev.completed + 1);
return {
...prev,
completed,
success: prev.success + (ok ? 1 : 0),
failed: prev.failed + (ok ? 0 : 1),
currentDriverType: '',
currentDriverName: '',
currentMessage: ok ? `已完成 ${row.name}` : `失败 ${row.name}`,
};
});
}
await refreshStatus(false);
} finally {
setBatchAction('');
setBatchProgress(null);
}
const skipTip = slimSkipCount > 0 ? `,精简版跳过 ${slimSkipCount}` : '';
if (failCount === 0) {
message.success(`${successLabel}完成:成功 ${successCount}${skipTip}`);
return;
}
if (successCount > 0) {
message.warning(`${successLabel}完成:成功 ${successCount},失败 ${failCount}${skipTip}`);
return;
}
message.error(`${successLabel}失败:失败 ${failCount}${skipTip}`);
}, [appendOperationLog, installDriver, refreshStatus]);
const reinstallNeededDrivers = useCallback(async () => {
await runBatchInstall(reinstallableRows, 'reinstall-updates', '当前没有需要重装的外置驱动', '重装需要更新的驱动');
}, [reinstallableRows, runBatchInstall]);
const installAllDrivers = useCallback(async () => {
await runBatchInstall(installableRows, 'install-all', '当前没有需要安装或启用的外置驱动', '安装所有驱动');
}, [installableRows, runBatchInstall]);
const removeAllDrivers = useCallback(() => {
if (removableRows.length === 0) {
message.info('当前没有可删除的外置驱动');
return;
}
Modal.confirm({
title: '删除所有已安装外置驱动?',
content: `将移除 ${removableRows.length} 个外置驱动包,后续连接对应数据源前需要重新安装。`,
okText: '删除所有',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
setBatchAction('remove-all');
setBatchProgress(createDriverBatchProgress(removableRows.length, '准备删除所有驱动'));
let successCount = 0;
let failCount = 0;
try {
for (const row of removableRows) {
setBatchProgress((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
currentDriverType: row.type,
currentDriverName: row.name,
currentMessage: `正在删除:${row.name}`,
};
});
const ok = await removeDriver(row, { silentToast: true, skipRefresh: true });
if (ok) {
successCount += 1;
await refreshStatus(false, { showLoading: false });
} else {
failCount += 1;
}
setBatchProgress((prev) => {
if (!prev) {
return prev;
}
const completed = Math.min(prev.total, prev.completed + 1);
return {
...prev,
completed,
success: prev.success + (ok ? 1 : 0),
failed: prev.failed + (ok ? 0 : 1),
currentDriverType: '',
currentDriverName: '',
currentMessage: ok ? `已完成 ${row.name}` : `删除失败 ${row.name}`,
};
});
}
await refreshStatus(false);
} finally {
setBatchAction('');
setBatchProgress(null);
}
if (failCount === 0) {
message.success(`删除所有驱动完成:成功 ${successCount}`);
return;
}
if (successCount > 0) {
message.warning(`删除所有驱动完成:成功 ${successCount},失败 ${failCount}`);
return;
}
message.error(`删除所有驱动失败:失败 ${failCount}`);
},
});
}, [refreshStatus, removableRows, removeDriver]);
const renderDriverCard = (row: DriverStatusRow) => {
const progress = resolveDriverProgress(row);
@@ -1165,7 +1424,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
</Button>
<Button key="close" type="primary" onClick={onClose}>
{batchBusy ? '后台运行' : '关闭'}
</Button>
</Space>
)}
@@ -1313,10 +1572,38 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
<Switch
checked={forceOverwriteInstalled}
onChange={(checked) => setForceOverwriteInstalled(checked)}
disabled={batchDirectoryImporting}
disabled={batchBusy}
/>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={batchBusy || installableRows.length === 0}
loading={batchAction === 'install-all'}
onClick={() => void installAllDrivers()}
>
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={batchBusy || reinstallableRows.length === 0}
loading={batchAction === 'reinstall-updates'}
onClick={() => void reinstallNeededDrivers()}
>
</Button>
<Button
danger
icon={<DeleteOutlined />}
disabled={batchBusy || removableRows.length === 0}
loading={batchAction === 'remove-all'}
onClick={() => void removeAllDrivers()}
>
</Button>
<Button
icon={<FolderOpenOutlined />}
disabled={batchBusy}
onClick={() => void openDriverDirectory()}
>
@@ -1324,12 +1611,29 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
<Button
icon={<FolderOpenOutlined />}
loading={batchDirectoryImporting}
disabled={batchBusy && !batchDirectoryImporting}
onClick={() => void installDriversFromDirectory()}
>
</Button>
</Space>
</div>
{batchProgress ? (
<div className="driver-manager-batch-progress-panel" style={managerSectionStyle}>
<div className="driver-manager-batch-progress-header">
<Text strong>{resolveDriverBatchActionLabel(batchAction)}</Text>
<Text type="secondary">{batchProgressMessage || '批量任务运行中'}</Text>
</div>
<Progress percent={batchProgressPercent} status="active" />
<div className="driver-manager-batch-progress-meta">
<Text type="secondary"> {batchProgress.completed} / {batchProgress.total}</Text>
<Text type="secondary"> {batchProgress.success}</Text>
{batchProgress.failed > 0 ? <Text type="danger"> {batchProgress.failed}</Text> : null}
{batchProgress.skipped > 0 ? <Text type="secondary"> {batchProgress.skipped}</Text> : null}
{batchProgress.currentDriverName ? <Text type="secondary">{batchProgress.currentDriverName}</Text> : null}
</div>
</div>
) : null}
<div className="driver-manager-list-head">
<Text type="secondary">{filterSummaryText}</Text>
{loading ? <Text type="secondary">...</Text> : null}

View File

@@ -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("%sdriver=%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("%sdriver=%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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
tools/compress-driver-artifact.sh <file> <GOOS/GOARCH> [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