mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-16 05:47:38 +08:00
✨ feat(driver): 完善驱动批量管理并优化总包安装
- 驱动管理支持批量安装、重装需更新和删除外置驱动 - 批量任务增加总进度展示,并实时刷新已完成驱动状态 - 后端复用驱动总包下载缓存,支持并发等待和长超时下载 - 开发态优先本地构建 driver-agent,避免发布包 revision 不匹配 - DuckDB 构建自动探测 UCRT64 gcc 工具链 - 驱动总包构建接入 UPX 压缩以降低发布体积
This commit is contained in:
2
.github/workflows/dev-build.yml
vendored
2
.github/workflows/dev-build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
133
tools/compress-driver-artifact.sh
Normal file
133
tools/compress-driver-artifact.sh
Normal 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
|
||||
Reference in New Issue
Block a user