From 76e2d33cd3e9c6c5f6dce428db29b9f7e2ff8695 Mon Sep 17 00:00:00 2001 From: eryajf Date: Fri, 26 Jun 2026 22:14:30 +0800 Subject: [PATCH] feat(update): add configurable update download source --- .../editor/EditorSettingsDialog.vue | 36 ++++ apps/desktop/src/composables/useAppUpdater.ts | 47 +++-- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/http.ts | 5 + apps/desktop/src/lib/tauri.ts | 11 ++ .../stores/__tests__/settingsStore.spec.ts | 9 + apps/desktop/src/stores/settingsStore.ts | 10 ++ packages/app-tests/appUpdater.test.ts | 20 ++- src-tauri/src/commands/update.rs | 169 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + 12 files changed, 300 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index f84a57d13..f09722cba 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -34,6 +34,7 @@ import { type DesktopIconTheme, type InterfaceLayout, type DisconnectTabHandlingMode, + type UpdateDownloadSource, type CustomThemeColors, type CustomTheme, } from "@/stores/settingsStore"; @@ -226,6 +227,7 @@ const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize); const editExportRowLimitEnabled = ref(settingsStore.editorSettings.exportRowLimitEnabled); const editExportRowLimit = ref(settingsStore.editorSettings.exportRowLimit); const editQueryExportKeysetOptimizationEnabled = ref(settingsStore.editorSettings.queryExportKeysetOptimizationEnabled); +const editUpdateDownloadSource = ref(settingsStore.editorSettings.updateDownloadSource); const editToolbarItems = ref({ ...settingsStore.editorSettings.toolbarItems }); const systemFonts = ref([]); const systemFontsLoading = ref(false); @@ -493,6 +495,7 @@ watch( editExportRowLimitEnabled.value = settingsStore.editorSettings.exportRowLimitEnabled; editExportRowLimit.value = settingsStore.editorSettings.exportRowLimit; editQueryExportKeysetOptimizationEnabled.value = settingsStore.editorSettings.queryExportKeysetOptimizationEnabled; + editUpdateDownloadSource.value = settingsStore.editorSettings.updateDownloadSource; editToolbarItems.value = { ...settingsStore.editorSettings.toolbarItems }; editSnippets.value = settingsStore.editorSettings.snippets.map((s) => ({ ...s })); } @@ -553,6 +556,7 @@ function hasChanges(): boolean { editExportRowLimitEnabled.value !== settingsStore.editorSettings.exportRowLimitEnabled || editExportRowLimit.value !== settingsStore.editorSettings.exportRowLimit || editQueryExportKeysetOptimizationEnabled.value !== settingsStore.editorSettings.queryExportKeysetOptimizationEnabled || + editUpdateDownloadSource.value !== settingsStore.editorSettings.updateDownloadSource || JSON.stringify(editToolbarItems.value) !== JSON.stringify(settingsStore.editorSettings.toolbarItems) || JSON.stringify(normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value)) !== JSON.stringify(settingsStore.editorSettings.sidebarHiddenTablePrefixes) || JSON.stringify(editSnippets.value) !== JSON.stringify(settingsStore.editorSettings.snippets) @@ -597,6 +601,7 @@ async function persistSettings() { exportRowLimitEnabled: editExportRowLimitEnabled.value, exportRowLimit: editExportRowLimit.value, queryExportKeysetOptimizationEnabled: editQueryExportKeysetOptimizationEnabled.value, + updateDownloadSource: editUpdateDownloadSource.value, toolbarItems: { ...editToolbarItems.value }, snippets: editSnippets.value, }); @@ -675,6 +680,8 @@ function resetDefaultsForTab(tab: SettingsCategory) { editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts); } else if (tab === "snippets") { editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s })); + } else if (tab === "about") { + editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource; } } @@ -719,6 +726,7 @@ function resetAllDefaults() { editExportRowLimitEnabled.value = DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled; editExportRowLimit.value = DEFAULT_EDITOR_SETTINGS.exportRowLimit; editQueryExportKeysetOptimizationEnabled.value = DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled; + editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource; editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems }; editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s })); } @@ -904,6 +912,10 @@ function onLocaleChange(v: any) { if (typeof v === "string") void setLocale(v as Locale); } +function onUpdateDownloadSourceChange(v: any) { + if (v === "official" || v === "cnb") editUpdateDownloadSource.value = v; +} + function setSidebarObjectDisplay(value: "grouped" | "simple") { editSidebarObjectDisplay.value = value; } @@ -3342,6 +3354,24 @@ watch( +
+
+
+ +

{{ t("settings.updateDownloadSourceDescription") }}

+
+ +
+
+
+ +
diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts index d99eb9b27..86846f4f9 100644 --- a/apps/desktop/src/composables/useAppUpdater.ts +++ b/apps/desktop/src/composables/useAppUpdater.ts @@ -3,6 +3,9 @@ import { useI18n } from "vue-i18n"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { useToast } from "@/composables/useToast"; import * as api from "@/lib/api"; +import { useSettingsStore } from "@/stores/settingsStore"; +import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore"; +import type { UpdateDownloadProgress } from "@/lib/tauri"; export function shouldOpenUpdateDialog(options: { silent?: boolean }) { return options.silent !== true; @@ -12,6 +15,23 @@ export function canDownloadAndInstallUpdate(info: api.UpdateInfo | null, isDeskt return isDesktop && info?.update_available === true && info.portable_mode !== true; } +export function normalizeUpdateDownloadSource(value: unknown): SettingsUpdateDownloadSource { + return value === "cnb" ? "cnb" : "official"; +} + +export function tagVersion(version: string): string { + const trimmed = version.trim(); + return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; +} + +export function resolveUpdateReleaseUrl(info: api.UpdateInfo | null, source: unknown, fallbackUrl: string): string { + const normalizedSource = normalizeUpdateDownloadSource(source); + if (normalizedSource === "cnb" && info?.latest_version) { + return `https://cnb.cool/dbxio.com/dbx/-/releases/tag/${tagVersion(info.latest_version)}`; + } + return info?.release_url || fallbackUrl; +} + export async function resolveUpdaterProxy(): Promise { if (!isTauriRuntime()) return undefined; try { @@ -25,6 +45,7 @@ export async function resolveUpdaterProxy(): Promise { export function useAppUpdater() { const { t } = useI18n(); const { toast } = useToast(); + const settingsStore = useSettingsStore(); const checkingUpdates = ref(false); const updateInfo = ref(null); @@ -78,7 +99,7 @@ export function useAppUpdater() { } function openLatestRelease() { - const url = updateInfo.value?.release_url || latestReleaseUrl; + const url = resolveUpdateReleaseUrl(updateInfo.value, settingsStore.editorSettings.updateDownloadSource, latestReleaseUrl); openUrl(url); } @@ -90,27 +111,21 @@ export function useAppUpdater() { } isDownloadingUpdate.value = true; downloadProgress.value = 0; + let unlisten: (() => void) | undefined; + const latestVersion = updateInfo.value?.latest_version; try { - const { check } = await import("@tauri-apps/plugin-updater"); - const proxy = await resolveUpdaterProxy(); - const update = await check(proxy ? { proxy } : undefined); - if (!update) return; - let totalBytes = 0; - let downloadedBytes = 0; - await update.downloadAndInstall((event) => { - if (event.event === "Started" && event.data.contentLength) { - totalBytes = event.data.contentLength; - } else if (event.event === "Progress") { - downloadedBytes += event.data.chunkLength; - downloadProgress.value = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0; - } else if (event.event === "Finished") { - downloadProgress.value = 100; - } + const { listen } = await import("@tauri-apps/api/event"); + unlisten = await listen("update-download-progress", (event) => { + const total = event.payload.total ?? 0; + downloadProgress.value = total > 0 ? Math.round((event.payload.downloaded / total) * 100) : 0; }); + await api.downloadAndInstallUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), latestVersion); + downloadProgress.value = 100; updateReady.value = true; } catch (e: any) { toast(t("updates.downloadFailed", { error: e?.message || String(e) }), 5000); } finally { + unlisten?.(); isDownloadingUpdate.value = false; } } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ec876a59f..ca7f31a23 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2482,6 +2482,10 @@ export default { closeActionMinimize: "Minimize to tray", 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 can be faster on mainland China networks.", + updateDownloadSourceOfficial: "Official source (recommended)", + updateDownloadSourceCnb: "CNB", debugLoggingEnabled: "Enable debug logs", debugLoggingEnabledDescription: "Record more detailed local user-side diagnostic logs while troubleshooting reports. Disabled by default.", debugLogsCopy: "Copy logs", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fda9ad108..7e2132887 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2506,6 +2506,10 @@ export default { closeActionMinimize: "最小化到托盘", updateNotificationsEnabled: "启用更新提醒", updateNotificationsEnabledDescription: "关闭后,DBX 不会自动检查应用和驱动更新,也不会显示更新红点;仍可手动检查更新。", + updateDownloadSource: "更新下载源", + updateDownloadSourceDescription: "选择应用内更新安装包的下载来源。官方源为推荐选项,CNB 适合国内网络环境。", + updateDownloadSourceOfficial: "官方源(推荐)", + updateDownloadSourceCnb: "CNB", debugLoggingEnabled: "启用调试日志", debugLoggingEnabledDescription: "开启后在本机记录更详细的用户侧诊断日志,反馈问题时可临时开启;默认关闭。", debugLogsCopy: "复制日志", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 4340c7397..2e73bad3e 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -393,6 +393,7 @@ export const checkMcpServerStatus = forward("checkMcpServerStatus"); export const installMcpServer = forward("installMcpServer"); export const checkForUpdates = forward("checkForUpdates"); export const getSystemProxyUrl = forward("getSystemProxyUrl"); +export const downloadAndInstallUpdate = forward("downloadAndInstallUpdate"); export const getAppVersion = forward("getAppVersion"); // Layout diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 358a80313..a25b3383b 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -49,6 +49,7 @@ import type { DriverInstallProgress, JavaRuntimeConfig, UpdateInfo, + UpdateDownloadSource, RedisDatabaseInfo, RedisValue, RedisScanResult, @@ -1810,6 +1811,10 @@ export async function getSystemProxyUrl(): Promise { return null; } +export async function downloadAndInstallUpdate(_source: UpdateDownloadSource, _latestVersion?: string): Promise { + throw new Error("In-app update installation is only available in the desktop app."); +} + export async function getAppVersion(): Promise { const res: { version: string } = await get("/api/version"); return res.version; diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 20a7ef016..a44769a39 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1103,6 +1103,13 @@ export interface UpdateInfo { release_notes: string; } +export type UpdateDownloadSource = "official" | "cnb"; + +export interface UpdateDownloadProgress { + downloaded: number; + total: number | null; +} + export interface McpServerStatus { installed: boolean; npm_available: boolean; @@ -1132,6 +1139,10 @@ export async function getSystemProxyUrl(): Promise { return invoke("get_system_proxy_url"); } +export async function downloadAndInstallUpdate(source: UpdateDownloadSource, latestVersion?: string): Promise { + return invoke("download_and_install_update", { source, latestVersion }); +} + export async function getAppVersion(): Promise { const { getVersion } = await import("@tauri-apps/api/app"); return getVersion(); diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index b725c7ec5..7784aaa24 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -9,4 +9,13 @@ describe("normalizeEditorSettings", () => { it("preserves disabled automatic table aliases", () => { expect(normalizeEditorSettings({ autoAliasTables: false }).autoAliasTables).toBe(false); }); + + it("defaults update downloads to the official source", () => { + expect(normalizeEditorSettings({}).updateDownloadSource).toBe("official"); + }); + + it("preserves CNB update download source and rejects invalid values", () => { + expect(normalizeEditorSettings({ updateDownloadSource: "cnb" }).updateDownloadSource).toBe("cnb"); + expect(normalizeEditorSettings({ updateDownloadSource: "mirror" as any }).updateDownloadSource).toBe("official"); + }); }); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 34eb986b7..07c2e0301 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -58,6 +58,8 @@ export type DesktopIconTheme = "default" | "black"; export type InterfaceLayout = "separated" | "classic"; +export type UpdateDownloadSource = "official" | "cnb"; + export const DEFAULT_SIDEBAR_TABLE_PAGE_SIZE = 1000; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { @@ -359,6 +361,7 @@ export interface EditorSettings { exportRowLimitEnabled: boolean; exportRowLimit: number; queryExportKeysetOptimizationEnabled: boolean; + updateDownloadSource: UpdateDownloadSource; toolbarItems: ToolbarItems; } @@ -462,6 +465,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { exportRowLimitEnabled: false, exportRowLimit: 100000, queryExportKeysetOptimizationEnabled: true, + updateDownloadSource: "official", toolbarItems: { ...DEFAULT_TOOLBAR_ITEMS }, }; @@ -494,6 +498,10 @@ function normalizeDataGridRenderMode(value: unknown): DataGridRenderMode { return DATA_GRID_RENDER_MODES.includes(value as DataGridRenderMode) ? (value as DataGridRenderMode) : DEFAULT_EDITOR_SETTINGS.dataGridRenderMode; } +function normalizeUpdateDownloadSource(value: unknown): UpdateDownloadSource { + return value === "cnb" ? "cnb" : DEFAULT_EDITOR_SETTINGS.updateDownloadSource; +} + function normalizeDisconnectTabHandlingMode(value: unknown, legacyCloseTabsOnDisconnect?: unknown): DisconnectTabHandlingMode { if (DISCONNECT_TAB_HANDLING_MODES.includes(value as DisconnectTabHandlingMode)) { return value as DisconnectTabHandlingMode; @@ -632,6 +640,7 @@ export function normalizeEditorSettings(settings: Partial, exist exportRowLimitEnabled: typeof settings.exportRowLimitEnabled === "boolean" ? settings.exportRowLimitEnabled : DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled, exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit, queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled, + updateDownloadSource: normalizeUpdateDownloadSource(settings.updateDownloadSource), toolbarItems: normalizeToolbarItems(settings.toolbarItems), }; } @@ -803,6 +812,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.exportRowLimitEnabled !== undefined) editorSettings.value.exportRowLimitEnabled = partial.exportRowLimitEnabled; if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit))); if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled; + if (partial.updateDownloadSource !== undefined) editorSettings.value.updateDownloadSource = normalizeUpdateDownloadSource(partial.updateDownloadSource); if (partial.toolbarItems !== undefined) editorSettings.value.toolbarItems = normalizeToolbarItems(partial.toolbarItems); saveEditorSettings(editorSettings.value); } diff --git a/packages/app-tests/appUpdater.test.ts b/packages/app-tests/appUpdater.test.ts index 65f7daf90..b02fb4b4e 100644 --- a/packages/app-tests/appUpdater.test.ts +++ b/packages/app-tests/appUpdater.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { canDownloadAndInstallUpdate } from "../../apps/desktop/src/composables/useAppUpdater.ts"; +import { canDownloadAndInstallUpdate, normalizeUpdateDownloadSource, resolveUpdateReleaseUrl, tagVersion } from "../../apps/desktop/src/composables/useAppUpdater.ts"; import type { UpdateInfo } from "../../apps/desktop/src/lib/api.ts"; function updateInfo(overrides: Partial = {}): UpdateInfo { @@ -30,3 +30,21 @@ test("blocks in-app update installation outside desktop runtime or without an up assert.equal(canDownloadAndInstallUpdate(updateInfo({ update_available: false }), true), false); assert.equal(canDownloadAndInstallUpdate(null, true), false); }); + +test("normalizes update download source", () => { + assert.equal(normalizeUpdateDownloadSource("official"), "official"); + assert.equal(normalizeUpdateDownloadSource("cnb"), "cnb"); + assert.equal(normalizeUpdateDownloadSource("unknown"), "official"); +}); + +test("normalizes release tag versions", () => { + assert.equal(tagVersion("0.5.39"), "v0.5.39"); + assert.equal(tagVersion("v0.5.39"), "v0.5.39"); +}); + +test("resolves release page URL from update download source", () => { + const fallbackUrl = "https://github.com/t8y2/dbx/releases/latest"; + assert.equal(resolveUpdateReleaseUrl(updateInfo({ latest_version: "0.5.39" }), "cnb", fallbackUrl), "https://cnb.cool/dbxio.com/dbx/-/releases/tag/v0.5.39"); + assert.equal(resolveUpdateReleaseUrl(updateInfo({ release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.39" }), "official", fallbackUrl), "https://github.com/t8y2/dbx/releases/tag/v0.5.39"); + assert.equal(resolveUpdateReleaseUrl(null, "cnb", fallbackUrl), fallbackUrl); +}); diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 412099b65..198f36f32 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -1,4 +1,78 @@ +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; + pub use dbx_core::update::UpdateInfo; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_updater::UpdaterExt; + +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 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 UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "update-download-progress"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UpdateDownloadSource { + Official, + Cnb, +} + +#[derive(Clone, Debug, Serialize)] +pub struct UpdateDownloadProgress { + pub downloaded: u64, + pub total: Option, +} + +impl UpdateDownloadSource { + fn label(&self) -> &'static str { + match self { + Self::Official => "official", + Self::Cnb => "cnb", + } + } + + fn endpoints(&self, latest_version: Option<&str>) -> Result, String> { + match self { + Self::Official => Ok(OFFICIAL_UPDATE_ENDPOINTS.iter().map(|endpoint| endpoint.to_string()).collect()), + 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))]) + } + } + } + + fn rewrite_download_url(&self, url: &str) -> Result, String> { + if !matches!(self, Self::Cnb) { + return Ok(None); + } + + if url.starts_with(CNB_RELEASE_DOWNLOAD_PREFIX) { + return Ok(None); + } + + let rewritten = url + .strip_prefix(GITHUB_RELEASE_DOWNLOAD_PREFIX) + .map(|path| format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{path}")) + .ok_or_else(|| format!("Unsupported update download URL for CNB source: {url}"))?; + Ok(Some(rewritten)) + } +} + +fn tag_version(version: &str) -> String { + let version = version.trim(); + if version.starts_with('v') { + version.to_string() + } else { + format!("v{version}") + } +} #[tauri::command] pub async fn check_for_updates() -> Result { @@ -13,3 +87,98 @@ pub async fn check_for_updates() -> Result { pub async fn get_system_proxy_url() -> Option { tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.ok().flatten() } + +#[tauri::command] +pub async fn download_and_install_update( + app: AppHandle, + source: UpdateDownloadSource, + latest_version: Option, +) -> Result<(), String> { + if crate::data_dir::is_portable_mode() { + return Err("Portable builds cannot use the in-app installer.".to_string()); + } + + let endpoint_urls = source.endpoints(latest_version.as_deref())?; + println!("[DBX updater] checking from {} endpoints: {}", source.label(), endpoint_urls.join(", ")); + let mut endpoints = Vec::with_capacity(endpoint_urls.len()); + for endpoint_url in endpoint_urls { + endpoints.push(endpoint_url.parse().map_err(|e| format!("Invalid update endpoint: {e}"))?); + } + let mut builder = + app.updater_builder().endpoints(endpoints).map_err(|e| format!("Failed to configure updater endpoint: {e}"))?; + + if let Some(proxy_url) = dbx_core::update::system_proxy_url() { + let proxy = proxy_url.parse().map_err(|e| format!("Invalid system proxy URL: {e}"))?; + builder = builder.proxy(proxy); + } + + let updater = builder.build().map_err(|e| format!("Failed to create updater: {e}"))?; + let update = updater.check().await.map_err(|e| format!("Failed to check updates: {e}"))?; + let Some(mut update) = update else { + return Err("No update available.".to_string()); + }; + 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}"))?; + } + println!("[DBX updater] downloading from {} URL: {}", source.label(), update.download_url); + + let downloaded = Arc::new(AtomicU64::new(0)); + let finished_downloaded = Arc::clone(&downloaded); + update + .download_and_install( + |chunk_len, total| { + let downloaded = + downloaded.fetch_add(chunk_len as u64, Ordering::Relaxed).saturating_add(chunk_len as u64); + let _ = app.emit(UPDATE_DOWNLOAD_PROGRESS_EVENT, UpdateDownloadProgress { downloaded, total }); + }, + || { + let downloaded = finished_downloaded.load(Ordering::Relaxed); + let _ = app.emit( + UPDATE_DOWNLOAD_PROGRESS_EVENT, + UpdateDownloadProgress { downloaded, total: Some(downloaded) }, + ); + }, + ) + .await + .map_err(|e| format!("Failed to download and install update: {e}")) +} + +#[cfg(test)] +mod tests { + use super::{tag_version, UpdateDownloadSource, CNB_RELEASE_DOWNLOAD_PREFIX, OFFICIAL_UPDATE_ENDPOINTS}; + + #[test] + fn normalizes_update_tag_versions() { + assert_eq!(tag_version("0.5.39"), "v0.5.39"); + assert_eq!(tag_version("v0.5.39"), "v0.5.39"); + } + + #[test] + fn builds_official_update_endpoints() { + let endpoints = UpdateDownloadSource::Official.endpoints(None).unwrap(); + assert_eq!(endpoints, OFFICIAL_UPDATE_ENDPOINTS); + } + + #[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")]); + } + + #[test] + fn rewrites_github_asset_url_to_cnb() { + let download_url = UpdateDownloadSource::Cnb + .rewrite_download_url("https://github.com/t8y2/dbx/releases/download/v0.5.39/DBX_0.5.39_aarch64.dmg") + .unwrap() + .unwrap(); + assert_eq!(download_url, "https://cnb.cool/dbxio.com/dbx/-/releases/download/v0.5.39/DBX_0.5.39_aarch64.dmg"); + } + + #[test] + fn accepts_existing_cnb_asset_url() { + let download_url = UpdateDownloadSource::Cnb + .rewrite_download_url("https://cnb.cool/dbxio.com/dbx/-/releases/download/v0.5.39/DBX_0.5.39_aarch64.dmg") + .unwrap(); + assert_eq!(download_url, None); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 405c3042f..93c38314b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -878,6 +878,7 @@ pub fn run() { commands::mcp::install_mcp_server, commands::update::check_for_updates, commands::update::get_system_proxy_url, + commands::update::download_and_install_update, commands::transfer::start_transfer, commands::transfer::cancel_transfer, commands::database_export::export_database_sql,