feat(agent): support offline driver import via ZIP bundle and local JAR

Add two offline import methods for users behind corporate firewalls:
- ZIP offline bundle: import all drivers + JRE from a single ZIP file
- Single JAR import: import individual driver JARs per database type
This commit is contained in:
t8y2 2026-05-20 09:43:23 +08:00
parent 49a7541450
commit 98e2d03f64
9 changed files with 336 additions and 3 deletions

18
Cargo.lock generated
View File

@ -1757,6 +1757,7 @@ dependencies = [
"tokio",
"tokio-util",
"uuid",
"zip 2.4.2",
]
[[package]]
@ -9745,6 +9746,23 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.14.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zip"
version = "4.6.1"

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue";
import { useI18n } from "vue-i18n";
import { FolderOpen, Trash2, Download, RotateCcw, Loader2, RefreshCw, Check, Clock3 } from "lucide-vue-next";
import { FolderOpen, Trash2, Download, RotateCcw, Loader2, RefreshCw, Check, Clock3, FileUp } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -241,6 +241,50 @@ async function uninstallDriver(dbType: string) {
}
}
const importingZip = ref(false);
async function importOfflineZip() {
if (isWeb || importingZip.value) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: "选择离线驱动包",
multiple: false,
filters: [{ name: "ZIP", extensions: ["zip"] }],
});
if (typeof selected !== "string") return;
importingZip.value = true;
progress.value = null;
try {
const count = await api.importAgentsFromZip(selected);
await refreshAgents();
toast(`离线导入完成,已安装 ${count} 个驱动`);
} catch (e: any) {
toast(`离线导入失败: ${e}`);
} finally {
importingZip.value = false;
progress.value = null;
}
}
async function importDriverJar(dbType: string) {
if (isWeb) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: "选择驱动 JAR 文件",
multiple: false,
filters: [{ name: "JAR", extensions: ["jar"] }],
});
if (typeof selected !== "string") return;
const label = drivers.value.find((d) => d.db_type === dbType)?.label ?? dbType;
try {
await api.importAgentJar(dbType, selected);
await refreshAgents();
toast(`${label} 驱动导入成功`);
} catch (e: any) {
toast(`${label} 驱动导入失败: ${e}`);
}
}
async function reinstallJre(jreKey: string) {
reinstallingJre.value = jreKey;
progress.value = null;
@ -446,6 +490,16 @@ onUnmounted(() => {
<TabsTrigger value="agent">内置驱动</TabsTrigger>
<TabsTrigger value="jdbc">JDBC 驱动</TabsTrigger>
</TabsList>
<Button
variant="ghost"
size="sm"
class="h-7 rounded-full text-xs gap-1 text-muted-foreground"
:disabled="importingZip"
@click="importOfflineZip"
>
<FileUp class="h-3.5 w-3.5" />
{{ importingZip ? "导入中..." : "导入离线包" }}
</Button>
<Button
variant="ghost"
size="sm"
@ -642,6 +696,19 @@ onUnmounted(() => {
<Download class="h-3 w-3 mr-1" />
安装
</Button>
<Button
v-if="
!driver.installed && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)
"
size="sm"
variant="ghost"
class="h-7 w-7 rounded-full text-xs text-muted-foreground"
title="导入本地 JAR"
:disabled="upgradingAll || installing !== null"
@click="importDriverJar(driver.db_type)"
>
<FileUp class="h-3.5 w-3.5" />
</Button>
<template v-else>
<Check class="h-4 w-4 text-green-600" />
<Button

View File

@ -52,6 +52,8 @@ export const uninstallAgent = forward("uninstallAgent");
export const getAgentJavaRuntimeConfig = forward("getAgentJavaRuntimeConfig");
export const setAgentJavaRuntimeConfig = forward("setAgentJavaRuntimeConfig");
export const invalidateAgentRegistryCache = forward("invalidateAgentRegistryCache");
export const importAgentsFromZip = forward("importAgentsFromZip");
export const importAgentJar = forward("importAgentJar");
export const reinstallJre = forward("reinstallJre");
export const uninstallJre = forward("uninstallJre");
export const listenAgentInstallProgress = forward("listenAgentInstallProgress");

View File

@ -178,6 +178,14 @@ export async function invalidateAgentRegistryCache(): Promise<void> {
await post("/api/agents/invalidate-registry-cache", {});
}
export async function importAgentsFromZip(_path: string): Promise<number> {
throw new Error("Offline ZIP import is only available in the desktop app");
}
export async function importAgentJar(_dbType: string, _path: string): Promise<void> {
throw new Error("Local JAR import is only available in the desktop app");
}
export async function reinstallJre(jreKey?: string): Promise<void> {
await post("/api/agents/reinstall-jre", { jreKey });
}

View File

@ -376,6 +376,14 @@ export async function invalidateAgentRegistryCache(): Promise<void> {
return invoke("invalidate_agent_registry_cache");
}
export async function importAgentsFromZip(path: string): Promise<number> {
return invoke("import_agents_from_zip", { path });
}
export async function importAgentJar(dbType: string, path: string): Promise<void> {
return invoke("import_agent_jar_cmd", { dbType, path });
}
export async function reinstallJre(jreKey?: string): Promise<void> {
return invoke("reinstall_jre", { jreKey });
}

View File

@ -33,3 +33,4 @@ csv = "1"
calamine = "0.30.1"
base64 = "0.22"
async-trait = "0.1"
zip = { version = "2", default-features = false, features = ["deflate"] }

View File

@ -1,4 +1,5 @@
use std::path::PathBuf;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::agent_manager::{AgentDriverInfo, AgentManager, AgentRegistry, InstalledDriver, DEFAULT_JRE_KEY};
@ -185,3 +186,192 @@ fn backup_path(dest: &std::path::Path) -> std::path::PathBuf {
let file_name = dest.file_name().and_then(|name| name.to_str()).unwrap_or("download");
dest.with_file_name(format!("{file_name}.backup-{}", uuid::Uuid::new_v4()))
}
// ──────────── Offline import ────────────
#[derive(Debug, Clone, serde::Serialize)]
pub struct OfflineImportProgress {
pub step: String,
pub current: u32,
pub total: u32,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct OfflineImportResult {
pub jre_installed: Vec<String>,
pub drivers_installed: Vec<String>,
pub drivers_skipped: Vec<String>,
}
pub fn import_offline_zip(
am: &AgentManager,
zip_path: &Path,
progress: impl Fn(OfflineImportProgress),
) -> Result<OfflineImportResult, String> {
let file = std::fs::File::open(zip_path).map_err(|e| format!("Failed to open ZIP file: {e}"))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP file: {e}"))?;
let registry = read_registry_from_zip(&mut archive)?;
let platform = AgentManager::current_platform();
let mut local_state = am.load_state();
let mut result =
OfflineImportResult { jre_installed: Vec::new(), drivers_installed: Vec::new(), drivers_skipped: Vec::new() };
let jre_entries: Vec<(String, String)> = (0..archive.len())
.filter_map(|i| {
let entry = archive.by_index(i).ok()?;
let name = entry.name().to_string();
if name.starts_with("jre/") && name.ends_with(".tar.gz") && name.contains(platform) {
let jre_key = extract_jre_key_from_filename(&name)?;
Some((jre_key, name))
} else {
None
}
})
.collect();
let driver_entries: Vec<(String, String)> = (0..archive.len())
.filter_map(|i| {
let entry = archive.by_index(i).ok()?;
let name = entry.name().to_string();
if name.starts_with("drivers/") && name.ends_with(".jar") {
let db_type = extract_db_type_from_filename(&name)?;
Some((db_type, name))
} else {
None
}
})
.collect();
let total = (jre_entries.len() + driver_entries.len()) as u32;
let mut current: u32 = 0;
for (jre_key, entry_name) in &jre_entries {
current += 1;
let jre_version = registry.resolve_jre(jre_key).map(|j| j.version.clone());
let existing_version = local_state.jre_versions.get(jre_key);
if am.is_jre_installed(jre_key) && existing_version == jre_version.as_ref() {
continue;
}
progress(OfflineImportProgress { step: "jre-extract".into(), current, total, label: format!("JRE {jre_key}") });
let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?;
let tmp_archive = am.base_dir().join(format!("jre-offline-{jre_key}.tar.gz"));
{
let mut out =
std::fs::File::create(&tmp_archive).map_err(|e| format!("Failed to create temp file: {e}"))?;
std::io::copy(&mut entry, &mut out).map_err(|e| format!("Failed to extract JRE archive: {e}"))?;
}
let jre_dir = am.jre_dir(jre_key);
if jre_dir.exists() {
std::fs::remove_dir_all(&jre_dir).ok();
}
extract_tar_gz(&tmp_archive, &jre_dir)?;
std::fs::remove_file(&tmp_archive).ok();
if let Some(ver) = jre_version {
local_state.jre_versions.insert(jre_key.clone(), ver);
}
result.jre_installed.push(jre_key.clone());
}
for (db_type, entry_name) in &driver_entries {
current += 1;
if let Some(remote_driver) = registry.drivers.get(db_type) {
if let Some(installed) = local_state.installed_drivers.get(db_type) {
if installed.version != "0.1.0-local"
&& installed.version != "local"
&& !crate::update::is_newer_version(&remote_driver.version, &installed.version)
{
result.drivers_skipped.push(db_type.clone());
continue;
}
}
}
progress(OfflineImportProgress {
step: "driver".into(),
current,
total,
label: AGENT_TYPES
.iter()
.find(|(k, _)| *k == db_type)
.map(|(_, l)| l.to_string())
.unwrap_or_else(|| db_type.clone()),
});
let jar_path = am.driver_jar_path(db_type);
if let Some(parent) = jar_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?;
let mut out = std::fs::File::create(&jar_path).map_err(|e| format!("Failed to write driver JAR: {e}"))?;
std::io::copy(&mut entry, &mut out).map_err(|e| format!("Failed to copy driver JAR: {e}"))?;
let version = registry.drivers.get(db_type).map(|d| d.version.clone()).unwrap_or_else(|| "local".to_string());
let jre_key =
registry.drivers.get(db_type).map(|d| d.jre.clone()).unwrap_or_else(|| DEFAULT_JRE_KEY.to_string());
local_state.installed_drivers.insert(
db_type.clone(),
InstalledDriver { version, installed_at: chrono::Utc::now().to_rfc3339(), jre: jre_key },
);
result.drivers_installed.push(db_type.clone());
}
am.save_state(&local_state)?;
Ok(result)
}
fn read_registry_from_zip(archive: &mut zip::ZipArchive<std::fs::File>) -> Result<AgentRegistry, String> {
let mut entry = archive
.by_name("agent-registry.json")
.map_err(|_| "ZIP 文件中未找到 agent-registry.json请确认这是有效的离线驱动包".to_string())?;
let mut buf = String::new();
entry.read_to_string(&mut buf).map_err(|e| format!("Failed to read agent-registry.json: {e}"))?;
serde_json::from_str(&buf).map_err(|e| format!("Invalid agent-registry.json: {e}"))
}
fn extract_jre_key_from_filename(name: &str) -> Option<String> {
let filename = name.rsplit('/').next()?;
let rest = filename.strip_prefix("jre-")?;
let key = rest.split('-').next()?;
if key.is_empty() {
return None;
}
Some(key.to_string())
}
fn extract_db_type_from_filename(name: &str) -> Option<String> {
let filename = name.rsplit('/').next()?;
let rest = filename.strip_prefix("dbx-agent-")?;
let db_type = rest.strip_suffix(".jar")?;
if db_type.is_empty() {
return None;
}
Some(db_type.to_string())
}
fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), String> {
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let status = std::process::Command::new("tar")
.args(["xzf", &archive.to_string_lossy(), "-C", &dest.to_string_lossy(), "--strip-components=1"])
.status()
.map_err(|e| format!("Failed to extract archive: {e}"))?;
if !status.success() {
return Err("Failed to extract JRE archive".to_string());
}
Ok(())
}
pub fn import_agent_jar(am: &AgentManager, db_type: &str, jar_path: &Path) -> Result<(), String> {
if !jar_path.exists() {
return Err(format!("File not found: {}", jar_path.display()));
}
install_local_agent(am, db_type, jar_path.to_path_buf())
}

View File

@ -7,7 +7,8 @@ use dbx_core::agent_manager::{
};
use dbx_core::agent_service::{
build_agent_list, download_temp_path, fetch_registry, find_local_agent_jar, github_url_to_r2_path,
install_local_agent, invalidate_registry_cache, replace_download,
import_agent_jar, import_offline_zip, install_local_agent, invalidate_registry_cache, replace_download,
OfflineImportProgress,
};
use dbx_core::connection::AppState;
@ -288,6 +289,42 @@ pub async fn invalidate_agent_registry_cache() -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub async fn import_agents_from_zip(
app: tauri::AppHandle,
state: State<'_, Arc<AppState>>,
path: String,
) -> Result<u32, String> {
let am = &state.agent_manager;
let zip_path = std::path::PathBuf::from(&path);
let app_handle = app.clone();
let result = import_offline_zip(am, &zip_path, |p: OfflineImportProgress| {
let _ = app_handle.emit(
"agent-install-progress",
serde_json::json!({
"step": p.step,
"downloaded": p.current as u64,
"total": p.total as u64,
"db_type": p.label,
"current": p.current,
"total_drivers": p.total,
}),
);
})?;
let count = result.drivers_installed.len() as u32;
let _ = app.emit("agent-install-progress", serde_json::json!({ "step": "done" }));
Ok(count)
}
#[tauri::command]
pub async fn import_agent_jar_cmd(
state: State<'_, Arc<AppState>>,
db_type: String,
path: String,
) -> Result<(), String> {
import_agent_jar(&state.agent_manager, &db_type, std::path::Path::new(&path))
}
#[tauri::command]
pub async fn reinstall_jre(
app: tauri::AppHandle,

View File

@ -255,6 +255,8 @@ pub fn run() {
commands::agents::uninstall_jre,
commands::agents::reinstall_jre,
commands::agents::invalidate_agent_registry_cache,
commands::agents::import_agents_from_zip,
commands::agents::import_agent_jar_cmd,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")