fix(updates): guard portable builds from installer updates (#550)

This commit is contained in:
skyler
2026-05-30 19:15:37 +08:00
committed by GitHub
parent 398e040d5c
commit 528b51fb8d
10 changed files with 92 additions and 4 deletions
@@ -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<boolean>("open", { required: true });
@@ -98,6 +99,12 @@ watch(
<code class="bg-muted px-1 py-0.5 rounded text-[11px]">docker compose pull && docker compose up -d</code>
{{ t("updates.toUpdate") }}
</p>
<p
v-if="isDesktop && updateInfo?.update_available && updateInfo.portable_mode"
class="text-xs text-muted-foreground"
>
{{ t("updates.portableManualUpdate") }}
</p>
</div>
<DialogFooter>
<Button v-if="!isDownloadingUpdate && !updateReady" variant="outline" @click="open = false">{{
@@ -105,7 +112,7 @@ watch(
}}</Button>
<template v-if="updateInfo?.update_available">
<Button variant="outline" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
<template v-if="isDesktop">
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
<div v-if="updateReady" class="flex flex-col items-end gap-1">
<Button @click="emit('restart')">{{ t("updates.restart") }}</Button>
<span class="text-xs text-muted-foreground">{{ t("updates.reopenHint") }}</span>
@@ -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<string | undefined> {
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 {
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -54,6 +54,8 @@ export default {
rateLimited: "GitHub 更新检查暂时触发频率限制。你仍然可以打开下载页手动查看。",
openRelease: "打开下载页",
downloadAndInstall: "下载并安装",
portableManualUpdate:
"便携版不能使用应用内安装器更新。请从 release 下载便携版 ZIP,并解压覆盖当前 DBX 目录,以保留 portable.dbx 和 data。",
downloading: "下载中 {progress}%",
downloadFailed: "更新下载失败:{error}",
restart: "退出并重启",
+1
View File
@@ -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;
+3
View File
@@ -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);
}
}
+32
View File
@@ -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> = {}): 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);
});
+3 -1
View File
@@ -4,7 +4,9 @@ pub use dbx_core::update::UpdateInfo;
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
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]
+31 -2
View File
@@ -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<PathBuf> {
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<PathBuf>, 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));
}
}