diff --git a/.github/workflows/agents-release.yml b/.github/workflows/agents-release.yml index 759286c93..3f75a3802 100644 --- a/.github/workflows/agents-release.yml +++ b/.github/workflows/agents-release.yml @@ -551,3 +551,67 @@ jobs: aws s3 cp "$f" "$R2_BUCKET/$key" --endpoint-url "$R2_ENDPOINT" echo "Uploaded $(basename $f)" done + + sync-release-to-cnb: + name: Sync agents releases to CNB + needs: release + if: ${{ always() && needs.release.result == 'success' }} + runs-on: ubuntu-latest + continue-on-error: true + strategy: + fail-fast: false + matrix: + tag: ["${{ github.ref_name }}", "agents-latest"] + steps: + - name: Sync release assets to CNB + env: + TAG_NAME: ${{ matrix.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + CNB_REPOSITORY: dbxio.com/dbx + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + run: | + set -euo pipefail + wget -q https://cnb.cool/znb/mpgrm/-/releases/download/v0.1.0/mpgrm_linux_amd64.tar.gz + tar -xf mpgrm_linux_amd64.tar.gz + chmod +x mpgrm + ./mpgrm releases sync \ + --repo "https://github.com/${GITHUB_REPOSITORY}.git" \ + --target-repo "https://cnb.cool/${CNB_REPOSITORY}.git" \ + --tags "${TAG_NAME}" + + sync-release-to-atomgit: + name: Sync agents releases to AtomGit + needs: release + if: ${{ always() && needs.release.result == 'success' }} + runs-on: ubuntu-latest + continue-on-error: true + strategy: + fail-fast: false + matrix: + tag: ["${{ github.ref_name }}", "agents-latest"] + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Sync release assets to AtomGit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ matrix.tag }} + GITHUB_REPOSITORY: ${{ github.repository }} + ATOMGIT_TOKEN: ${{ secrets.ATOMGIT_TOKEN }} + ATOMGIT_REPOSITORY: t8y2/dbx + run: | + set -euo pipefail + git fetch origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + git remote add atomgit "https://t8y2:${ATOMGIT_TOKEN}@atomgit.com/t8y2/dbx.git" + git push atomgit "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}" --force + mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets" + gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \ + --json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \ + > "$RUNNER_TEMP/github-release/release.json" + gh release download "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/release-assets" --clobber + node .github/scripts/sync-atomgit-release.mjs \ + --github-release "$RUNNER_TEMP/github-release/release.json" \ + --assets-dir "$RUNNER_TEMP/release-assets" \ + ${{ matrix.tag == 'agents-latest' && '--replace-assets' || '' }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 838b09f5a..87fd86895 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2994,7 +2994,7 @@ export default { updateNotificationsEnabled: "Enable update reminders", updateNotificationsEnabledDescription: "When disabled, DBX will not automatically check app or driver updates or show update badges. Manual checks are still available.", updateDownloadSource: "Update download source", - updateDownloadSourceDescription: "Choose where in-app update installers are downloaded from. The official source is recommended; CNB or AtomGit can be faster on mainland China networks.", + updateDownloadSourceDescription: "Choose where app updates, database agents, drivers, and managed JREs are downloaded from. The official source is recommended; CNB or AtomGit can be faster on mainland China networks.", updateDownloadSourceOfficial: "Official source (recommended)", updateDownloadSourceCnb: "CNB", updateDownloadSourceAtomgit: "AtomGit", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 6f37c4656..23f7e6b34 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2993,7 +2993,7 @@ export default withEnglishFallback({ updateNotificationsEnabled: "启用更新提醒", updateNotificationsEnabledDescription: "关闭后,DBX 不会自动检查应用和驱动更新,也不会显示更新红点;仍可手动检查更新。", updateDownloadSource: "更新下载源", - updateDownloadSourceDescription: "选择应用内更新安装包的下载来源。官方源为推荐选项,CNB 或 AtomGit 适合国内网络环境。", + updateDownloadSourceDescription: "选择应用更新、数据库 Agent、驱动和托管 JRE 的下载来源。官方源为推荐选项,CNB 或 AtomGit 适合国内网络环境。", updateDownloadSourceOfficial: "官方源(推荐)", updateDownloadSourceCnb: "CNB", updateDownloadSourceAtomgit: "AtomGit", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index a1822d694..66681515a 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -1,6 +1,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import type * as TauriModule from "@/lib/backend/tauri"; import { appendDebugLog } from "@/lib/backend/debugLog"; +import { useSettingsStore } from "@/stores/settingsStore"; // --------------------------------------------------------------------------- // Lazy backend resolution (avoids top-level await) @@ -76,15 +77,24 @@ export const installJdbcPlugin = forward("installJdbcPlugin"); export const installJdbcPluginLocal = forward("installJdbcPluginLocal"); export const uninstallJdbcPlugin = forward("uninstallJdbcPlugin"); export const listInstalledAgentsLocal = forward("listInstalledAgentsLocal"); -export const listInstalledAgents = forward("listInstalledAgents"); +export async function listInstalledAgents() { + const backend = await getBackend(); + return backend.listInstalledAgents(useSettingsStore().editorSettings.updateDownloadSource); +} export const isAgentInstalled = forward("isAgentInstalled"); export const getDriverStoreUsage = forward("getDriverStoreUsage"); export const clearDriverDownloadCache = forward("clearDriverDownloadCache"); export const getDriverRuntimeSummary = forward("getDriverRuntimeSummary"); export const stopDriverRuntime = forward("stopDriverRuntime"); export const restartDriverRuntime = forward("restartDriverRuntime"); -export const installAgent = forward("installAgent"); -export const upgradeAllAgents = forward("upgradeAllAgents"); +export async function installAgent(dbType: string) { + const backend = await getBackend(); + return backend.installAgent(dbType, useSettingsStore().editorSettings.updateDownloadSource); +} +export async function upgradeAllAgents() { + const backend = await getBackend(); + return backend.upgradeAllAgents(useSettingsStore().editorSettings.updateDownloadSource); +} export const checkAgentUpdateBlockers = forward("checkAgentUpdateBlockers"); export const uninstallAgent = forward("uninstallAgent"); export const getAgentJavaRuntimeConfig = forward("getAgentJavaRuntimeConfig"); @@ -92,7 +102,10 @@ 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 async function reinstallJre(jreKey?: string) { + const backend = await getBackend(); + return backend.reinstallJre(jreKey, useSettingsStore().editorSettings.updateDownloadSource); +} export const uninstallJre = forward("uninstallJre"); export const listenAgentInstallProgress = forward("listenAgentInstallProgress"); export const loadSavedSqlLibrary = forward("loadSavedSqlLibrary"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 0cdb3dcfe..2890c0f36 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -342,7 +342,7 @@ export async function listInstalledAgentsLocal(): Promise { return get("/api/agents/installed-local"); } -export async function listInstalledAgents(): Promise { +export async function listInstalledAgents(_source?: UpdateDownloadSource): Promise { return get("/api/agents/installed"); } @@ -370,11 +370,11 @@ export async function restartDriverRuntime(runtimeId: string): Promise { await post("/api/agents/runtime/restart", { runtimeId }); } -export async function installAgent(dbType: string): Promise { +export async function installAgent(dbType: string, _source?: UpdateDownloadSource): Promise { await post("/api/agents/install", { dbType }); } -export async function upgradeAllAgents(): Promise { +export async function upgradeAllAgents(_source?: UpdateDownloadSource): Promise { return post("/api/agents/upgrade-all", {}); } @@ -427,7 +427,7 @@ export async function importAgentJar(dbType: string, pathOrFile: string | File): if (!uploadRes.ok) throw new Error(await uploadRes.text()); } -export async function reinstallJre(jreKey?: string): Promise { +export async function reinstallJre(jreKey?: string, _source?: UpdateDownloadSource): Promise { await post("/api/agents/reinstall-jre", { jreKey }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index d842cdd59..4909d25ab 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1188,8 +1188,8 @@ export async function listInstalledAgentsLocal(): Promise { return invoke("list_installed_agents_local"); } -export async function listInstalledAgents(): Promise { - return invoke("list_installed_agents"); +export async function listInstalledAgents(source?: UpdateDownloadSource): Promise { + return invoke("list_installed_agents", { source }); } export async function isAgentInstalled(dbType: string): Promise { @@ -1216,12 +1216,12 @@ export async function restartDriverRuntime(runtimeId: string): Promise { return invoke("restart_driver_runtime", { runtimeId }); } -export async function installAgent(dbType: string): Promise { - return invoke("install_agent", { dbType }); +export async function installAgent(dbType: string, source?: UpdateDownloadSource): Promise { + return invoke("install_agent", { dbType, source }); } -export async function upgradeAllAgents(): Promise { - return invoke("upgrade_all_agents"); +export async function upgradeAllAgents(source?: UpdateDownloadSource): Promise { + return invoke("upgrade_all_agents", { source }); } export async function checkAgentUpdateBlockers(dbTypes: string[]): Promise { @@ -1258,8 +1258,8 @@ export async function importAgentJar(dbType: string, path: string | File): Promi return invoke("import_agent_jar_cmd", { dbType, path }); } -export async function reinstallJre(jreKey?: string): Promise { - return invoke("reinstall_jre", { jreKey }); +export async function reinstallJre(jreKey?: string, source?: UpdateDownloadSource): Promise { + return invoke("reinstall_jre", { jreKey, source }); } export async function uninstallJre(jreKey: string): Promise { diff --git a/crates/dbx-core/src/agent_service.rs b/crates/dbx-core/src/agent_service.rs index 26283adc8..1e99f67bc 100644 --- a/crates/dbx-core/src/agent_service.rs +++ b/crates/dbx-core/src/agent_service.rs @@ -7,6 +7,7 @@ use crate::agent_catalog; use crate::agent_manager::{ AgentDriverInfo, AgentManager, AgentRegistry, InstalledDriver, JavaRuntimeMode, DEFAULT_JRE_KEY, }; +use crate::DownloadSource; /// Number of attempts to delete a JRE directory before giving up (Windows /// experiences transient `ERROR_ACCESS_DENIED` when java.exe is still mapped @@ -131,8 +132,9 @@ fn replace_old_jre_dir(am: &AgentManager, path: &Path) -> Result const REGISTRY_PATH: &str = "https://github.com/t8y2/dbx/releases/download/agents-latest/agent-registry.json"; const REGISTRY_R2_PATH: &str = "agents/agent-registry.json"; -static REGISTRY_CACHE: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| tokio::sync::Mutex::new(None)); +static REGISTRY_CACHE: std::sync::LazyLock< + tokio::sync::Mutex>, +> = std::sync::LazyLock::new(|| tokio::sync::Mutex::new(std::collections::HashMap::new())); #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct AgentProgressEvent { @@ -323,9 +325,13 @@ pub fn install_local_agent(am: &AgentManager, db_type: &str, source: PathBuf) -> } pub async fn fetch_registry() -> Result { + fetch_registry_from(DownloadSource::Official).await +} + +pub async fn fetch_registry_from(source: DownloadSource) -> Result { { let cache = REGISTRY_CACHE.lock().await; - if let Some((ts, registry)) = cache.as_ref() { + if let Some((ts, registry)) = cache.get(&source) { if ts.elapsed() < std::time::Duration::from_secs(300) { return Ok(registry.clone()); } @@ -335,16 +341,40 @@ pub async fn fetch_registry() -> Result { .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|err| format!("Failed to create HTTP client: {err}"))?; - let resp = crate::race_download(&client, REGISTRY_PATH, REGISTRY_R2_PATH, "dbx-agent-manager") + let resp = open_download_response(&client, source, REGISTRY_PATH, REGISTRY_R2_PATH, "dbx-agent-manager") .await .map_err(|err| format!("Failed to fetch agent registry: {err}"))?; let registry: AgentRegistry = resp.json().await.map_err(|err| format!("Failed to parse registry: {err}"))?; - *REGISTRY_CACHE.lock().await = Some((std::time::Instant::now(), registry.clone())); + REGISTRY_CACHE.lock().await.insert(source, (std::time::Instant::now(), registry.clone())); Ok(registry) } +async fn open_download_response( + client: &reqwest::Client, + source: DownloadSource, + github_url: &str, + r2_path: &str, + user_agent: &str, +) -> Result { + let mut errors = Vec::new(); + for url in source.download_candidate_urls(github_url, r2_path)? { + match client + .get(&url) + .header(reqwest::header::USER_AGENT, user_agent) + .header(reqwest::header::ACCEPT_ENCODING, "identity") + .send() + .await + .and_then(|response| response.error_for_status()) + { + Ok(response) => return Ok(response), + Err(error) => errors.push(format!("{url}: {error}")), + } + } + Err(errors.join("; ")) +} + pub async fn invalidate_registry_cache() { - *REGISTRY_CACHE.lock().await = None; + REGISTRY_CACHE.lock().await.clear(); } pub async fn install_agent_driver( @@ -352,14 +382,31 @@ pub async fn install_agent_driver( db_type: &str, progress: impl Fn(AgentProgressEvent), ) -> Result<(), String> { - install_agent_driver_with_batch(am, db_type, &progress, None, None).await + install_agent_driver_from(am, db_type, DownloadSource::Official, progress).await +} + +pub async fn install_agent_driver_from( + am: &AgentManager, + db_type: &str, + source: DownloadSource, + progress: impl Fn(AgentProgressEvent), +) -> Result<(), String> { + install_agent_driver_with_batch(am, db_type, source, &progress, None, None).await } pub async fn upgrade_all_agent_drivers( am: &AgentManager, progress: impl Fn(AgentProgressEvent), ) -> Result { - let registry = fetch_registry().await?; + upgrade_all_agent_drivers_from(am, DownloadSource::Official, progress).await +} + +pub async fn upgrade_all_agent_drivers_from( + am: &AgentManager, + source: DownloadSource, + progress: impl Fn(AgentProgressEvent), +) -> Result { + let registry = fetch_registry_from(source).await?; let agents = build_agent_list(am, Some(®istry)); let updatable: Vec<&AgentDriverInfo> = agents.iter().filter(|agent| agent.update_available).collect(); let total = updatable.len() as u32; @@ -369,6 +416,7 @@ pub async fn upgrade_all_agent_drivers( match install_agent_driver_from_registry( am, ®istry, + source, &agent.db_type, &progress, Some((index + 1) as u32), @@ -439,7 +487,16 @@ pub async fn reinstall_agent_jre( jre_key: &str, progress: impl Fn(AgentProgressEvent), ) -> Result<(), String> { - let registry = fetch_registry().await?; + reinstall_agent_jre_from(am, jre_key, DownloadSource::Official, progress).await +} + +pub async fn reinstall_agent_jre_from( + am: &AgentManager, + jre_key: &str, + source: DownloadSource, + progress: impl Fn(AgentProgressEvent), +) -> Result<(), String> { + let registry = fetch_registry_from(source).await?; let jre_info = registry.resolve_jre(jre_key).ok_or_else(|| format!("No JRE definition for version: {jre_key}"))?; let platform = AgentManager::current_platform(); let platform_jre = jre_info @@ -451,6 +508,7 @@ pub async fn reinstall_agent_jre( am, &progress, "jre", + source, &platform_jre.url, &r2_path_with_cache_buster(&github_url_to_r2_path(&platform_jre.url, "jre"), &jre_info.version), &jre_archive, @@ -497,19 +555,23 @@ pub fn import_agents_from_zip( async fn install_agent_driver_with_batch( am: &AgentManager, db_type: &str, + source: DownloadSource, progress: &impl Fn(AgentProgressEvent), current: Option, total_drivers: Option, ) -> Result<(), String> { - match fetch_registry().await { + match fetch_registry_from(source).await { Ok(registry) => { - match install_agent_driver_from_registry(am, ®istry, db_type, progress, current, total_drivers).await { + match install_agent_driver_from_registry(am, ®istry, source, db_type, progress, current, total_drivers) + .await + { Ok(()) => Ok(()), Err(registry_err) => { if let Some(local_jar) = find_local_agent_jar(db_type) { install_local_agent_with_registry_jre( am, ®istry, + source, db_type, local_jar, progress, @@ -538,6 +600,7 @@ async fn install_agent_driver_with_batch( async fn ensure_jre_from_registry( am: &AgentManager, registry: &AgentRegistry, + source: DownloadSource, jre_key: &str, db_type: &str, progress: &impl Fn(AgentProgressEvent), @@ -560,6 +623,7 @@ async fn ensure_jre_from_registry( am, progress, "jre", + source, &platform_jre.url, &r2_path_with_cache_buster(&github_url_to_r2_path(&platform_jre.url, "jre"), &jre_info.version), &jre_archive, @@ -584,6 +648,7 @@ async fn ensure_jre_from_registry( async fn install_local_agent_with_registry_jre( am: &AgentManager, registry: &AgentRegistry, + source: DownloadSource, db_type: &str, local_jar: PathBuf, progress: &impl Fn(AgentProgressEvent), @@ -592,7 +657,7 @@ async fn install_local_agent_with_registry_jre( ) -> Result<(), String> { let jre_key = DEFAULT_JRE_KEY; if jre_needs_install(am, registry, jre_key) { - ensure_jre_from_registry(am, registry, jre_key, db_type, progress, current, total_drivers).await?; + ensure_jre_from_registry(am, registry, source, jre_key, db_type, progress, current, total_drivers).await?; } install_local_agent(am, db_type, local_jar)?; if let Some(jre_info) = registry.resolve_jre(jre_key) { @@ -608,6 +673,7 @@ async fn install_local_agent_with_registry_jre( async fn install_agent_driver_from_registry( am: &AgentManager, registry: &AgentRegistry, + source: DownloadSource, db_type: &str, progress: &impl Fn(AgentProgressEvent), current: Option, @@ -615,8 +681,17 @@ async fn install_agent_driver_from_registry( ) -> Result<(), String> { let Some(driver) = agent_registry_driver(registry, db_type) else { if let Some(local_jar) = find_local_agent_jar(db_type) { - install_local_agent_with_registry_jre(am, registry, db_type, local_jar, progress, current, total_drivers) - .await?; + install_local_agent_with_registry_jre( + am, + registry, + source, + db_type, + local_jar, + progress, + current, + total_drivers, + ) + .await?; return Ok(()); } return Err(format!("Unknown driver type: {db_type}")); @@ -628,7 +703,7 @@ async fn install_agent_driver_from_registry( let needs_jre = requires_java_runtime && jre_needs_install(am, registry, jre_key); if needs_jre { - ensure_jre_from_registry(am, registry, jre_key, db_type, progress, current, total_drivers).await?; + ensure_jre_from_registry(am, registry, source, jre_key, db_type, progress, current, total_drivers).await?; } let (artifact, target_path, is_native_artifact) = if let Some(native) = native_artifact { @@ -650,6 +725,7 @@ async fn install_agent_driver_from_registry( am, progress, "driver", + source, &artifact.url, &r2_path_with_cache_buster(&github_url_to_r2_path(&artifact.url, "driver"), &driver.version), &target_path, @@ -706,6 +782,7 @@ async fn download_with_progress( am: &AgentManager, progress: &impl Fn(AgentProgressEvent), step: &str, + source: DownloadSource, url: &str, r2_path: &str, dest: &std::path::Path, @@ -764,6 +841,7 @@ async fn download_with_progress( let (mut resp, resumed, source_url) = match open_agent_download_response( &client, + source, url, r2_path, "dbx-agent-manager", @@ -847,6 +925,7 @@ async fn download_with_progress( async fn open_agent_download_response( client: &reqwest::Client, + source: DownloadSource, github_url: &str, r2_path: &str, user_agent: &str, @@ -855,7 +934,7 @@ async fn open_agent_download_response( resume_source: Option<&str>, ) -> Result<(reqwest::Response, bool, String), String> { let mut errors = Vec::new(); - for candidate_url in crate::download_candidate_urls(github_url, r2_path) { + for candidate_url in source.download_candidate_urls(github_url, r2_path)? { if resume_from > 0 && resume_source.is_some_and(|source| source != candidate_url) { continue; } @@ -1448,7 +1527,17 @@ mod agent_registry_install_tests { write_cached_driver_download(&manager, db_type, version, native_url, &native_path, native_bytes); let progress = |_| {}; - install_agent_driver_from_registry(&manager, ®istry, db_type, &progress, None, None).await.unwrap(); + install_agent_driver_from_registry( + &manager, + ®istry, + DownloadSource::Official, + db_type, + &progress, + None, + None, + ) + .await + .unwrap(); assert_eq!(std::fs::read(&native_path).unwrap(), native_bytes); assert!(!cache_path.exists()); @@ -1474,8 +1563,17 @@ mod agent_registry_install_tests { .unwrap(); let progress = |_| {}; - let err = - install_agent_driver_from_registry(&manager, ®istry, db_type, &progress, None, None).await.unwrap_err(); + let err = install_agent_driver_from_registry( + &manager, + ®istry, + DownloadSource::Official, + db_type, + &progress, + None, + None, + ) + .await + .unwrap_err(); assert!(err.contains("invalid or corrupt")); assert!(cache_path.exists()); diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 624fe2ae3..62d604875 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -66,6 +66,43 @@ pub mod update; pub mod xlsx_export; pub const R2_CDN_BASE: &str = "https://dl.dbxio.com/"; +pub const GITHUB_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/t8y2/dbx/releases/download/"; +pub const CNB_RELEASE_DOWNLOAD_PREFIX: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/download/"; +pub const ATOMGIT_RELEASE_DOWNLOAD_PREFIX: &str = "https://atomgit.com/t8y2/dbx/releases/download/"; + +#[derive(Clone, Copy, Debug, Default, serde::Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum DownloadSource { + #[default] + Official, + Cnb, + Atomgit, +} + +impl DownloadSource { + pub fn download_candidate_urls(self, github_url: &str, r2_path: &str) -> Result, String> { + match self { + Self::Official => Ok(download_candidate_urls(github_url, r2_path)), + Self::Cnb => Ok(vec![ + rewrite_github_release_url(github_url, CNB_RELEASE_DOWNLOAD_PREFIX)?, + format!("{R2_CDN_BASE}{r2_path}"), + ]), + Self::Atomgit => Ok(vec![ + rewrite_github_release_url(github_url, ATOMGIT_RELEASE_DOWNLOAD_PREFIX)?, + format!("{R2_CDN_BASE}{r2_path}"), + ]), + } + } +} + +fn rewrite_github_release_url(url: &str, target_prefix: &str) -> Result { + if url.starts_with(target_prefix) { + return Ok(url.to_string()); + } + url.strip_prefix(GITHUB_RELEASE_DOWNLOAD_PREFIX) + .map(|path| format!("{target_prefix}{path}")) + .ok_or_else(|| format!("Unsupported DBX release download URL: {url}")) +} pub fn download_candidate_urls(github_url: &str, r2_path: &str) -> Vec { vec![format!("{R2_CDN_BASE}{r2_path}"), github_url.to_string()] @@ -109,7 +146,7 @@ pub async fn race_download( #[cfg(test)] mod tests { - use super::download_candidate_urls; + use super::{download_candidate_urls, DownloadSource}; #[test] fn download_candidates_exclude_third_party_github_proxy() { @@ -126,4 +163,23 @@ mod tests { ] ); } + + #[test] + fn mirror_download_candidates_rewrite_release_urls() { + let github_url = "https://github.com/t8y2/dbx/releases/download/agents-latest/agent-registry.json"; + assert_eq!( + DownloadSource::Cnb.download_candidate_urls(github_url, "agents/agent-registry.json").unwrap(), + vec![ + "https://cnb.cool/dbxio.com/dbx/-/releases/download/agents-latest/agent-registry.json", + "https://dl.dbxio.com/agents/agent-registry.json", + ] + ); + assert_eq!( + DownloadSource::Atomgit.download_candidate_urls(github_url, "agents/agent-registry.json").unwrap(), + vec![ + "https://atomgit.com/t8y2/dbx/releases/download/agents-latest/agent-registry.json", + "https://dl.dbxio.com/agents/agent-registry.json", + ] + ); + } } diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index dd17eff0d..8ff69d7f4 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -4,13 +4,14 @@ use tauri::{Emitter, State}; use dbx_core::agent_manager::{AgentDriverInfo, DriverStoreUsage, JavaRuntimeConfig, JavaRuntimeMode, DEFAULT_JRE_KEY}; use dbx_core::agent_service::{ - build_agent_list, clear_agent_download_cache, fetch_registry, import_agent_jar, - import_agents_from_zip as import_agents_from_zip_core, install_agent_driver, invalidate_registry_cache, - reinstall_agent_jre, uninstall_agent_driver, uninstall_agent_jre, upgrade_all_agent_drivers, AgentProgressEvent, - UpgradeAllAgentDriversResult, + build_agent_list, clear_agent_download_cache, fetch_registry_from, import_agent_jar, + import_agents_from_zip as import_agents_from_zip_core, install_agent_driver_from, invalidate_registry_cache, + reinstall_agent_jre_from, uninstall_agent_driver, uninstall_agent_jre, upgrade_all_agent_drivers_from, + AgentProgressEvent, UpgradeAllAgentDriversResult, }; use dbx_core::connection::AppState; use dbx_core::driver_runtime::DriverRuntimeSummary; +use dbx_core::DownloadSource; #[derive(Debug, Clone, serde::Serialize)] pub struct AgentUpdateBlocker { @@ -24,8 +25,11 @@ pub async fn list_installed_agents_local(state: State<'_, Arc>) -> Res } #[tauri::command] -pub async fn list_installed_agents(state: State<'_, Arc>) -> Result, String> { - let registry = fetch_registry().await.ok(); +pub async fn list_installed_agents( + state: State<'_, Arc>, + source: Option, +) -> Result, String> { + let registry = fetch_registry_from(source.unwrap_or_default()).await.ok(); Ok(build_agent_list(&state.agent_manager, registry.as_ref())) } @@ -64,24 +68,31 @@ pub async fn install_agent( app: tauri::AppHandle, state: State<'_, Arc>, db_type: String, + source: Option, ) -> Result<(), String> { ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?; let app_handle = app.clone(); - install_agent_driver(&state.agent_manager, &db_type, move |event| emit_agent_progress(&app_handle, event)).await + install_agent_driver_from(&state.agent_manager, &db_type, source.unwrap_or_default(), move |event| { + emit_agent_progress(&app_handle, event) + }) + .await } #[tauri::command] pub async fn upgrade_all_agents( app: tauri::AppHandle, state: State<'_, Arc>, + source: Option, ) -> Result { - let registry = fetch_registry().await?; + let source = source.unwrap_or_default(); + let registry = fetch_registry_from(source).await?; let agents = build_agent_list(&state.agent_manager, Some(®istry)); let updatable: Vec = agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect(); ensure_no_agent_update_blockers(state.inner().as_ref(), &updatable).await?; let app_handle = app.clone(); - upgrade_all_agent_drivers(&state.agent_manager, move |event| emit_agent_progress(&app_handle, event)).await + upgrade_all_agent_drivers_from(&state.agent_manager, source, move |event| emit_agent_progress(&app_handle, event)) + .await } #[tauri::command] @@ -172,10 +183,14 @@ pub async fn reinstall_jre( app: tauri::AppHandle, state: State<'_, Arc>, jre_key: Option, + source: Option, ) -> Result<(), String> { let key = jre_key.as_deref().unwrap_or(DEFAULT_JRE_KEY); let app_handle = app.clone(); - reinstall_agent_jre(&state.agent_manager, key, move |event| emit_agent_progress(&app_handle, event)).await + reinstall_agent_jre_from(&state.agent_manager, key, source.unwrap_or_default(), move |event| { + emit_agent_progress(&app_handle, event) + }) + .await } fn emit_agent_progress(app: &tauri::AppHandle, event: AgentProgressEvent) { diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index ea80cac06..222e8b444 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -12,6 +12,7 @@ const OFFICIAL_UPDATE_ENDPOINTS: [&str; 2] = [ "https://dl.dbxio.com/releases/latest/latest.json", "https://github.com/t8y2/dbx/releases/latest/download/latest.json", ]; +const R2_LATEST_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/latest/"; const CNB_RELEASE_DOWNLOAD_PREFIX: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/download/"; const GITHUB_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/t8y2/dbx/releases/download/"; const ATOMGIT_RELEASE_DOWNLOAD_PREFIX: &str = "https://atomgit.com/t8y2/dbx/releases/download/"; @@ -46,12 +47,18 @@ impl UpdateDownloadSource { Self::Cnb => { let version = latest_version.ok_or_else(|| "Latest version is required for CNB updates.".to_string())?; - Ok(vec![format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{}/latest.json", tag_version(version))]) + Ok(vec![ + format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{}/latest.json", tag_version(version)), + OFFICIAL_UPDATE_ENDPOINTS[0].to_string(), + ]) } Self::Atomgit => { let version = latest_version.ok_or_else(|| "Latest version is required for AtomGit updates.".to_string())?; - Ok(vec![format!("{ATOMGIT_RELEASE_DOWNLOAD_PREFIX}{}/latest.json", tag_version(version))]) + Ok(vec![ + format!("{ATOMGIT_RELEASE_DOWNLOAD_PREFIX}{}/latest.json", tag_version(version)), + OFFICIAL_UPDATE_ENDPOINTS[0].to_string(), + ]) } } } @@ -78,6 +85,18 @@ impl UpdateDownloadSource { Self::Official => None, } } + + fn r2_fallback_url(&self, url: &str) -> Result, String> { + if matches!(self, Self::Official) || url.starts_with(R2_LATEST_RELEASE_DOWNLOAD_PREFIX) { + return Ok(None); + } + let filename = url + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| format!("Unsupported update download URL for {} source: {url}", self.label()))?; + Ok(Some(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"))) + } } fn tag_version(version: &str) -> String { @@ -136,6 +155,12 @@ pub async fn download_and_install_update( if let Some(download_url) = source.rewrite_download_url(update.download_url.as_str())? { update.download_url = download_url.parse().map_err(|e| format!("Invalid CNB update download URL: {e}"))?; } + if !update_url_is_available(update.download_url.as_str()).await { + if let Some(fallback_url) = source.r2_fallback_url(update.download_url.as_str())? { + println!("[DBX updater] {} asset unavailable; falling back to R2: {fallback_url}", source.label()); + update.download_url = fallback_url.parse().map_err(|e| format!("Invalid R2 update download URL: {e}"))?; + } + } println!("[DBX updater] downloading from {} URL: {}", source.label(), update.download_url); let downloaded = Arc::new(AtomicU64::new(0)); @@ -159,11 +184,25 @@ pub async fn download_and_install_update( .map_err(|e| format!("Failed to download and install update: {e}")) } +async fn update_url_is_available(url: &str) -> bool { + let client = match reqwest::Client::builder().timeout(std::time::Duration::from_secs(10)).build() { + Ok(client) => client, + Err(_) => return false, + }; + // Request only the first byte because some release hosts do not implement HEAD consistently. + client + .get(url) + .header(reqwest::header::RANGE, "bytes=0-0") + .send() + .await + .is_ok_and(|response| response.status().is_success()) +} + #[cfg(test)] mod tests { use super::{ tag_version, UpdateDownloadSource, ATOMGIT_RELEASE_DOWNLOAD_PREFIX, CNB_RELEASE_DOWNLOAD_PREFIX, - OFFICIAL_UPDATE_ENDPOINTS, + OFFICIAL_UPDATE_ENDPOINTS, R2_LATEST_RELEASE_DOWNLOAD_PREFIX, }; #[test] @@ -181,13 +220,22 @@ mod tests { #[test] fn builds_cnb_update_endpoint_for_tag() { let endpoints = UpdateDownloadSource::Cnb.endpoints(Some("0.5.39")).unwrap(); - assert_eq!(endpoints, vec![format!("{CNB_RELEASE_DOWNLOAD_PREFIX}v0.5.39/latest.json")]); + assert_eq!( + endpoints, + vec![format!("{CNB_RELEASE_DOWNLOAD_PREFIX}v0.5.39/latest.json"), OFFICIAL_UPDATE_ENDPOINTS[0].to_string()] + ); } #[test] fn builds_atomgit_update_endpoint_for_tag() { let endpoints = UpdateDownloadSource::Atomgit.endpoints(Some("0.5.44")).unwrap(); - assert_eq!(endpoints, vec![format!("{ATOMGIT_RELEASE_DOWNLOAD_PREFIX}v0.5.44/latest.json")]); + assert_eq!( + endpoints, + vec![ + format!("{ATOMGIT_RELEASE_DOWNLOAD_PREFIX}v0.5.44/latest.json"), + OFFICIAL_UPDATE_ENDPOINTS[0].to_string(), + ] + ); } #[test] @@ -223,4 +271,12 @@ mod tests { .unwrap(); assert_eq!(download_url, None); } + + #[test] + fn builds_r2_fallback_for_mirror_asset() { + let fallback = UpdateDownloadSource::Atomgit + .r2_fallback_url("https://atomgit.com/t8y2/dbx/releases/download/v0.5.44/DBX_0.5.44_x64.dmg") + .unwrap(); + assert_eq!(fallback, Some(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.44_x64.dmg"))); + } }