From 528b51fb8d60b25ed98200e5f5bffca31e783e5c Mon Sep 17 00:00:00 2001
From: skyler <1156263951@qq.com>
Date: Sat, 30 May 2026 19:15:37 +0800
Subject: [PATCH] fix(updates): guard portable builds from installer updates
(#550)
---
.../src/components/layout/UpdateDialog.vue | 9 ++++-
apps/desktop/src/composables/useAppUpdater.ts | 8 +++++
apps/desktop/src/i18n/locales/en.ts | 2 ++
apps/desktop/src/i18n/locales/es.ts | 2 ++
apps/desktop/src/i18n/locales/zh-CN.ts | 2 ++
apps/desktop/src/lib/tauri.ts | 1 +
crates/dbx-core/src/update.rs | 3 ++
packages/app-tests/appUpdater.test.ts | 32 ++++++++++++++++++
src-tauri/src/commands/update.rs | 4 ++-
src-tauri/src/data_dir.rs | 33 +++++++++++++++++--
10 files changed, 92 insertions(+), 4 deletions(-)
create mode 100644 packages/app-tests/appUpdater.test.ts
diff --git a/apps/desktop/src/components/layout/UpdateDialog.vue b/apps/desktop/src/components/layout/UpdateDialog.vue
index aadec81751..b3d8ca0687 100644
--- a/apps/desktop/src/components/layout/UpdateDialog.vue
+++ b/apps/desktop/src/components/layout/UpdateDialog.vue
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { UpdateInfo } from "@/lib/api";
import { isTauriRuntime } from "@/lib/tauriRuntime";
+import { canDownloadAndInstallUpdate } from "@/composables/useAppUpdater";
const open = defineModel("open", { required: true });
@@ -98,6 +99,12 @@ watch(
docker compose pull && docker compose up -d
{{ t("updates.toUpdate") }}
+
+ {{ t("updates.portableManualUpdate") }}
+
-
+
{{ t("updates.reopenHint") }}
diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts
index b3a079cdf2..d99eb9b272 100644
--- a/apps/desktop/src/composables/useAppUpdater.ts
+++ b/apps/desktop/src/composables/useAppUpdater.ts
@@ -8,6 +8,10 @@ export function shouldOpenUpdateDialog(options: { silent?: boolean }) {
return options.silent !== true;
}
+export function canDownloadAndInstallUpdate(info: api.UpdateInfo | null, isDesktop: boolean) {
+ return isDesktop && info?.update_available === true && info.portable_mode !== true;
+}
+
export async function resolveUpdaterProxy(): Promise
{
if (!isTauriRuntime()) return undefined;
try {
@@ -80,6 +84,10 @@ export function useAppUpdater() {
async function downloadAndInstallUpdate() {
if (!isTauriRuntime() || isDownloadingUpdate.value) return;
+ if (!canDownloadAndInstallUpdate(updateInfo.value, true)) {
+ openLatestRelease();
+ return;
+ }
isDownloadingUpdate.value = true;
downloadProgress.value = 0;
try {
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index f0c9aebc1a..a15f796da9 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -55,6 +55,8 @@ export default {
"GitHub update checks are temporarily rate limited. You can still open the release page to check manually.",
openRelease: "Open Release",
downloadAndInstall: "Download & Install",
+ portableManualUpdate:
+ "Portable builds cannot use the in-app installer. Download the portable ZIP from the release page, then extract it over the current DBX folder to keep portable.dbx and data.",
downloading: "Downloading {progress}%",
downloadFailed: "Update download failed: {error}",
restart: "Exit & Restart",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index ffc9f9ae3a..142766ce1c 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -55,6 +55,8 @@ export default {
"Las verificaciones de actualizaciones de GitHub están temporalmente limitadas. Puedes abrir la página de lanzamientos para verificar manualmente.",
openRelease: "Abrir lanzamiento",
downloadAndInstall: "Descargar e instalar",
+ portableManualUpdate:
+ "Las versiones portables no pueden usar el instalador integrado. Descarga el ZIP portable desde la página de lanzamiento y extráelo sobre la carpeta actual de DBX para conservar portable.dbx y data.",
downloading: "Descargando {progress}%",
downloadFailed: "Error al descargar la actualización: {error}",
restart: "Salir y reiniciar",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index e8881ae6cb..973ca9bfcb 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -54,6 +54,8 @@ export default {
rateLimited: "GitHub 更新检查暂时触发频率限制。你仍然可以打开下载页手动查看。",
openRelease: "打开下载页",
downloadAndInstall: "下载并安装",
+ portableManualUpdate:
+ "便携版不能使用应用内安装器更新。请从 release 下载便携版 ZIP,并解压覆盖当前 DBX 目录,以保留 portable.dbx 和 data。",
downloading: "下载中 {progress}%",
downloadFailed: "更新下载失败:{error}",
restart: "退出并重启",
diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts
index aa7fb15bbf..01a2604fa8 100644
--- a/apps/desktop/src/lib/tauri.ts
+++ b/apps/desktop/src/lib/tauri.ts
@@ -908,6 +908,7 @@ export interface UpdateInfo {
current_version: string;
latest_version: string;
update_available: boolean;
+ portable_mode: boolean;
release_name: string;
release_url: string;
release_notes: string;
diff --git a/crates/dbx-core/src/update.rs b/crates/dbx-core/src/update.rs
index d83895aa4d..33c4dbb758 100644
--- a/crates/dbx-core/src/update.rs
+++ b/crates/dbx-core/src/update.rs
@@ -35,6 +35,7 @@ pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub update_available: bool,
+ pub portable_mode: bool,
pub release_name: String,
pub release_url: String,
pub release_notes: String,
@@ -227,6 +228,7 @@ pub fn build_update_info(release: TauriRelease, current_version: &str) -> Update
UpdateInfo {
update_available: is_newer_version(&latest_version, current_version),
+ portable_mode: false,
current_version: current_version.to_string(),
release_name,
release_url,
@@ -391,5 +393,6 @@ HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings
assert_eq!(info.release_name, "DBX v0.5.3");
assert_eq!(info.release_url, "https://github.com/t8y2/dbx/releases/tag/v0.5.3");
assert_eq!(info.release_notes, "### 新功能\n\n真实发布说明");
+ assert!(!info.portable_mode);
}
}
diff --git a/packages/app-tests/appUpdater.test.ts b/packages/app-tests/appUpdater.test.ts
new file mode 100644
index 0000000000..1729df8c41
--- /dev/null
+++ b/packages/app-tests/appUpdater.test.ts
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { canDownloadAndInstallUpdate } from "../../apps/desktop/src/composables/useAppUpdater.ts";
+import type { UpdateInfo } from "../../apps/desktop/src/lib/api.ts";
+
+function updateInfo(overrides: Partial = {}): UpdateInfo {
+ return {
+ current_version: "0.5.25",
+ latest_version: "0.5.26",
+ update_available: true,
+ portable_mode: false,
+ release_name: "DBX v0.5.26",
+ release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.26",
+ release_notes: "",
+ ...overrides,
+ };
+}
+
+test("allows in-app update installation for installed desktop builds", () => {
+ assert.equal(canDownloadAndInstallUpdate(updateInfo(), true), true);
+});
+
+test("blocks in-app update installation for portable builds", () => {
+ assert.equal(canDownloadAndInstallUpdate(updateInfo({ portable_mode: true }), true), false);
+});
+
+test("blocks in-app update installation outside desktop runtime or without an update", () => {
+ assert.equal(canDownloadAndInstallUpdate(updateInfo(), false), false);
+ assert.equal(canDownloadAndInstallUpdate(updateInfo({ update_available: false }), true), false);
+ assert.equal(canDownloadAndInstallUpdate(null, true), false);
+});
diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs
index 40d900074d..768949a019 100644
--- a/src-tauri/src/commands/update.rs
+++ b/src-tauri/src/commands/update.rs
@@ -4,7 +4,9 @@ pub use dbx_core::update::UpdateInfo;
pub async fn check_for_updates() -> Result {
let release = dbx_core::update::fetch_latest_release().await?;
let current_version = env!("CARGO_PKG_VERSION");
- Ok(dbx_core::update::build_update_info(release, current_version))
+ let mut info = dbx_core::update::build_update_info(release, current_version);
+ info.portable_mode = crate::data_dir::is_portable_mode();
+ Ok(info)
}
#[tauri::command]
diff --git a/src-tauri/src/data_dir.rs b/src-tauri/src/data_dir.rs
index 80a1628164..dcc704a2ab 100644
--- a/src-tauri/src/data_dir.rs
+++ b/src-tauri/src/data_dir.rs
@@ -10,7 +10,7 @@ pub fn resolve_data_dir(default_app_data_dir: PathBuf) -> PathBuf {
#[cfg(target_os = "windows")]
if let Some(exe_dir) = current_exe_dir() {
- if exe_dir.join(PORTABLE_MARKER).is_file() {
+ if portable_marker_exists(&exe_dir) {
return exe_dir.join("data");
}
}
@@ -18,11 +18,26 @@ pub fn resolve_data_dir(default_app_data_dir: PathBuf) -> PathBuf {
default_app_data_dir
}
+#[cfg(target_os = "windows")]
+pub fn is_portable_mode() -> bool {
+ current_exe_dir().is_some_and(|dir| portable_marker_exists(&dir))
+}
+
+#[cfg(not(target_os = "windows"))]
+pub fn is_portable_mode() -> bool {
+ false
+}
+
#[cfg(target_os = "windows")]
fn current_exe_dir() -> Option {
std::env::current_exe().ok().and_then(|path| path.parent().map(std::path::Path::to_path_buf))
}
+#[cfg(target_os = "windows")]
+fn portable_marker_exists(exe_dir: &std::path::Path) -> bool {
+ exe_dir.join(PORTABLE_MARKER).is_file()
+}
+
#[cfg(test)]
fn resolve_data_dir_from_inputs(
default_app_data_dir: PathBuf,
@@ -40,11 +55,16 @@ fn resolve_data_dir_from_inputs(
}
}
+#[cfg(test)]
+fn is_portable_mode_from_inputs(exe_dir: Option, portable_marker_exists: bool) -> bool {
+ exe_dir.is_some() && portable_marker_exists
+}
+
#[cfg(test)]
mod tests {
use std::path::PathBuf;
- use super::resolve_data_dir_from_inputs;
+ use super::{is_portable_mode_from_inputs, resolve_data_dir_from_inputs};
#[test]
fn uses_portable_data_dir_when_marker_exists() {
@@ -55,4 +75,13 @@ mod tests {
assert_eq!(data_dir, exe_dir.join("data"));
}
+
+ #[test]
+ fn detects_portable_mode_only_when_marker_exists_next_to_exe() {
+ let exe_dir = PathBuf::from(r"D:\Apps\DBX");
+
+ assert!(is_portable_mode_from_inputs(Some(exe_dir), true));
+ assert!(!is_portable_mode_from_inputs(Some(PathBuf::from(r"D:\Apps\DBX")), false));
+ assert!(!is_portable_mode_from_inputs(None, true));
+ }
}