fix(updater): guard installs during active tasks
This commit is contained in:
19 files changed
+554
-26
No files matched your search
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "終了してアップデート",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "退出并更新",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user