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:
parent
49a7541450
commit
98e2d03f64
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue