fix(updater): guard installs during active tasks

This commit is contained in:
zipg authored and GitHub committed 2026-07-20 16:12:20 +08:00
1 parent b7db9b66db
commit 0d1d6e18a4
19 files changed
+554 -26

No files matched your search

+29 -1
View File
@@ -19,6 +19,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { useAppUpdater } from "@/composables/useAppUpdater";
import { useExportTracker } from "@/composables/useExportTracker";
import { useFileDrop } from "@/composables/useFileDrop";
import { usePanelResize } from "@/composables/usePanelResize";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
@@ -84,6 +85,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStor
import { apiUrl, webPath } from "@/lib/common/webPath";
import { APP_FONT_SANS_CSS_VAR, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
import { countActiveUpdateBlockingTasks } from "@/lib/app/appUpdateTaskGuard";
import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition";
import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -123,7 +125,29 @@ const savedSqlStore = useSavedSqlStore();
connectionStore.setBeforeConnectHandler((config) => ensureJdbcxRuntimeDrivers(config, api).then(() => undefined));
const { message: toastMessage, visible: toastVisible, toast } = useToast();
const { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
const { checkingUpdates, updateInfo, updateCheckMessage, showUpdateDialog, isDownloadingUpdate, downloadProgress, updateReady, hasUpdateAvailable, openUrl, checkUpdates, openLatestRelease, downloadAndInstallUpdate, restartApp } = useAppUpdater();
const { activeCount: activeBackgroundTaskCount } = useExportTracker();
const trackedUpdateTaskCount = computed(() => countActiveUpdateBlockingTasks(activeBackgroundTaskCount.value, queryStore.tabs));
const {
checkingUpdates,
updateInfo,
updateCheckMessage,
showUpdateDialog,
isDownloadingUpdate,
downloadProgress,
updateDownloaded,
isInstallingUpdate,
updateReady,
activeTaskCount: activeUpdateTaskCount,
hasUpdateAvailable,
openUrl,
checkUpdates,
openLatestRelease,
downloadAndInstallUpdate,
installDownloadedUpdate,
restartApp,
} = useAppUpdater({
getActiveTaskCount: () => trackedUpdateTaskCount.value,
});
const { setupFileDrop } = useFileDrop();
const isDesktop = isTauriRuntime();
@@ -2270,9 +2294,13 @@ onUnmounted(() => {
:update-check-message="updateCheckMessage"
:is-downloading-update="isDownloadingUpdate"
:download-progress="downloadProgress"
:update-downloaded="updateDownloaded"
:is-installing-update="isInstallingUpdate"
:update-ready="updateReady"
:active-task-count="activeUpdateTaskCount"
@open-latest-release="openLatestRelease"
@download-and-install="downloadAndInstallUpdate"
@install-downloaded="installDownloadedUpdate"
@restart="restartApp"
/>
<CloseActionPromptDialog v-if="isDesktop && showCloseActionPrompt" :open="showCloseActionPrompt" @update:open="handleCloseActionPromptOpenChange" @quit="chooseQuit" @minimize="chooseMinimize" />
+222
View File
@@ -0,0 +1,222 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import UpdateDialog from "@/components/layout/UpdateDialog.vue";
vi.mock("@/lib/backend/tauriRuntime", () => ({
isTauriRuntime: () => true,
}));
const mountedApps: App[] = [];
interface DialogState {
open: boolean;
isDownloadingUpdate: boolean;
downloadProgress: number;
updateDownloaded: boolean;
isInstallingUpdate: boolean;
updateReady: boolean;
}
async function flushDialog() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function mountDialog(activeTaskCount: number, initialState: Partial<DialogState> = {}, installDownloaded = vi.fn(async () => {})) {
const state = reactive<DialogState>({
open: true,
isDownloadingUpdate: false,
downloadProgress: 0,
updateDownloaded: false,
isInstallingUpdate: false,
updateReady: false,
...initialState,
});
const downloadAndInstall = vi.fn();
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
async function handleInstallDownloaded() {
state.isInstallingUpdate = true;
try {
await installDownloaded();
state.updateDownloaded = false;
state.updateReady = true;
} catch {
// The real updater reports the error but retains the downloaded package for retry.
} finally {
state.isInstallingUpdate = false;
}
}
return () =>
h(UpdateDialog, {
open: state.open,
"onUpdate:open": (value: boolean) => {
state.open = value;
},
updateInfo: {
current_version: "0.5.60",
latest_version: "0.5.61",
update_available: true,
portable_mode: false,
release_name: "DBX v0.5.61",
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.61",
release_notes: "",
},
updateCheckMessage: "",
isDownloadingUpdate: state.isDownloadingUpdate,
downloadProgress: state.downloadProgress,
updateDownloaded: state.updateDownloaded,
isInstallingUpdate: state.isInstallingUpdate,
updateReady: state.updateReady,
activeTaskCount,
"onDownload-and-install": downloadAndInstall,
"onInstall-downloaded": handleInstallDownloaded,
});
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await flushDialog();
return { state, downloadAndInstall, installDownloaded };
}
function buttonWithText(text: string): HTMLButtonElement | undefined {
return Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes(text));
}
function downloadButton(): HTMLButtonElement | undefined {
return buttonWithText("Download & Install");
}
function installDownloadedButton(): HTMLButtonElement | undefined {
return buttonWithText("Exit & Update");
}
async function pressEscape() {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
await flushDialog();
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("UpdateDialog active task guard", () => {
it("shows the task warning and disables installation while work is running", async () => {
await mountDialog(2);
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("2");
expect(downloadButton()?.disabled).toBe(true);
});
it("allows installation after all tasks finish", async () => {
await mountDialog(0);
expect(document.body.querySelector('[role="alert"]')).toBeNull();
expect(downloadButton()?.disabled).toBe(false);
});
it("retains the downloaded update and enables installation only after tasks finish", async () => {
await mountDialog(1, { updateDownloaded: true, downloadProgress: 100 });
expect(downloadButton()).toBeUndefined();
expect(installDownloadedButton()?.disabled).toBe(true);
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 });
expect(installDownloadedButton()?.disabled).toBe(false);
});
});
describe("UpdateDialog close protection", () => {
it("allows closing while a downloaded update is idle", async () => {
const { state } = await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 });
expect(buttonWithText("Cancel")).toBeDefined();
expect(document.body.querySelector('[data-slot="dialog-close"]')).not.toBeNull();
await pressEscape();
expect(state.open).toBe(false);
});
it("prevents closing while installation is in progress", async () => {
const { state } = await mountDialog(0, { updateDownloaded: true, isInstallingUpdate: true });
expect(buttonWithText("Cancel")).toBeUndefined();
expect(document.body.querySelector('[data-slot="dialog-close"]')).toBeNull();
await pressEscape();
expect(state.open).toBe(true);
});
it("allows closing after installation rejects", async () => {
let rejectInstall!: (error: Error) => void;
const installDownloaded = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectInstall = reject;
}),
);
const { state } = await mountDialog(0, { updateDownloaded: true }, installDownloaded);
installDownloadedButton()?.click();
await flushDialog();
await pressEscape();
expect(state.open).toBe(true);
rejectInstall(new Error("install failed"));
await flushDialog();
expect(buttonWithText("Cancel")).toBeDefined();
await pressEscape();
expect(state.open).toBe(false);
});
it("retries the retained download after reopening without downloading again", async () => {
const installDownloaded = vi.fn(async () => {
throw new Error("install failed");
});
const { state, downloadAndInstall } = await mountDialog(0, { updateDownloaded: true }, installDownloaded);
installDownloadedButton()?.click();
await flushDialog();
await pressEscape();
state.open = true;
await flushDialog();
installDownloadedButton()?.click();
await flushDialog();
expect(installDownloaded).toHaveBeenCalledTimes(2);
expect(downloadAndInstall).not.toHaveBeenCalled();
});
it("keeps the successful install flow protected", async () => {
const installDownloaded = vi.fn(async () => {});
const { state, downloadAndInstall } = await mountDialog(0, { updateDownloaded: true }, installDownloaded);
installDownloadedButton()?.click();
await flushDialog();
expect(state.updateDownloaded).toBe(false);
expect(state.updateReady).toBe(true);
expect(buttonWithText("Restart")).toBeDefined();
expect(buttonWithText("Cancel")).toBeUndefined();
await pressEscape();
expect(state.open).toBe(true);
expect(installDownloaded).toHaveBeenCalledOnce();
expect(downloadAndInstall).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2 } from "@lucide/vue";
import { AlertTriangle, Loader2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { UpdateInfo } from "@/lib/backend/api";
@@ -15,12 +15,16 @@ const props = defineProps<{
updateCheckMessage: string;
isDownloadingUpdate: boolean;
downloadProgress: number;
updateDownloaded: boolean;
isInstallingUpdate: boolean;
updateReady: boolean;
activeTaskCount: number;
}>();
const emit = defineEmits<{
"open-latest-release": [];
"download-and-install": [];
"install-downloaded": [];
restart: [];
}>();
@@ -28,6 +32,8 @@ const { t } = useI18n();
const isDesktop = isTauriRuntime();
const renderedNotes = ref("");
// Keep a downloaded package retryable after install errors; only active transitions must trap the dialog.
const isCloseBlocked = computed(() => props.isDownloadingUpdate || props.isInstallingUpdate || props.updateReady);
function handleReleaseNotesClick(event: MouseEvent) {
const target = event.target as HTMLElement;
@@ -62,14 +68,15 @@ watch(
<Dialog v-model:open="open">
<DialogContent
class="sm:max-w-[520px]"
:show-close-button="!isCloseBlocked"
@interact-outside="
(e: Event) => {
if (isDownloadingUpdate || updateReady) e.preventDefault();
if (isCloseBlocked) e.preventDefault();
}
"
@escape-key-down="
(e: Event) => {
if (isDownloadingUpdate || updateReady) e.preventDefault();
if (isCloseBlocked) e.preventDefault();
}
"
>
@@ -102,18 +109,27 @@ watch(
<p v-if="isDesktop && updateInfo?.update_available && updateInfo.portable_mode" class="text-xs text-muted-foreground">
{{ t("updates.portableManualUpdate") }}
</p>
<div v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop) && activeTaskCount > 0" role="alert" class="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ t("updates.activeTasksBlockUpdate", { count: activeTaskCount }) }}</span>
</div>
</div>
<DialogFooter>
<Button v-if="!isDownloadingUpdate && !updateReady" variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button v-if="!isCloseBlocked" variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<template v-if="updateInfo?.update_available">
<Button variant="outline" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
<Button v-if="updateReady" @click="emit('restart')">{{ t("updates.restart") }}</Button>
<Button v-if="updateReady" :disabled="activeTaskCount > 0" @click="emit('restart')">{{ t("updates.restart") }}</Button>
<Button v-else-if="isInstallingUpdate" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.installing") }}
</Button>
<Button v-else-if="isDownloadingUpdate" disabled>
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("updates.downloading", { progress: downloadProgress }) }}
</Button>
<Button v-else @click="emit('download-and-install')">{{ t("updates.downloadAndInstall") }}</Button>
<Button v-else-if="updateDownloaded" :disabled="activeTaskCount > 0" @click="emit('install-downloaded')">{{ t("updates.exitAndUpdate") }}</Button>
<Button v-else :disabled="activeTaskCount > 0" @click="emit('download-and-install')">{{ t("updates.downloadAndInstall") }}</Button>
</template>
</template>
<Button v-else-if="updateCheckMessage" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
+68 -6
View File
@@ -7,6 +7,12 @@ import { useSettingsStore } from "@/stores/settingsStore";
import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore";
import type { UpdateDownloadProgress } from "@/lib/backend/tauri";
import { currentLocale } from "@/i18n";
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
import { downloadAndInstallUpdateWhenIdle, installDownloadedUpdateWhenIdle } from "@/lib/app/appUpdateInstallFlow";
interface UseAppUpdaterOptions {
getActiveTaskCount?: () => number;
}
export function shouldOpenUpdateDialog(options: { silent?: boolean }) {
return options.silent !== true;
@@ -45,7 +51,7 @@ export async function resolveUpdaterProxy(): Promise<string | undefined> {
}
}
export function useAppUpdater() {
export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
@@ -56,7 +62,10 @@ export function useAppUpdater() {
const showUpdateDialog = ref(false);
const isDownloadingUpdate = ref(false);
const downloadProgress = ref(0);
const updateDownloaded = ref(false);
const isInstallingUpdate = ref(false);
const updateReady = ref(false);
const activeTaskCount = computed(() => Math.max(0, Math.trunc(options.getActiveTaskCount?.() ?? 0)));
const hasUpdateAvailable = computed(() => updateInfo.value?.update_available === true);
const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
@@ -106,35 +115,84 @@ export function useAppUpdater() {
openUrl(url);
}
function blockUpdateForActiveTasks(): boolean {
if (!shouldBlockAppUpdate(activeTaskCount.value)) return false;
toast(t("updates.activeTasksBlockUpdate", { count: activeTaskCount.value }), 5000);
return true;
}
async function downloadAndInstallUpdate() {
if (!isTauriRuntime() || isDownloadingUpdate.value) return;
if (!isTauriRuntime() || isDownloadingUpdate.value || isInstallingUpdate.value || updateDownloaded.value || updateReady.value) return;
if (!canDownloadAndInstallUpdate(updateInfo.value, true)) {
openLatestRelease();
return;
}
if (blockUpdateForActiveTasks()) return;
isDownloadingUpdate.value = true;
downloadProgress.value = 0;
let unlisten: (() => void) | undefined;
const latestVersion = updateInfo.value?.latest_version;
let failureMessageKey = "updates.downloadFailed";
try {
const { listen } = await import("@tauri-apps/api/event");
unlisten = await listen<UpdateDownloadProgress>("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;
const result = await downloadAndInstallUpdateWhenIdle({
getActiveTaskCount: () => activeTaskCount.value,
download: async () => {
try {
await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), latestVersion);
downloadProgress.value = 100;
updateDownloaded.value = true;
} finally {
isDownloadingUpdate.value = false;
}
},
install: async () => {
failureMessageKey = "updates.installFailed";
await installPendingUpdate();
},
});
if (result === "blocked" || result === "downloaded") {
blockUpdateForActiveTasks();
}
} catch (e: any) {
toast(t("updates.downloadFailed", { error: e?.message || String(e) }), 5000);
toast(t(failureMessageKey, { error: e?.message || String(e) }), 5000);
} finally {
unlisten?.();
isDownloadingUpdate.value = false;
}
}
async function installPendingUpdate() {
isInstallingUpdate.value = true;
try {
await api.installDownloadedUpdate();
updateDownloaded.value = false;
updateReady.value = true;
} finally {
isInstallingUpdate.value = false;
}
}
async function installDownloadedUpdate() {
if (!isTauriRuntime() || isDownloadingUpdate.value || isInstallingUpdate.value || !updateDownloaded.value) return;
try {
const installed = await installDownloadedUpdateWhenIdle({
getActiveTaskCount: () => activeTaskCount.value,
install: installPendingUpdate,
});
if (!installed) blockUpdateForActiveTasks();
} catch (e: any) {
toast(t("updates.installFailed", { error: e?.message || String(e) }), 5000);
}
}
async function restartApp() {
if (!isTauriRuntime()) return;
if (blockUpdateForActiveTasks()) return;
try {
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
@@ -150,7 +208,10 @@ export function useAppUpdater() {
showUpdateDialog,
isDownloadingUpdate,
downloadProgress,
updateDownloaded,
isInstallingUpdate,
updateReady,
activeTaskCount,
hasUpdateAvailable,
latestReleaseUrl,
openUrl,
@@ -158,6 +219,7 @@ export function useAppUpdater() {
formatUpdateError,
openLatestRelease,
downloadAndInstallUpdate,
installDownloadedUpdate,
restartApp,
};
}
+3
View File
@@ -68,9 +68,12 @@ export default {
rateLimited: "GitHub update checks are temporarily rate limited. You can still open the release page to check manually.",
openRelease: "Open Release",
downloadAndInstall: "Download & Install",
activeTasksBlockUpdate: "{count} task(s) are still running. Wait for them to finish before updating DBX.",
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}",
installing: "Installing update...",
installFailed: "Update installation failed: {error}",
restart: "Exit & Restart",
restartFailed: "Failed to restart app: {error}",
exitAndUpdate: "Exit & Update",
+3
View File
@@ -70,9 +70,12 @@ export default withEnglishFallback({
rateLimited: "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",
activeTasksBlockUpdate: "Hay {count} tarea(s) en ejecución. Espera a que terminen antes de actualizar DBX.",
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}",
installing: "Instalando actualización...",
installFailed: "Error al instalar la actualización: {error}",
restart: "Salir y reiniciar",
restartFailed: "Error al reiniciar la aplicación: {error}",
exitAndUpdate: "Salir y actualizar",
+3
View File
@@ -69,9 +69,12 @@ export default withEnglishFallback({
rateLimited: "Le verifiche degli aggiornamenti di GitHub sono temporaneamente limitate. Puoi comunque aprire la pagina delle release per verificare manualmente.",
openRelease: "Apri Release",
downloadAndInstall: "Scarica e Installa",
activeTasksBlockUpdate: "Ci sono {count} attività in esecuzione. Attendi che terminino prima di aggiornare DBX.",
portableManualUpdate: "Le build portatili non possono utilizzare l'installer in-app. Scarica lo ZIP portatile dalla pagina delle release, quindi estrailo nella cartella DBX corrente per mantenere portable.dbx e i dati.",
downloading: "Download in corso {progress}%",
downloadFailed: "Download dell'aggiornamento non riuscito: {error}",
installing: "Installazione dell'aggiornamento...",
installFailed: "Installazione dell'aggiornamento non riuscita: {error}",
restart: "Esci e Riavvia",
restartFailed: "Impossibile riavviare l'app: {error}",
exitAndUpdate: "Esci e Aggiorna",
+3
View File
@@ -70,9 +70,12 @@ export default withEnglishFallback({
rateLimited: "GitHubのアップデート確認が一時的に制限されています。リリースページで手動確認できます。",
openRelease: "リリースページを開く",
downloadAndInstall: "ダウンロード & インストール",
activeTasksBlockUpdate: "{count} 件のタスクが実行中です。完了してから DBX を更新してください。",
portableManualUpdate: "ポータブル版はアプリ内インストーラーを使用できません。リリースページからポータブルZIPをダウンロードし、現在のDBXフォルダに上書き展開してください。portable.dbxとデータは保持されます。",
downloading: "ダウンロード中 {progress}%",
downloadFailed: "アップデートのダウンロードに失敗しました: {error}",
installing: "アップデートをインストール中...",
installFailed: "アップデートのインストールに失敗しました: {error}",
restart: "終了して再起動",
restartFailed: "アプリの再起動に失敗しました: {error}",
exitAndUpdate: "終了してアップデート",
+3
View File
@@ -70,9 +70,12 @@ export default withEnglishFallback({
rateLimited: "As verificações de atualização do GitHub estão temporariamente limitadas. Você ainda pode abrir a página de lançamento para verificar manualmente.",
openRelease: "Abrir Lançamento",
downloadAndInstall: "Baixar e Instalar",
activeTasksBlockUpdate: "Há {count} tarefa(s) em execução. Aguarde a conclusão antes de atualizar o DBX.",
portableManualUpdate: "Versões portáteis não podem usar o instalador interno. Baixe o ZIP portátil da página de lançamento e extraia-o sobre a pasta atual do DBX para manter o portable.dbx e os dados.",
downloading: "Baixando {progress}%",
downloadFailed: "Falha ao baixar a atualização: {error}",
installing: "Instalando atualização...",
installFailed: "Falha ao instalar a atualização: {error}",
restart: "Sair e Reiniciar",
restartFailed: "Falha ao reiniciar o aplicativo: {error}",
exitAndUpdate: "Sair e Atualizar",
+3
View File
@@ -70,9 +70,12 @@ export default withEnglishFallback({
rateLimited: "GitHub 更新检查暂时触发频率限制。你仍然可以打开下载页手动查看。",
openRelease: "打开下载页",
downloadAndInstall: "下载并安装",
activeTasksBlockUpdate: "有 {count} 个任务正在执行,请等待任务完成后再更新 DBX。",
portableManualUpdate: "便携版不能使用应用内安装器更新。请从 release 下载便携版 ZIP,并解压覆盖当前 DBX 目录,以保留 portable.dbx 和 data。",
downloading: "下载中 {progress}%",
downloadFailed: "更新下载失败:{error}",
installing: "正在安装更新...",
installFailed: "更新安装失败:{error}",
restart: "退出并重启",
restartFailed: "重启应用失败:{error}",
exitAndUpdate: "退出并更新",
+3
View File
@@ -70,9 +70,12 @@ export default withEnglishFallback({
rateLimited: "GitHub 更新檢查暫時觸發頻率限制。你仍然可以開啟下載頁手動檢視。",
openRelease: "開啟下載頁",
downloadAndInstall: "下載並安裝",
activeTasksBlockUpdate: "有 {count} 個任務正在執行,請等待任務完成後再更新 DBX。",
portableManualUpdate: "可攜版無法使用應用程式內建安裝器更新。請從發行頁下載可攜版 ZIP,並解壓縮覆蓋目前的 DBX 目錄,以保留 portable.dbx 和 data。",
downloading: "下載中 {progress}%",
downloadFailed: "更新下載失敗:{error}",
installing: "正在安裝更新...",
installFailed: "更新安裝失敗:{error}",
restart: "結束並重新啟動",
restartFailed: "重新啟動應用程式失敗:{error}",
exitAndUpdate: "結束並更新",
@@ -0,0 +1,31 @@
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
export type UpdateDownloadFlowResult = "blocked" | "downloaded" | "installed";
interface UpdateDownloadFlowOptions {
getActiveTaskCount: () => number;
download: () => Promise<void>;
install: () => Promise<void>;
}
interface DownloadedUpdateInstallOptions {
getActiveTaskCount: () => number;
install: () => Promise<void>;
}
export async function downloadAndInstallUpdateWhenIdle(options: UpdateDownloadFlowOptions): Promise<UpdateDownloadFlowResult> {
if (shouldBlockAppUpdate(options.getActiveTaskCount())) return "blocked";
await options.download();
if (shouldBlockAppUpdate(options.getActiveTaskCount())) return "downloaded";
await options.install();
return "installed";
}
export async function installDownloadedUpdateWhenIdle(options: DownloadedUpdateInstallOptions): Promise<boolean> {
if (shouldBlockAppUpdate(options.getActiveTaskCount())) return false;
await options.install();
return true;
}
@@ -0,0 +1,17 @@
export interface UpdateQueryTaskState {
isExecuting?: boolean;
explainExecutionId?: string;
}
function normalizedTaskCount(value: number): number {
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
}
export function countActiveUpdateBlockingTasks(backgroundTaskCount: number, queryTasks: UpdateQueryTaskState[]): number {
const activeQueryCount = queryTasks.filter((task) => task.isExecuting || Boolean(task.explainExecutionId?.trim())).length;
return normalizedTaskCount(backgroundTaskCount) + activeQueryCount;
}
export function shouldBlockAppUpdate(activeTaskCount: number): boolean {
return normalizedTaskCount(activeTaskCount) > 0;
}
+2 -1
View File
@@ -488,7 +488,8 @@ export const installMcpServer = forward("installMcpServer");
export const checkForUpdates = forward("checkForUpdates");
export const fetchChangelog = forward("fetchChangelog");
export const getSystemProxyUrl = forward("getSystemProxyUrl");
export const downloadAndInstallUpdate = forward("downloadAndInstallUpdate");
export const downloadUpdate = forward("downloadUpdate");
export const installDownloadedUpdate = forward("installDownloadedUpdate");
export const getAppVersion = forward("getAppVersion");
export const getAppSupportInfo = forward("getAppSupportInfo");
+5 -1
View File
@@ -2287,7 +2287,11 @@ export async function getSystemProxyUrl(): Promise<string | null> {
return null;
}
export async function downloadAndInstallUpdate(_source: UpdateDownloadSource, _latestVersion?: string): Promise<void> {
export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion?: string): Promise<void> {
throw new Error("In-app update downloads are only available in the desktop app.");
}
export async function installDownloadedUpdate(): Promise<void> {
throw new Error("In-app update installation is only available in the desktop app.");
}
+6 -2
View File
@@ -1458,8 +1458,12 @@ export async function getSystemProxyUrl(): Promise<string | null> {
return invoke("get_system_proxy_url");
}
export async function downloadAndInstallUpdate(source: UpdateDownloadSource, latestVersion?: string): Promise<void> {
return invoke("download_and_install_update", { source, latestVersion });
export async function downloadUpdate(source: UpdateDownloadSource, latestVersion?: string): Promise<void> {
return invoke("download_update", { source, latestVersion });
}
export async function installDownloadedUpdate(): Promise<void> {
return invoke("install_downloaded_update");
}
export async function getAppVersion(): Promise<string> {