feat(updater): download and prepare desktop updates in background
This commit is contained in:
77 files changed
+10536
-814
No files matched your search
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { blockingDesktopAiRunsForUpdate } from "@/lib/ai/desktopAiRunRegistry";
|
||||
import { setupUpdatePreparation, prepareUpdateWithDraftRecovery, isUpdatePreparationActive } from "@/lib/app/updatePreparation";
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent, provide } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FileText } from "@lucide/vue";
|
||||
@@ -202,6 +204,9 @@ const { isDark, themeMode, applyTheme, setThemeMode } = useTheme();
|
||||
const { activeCount: activeBackgroundTaskCount } = useExportTracker();
|
||||
const trackedUpdateTaskCount = computed(() => countActiveUpdateBlockingTasks(activeBackgroundTaskCount.value, queryStore.tabs));
|
||||
const {
|
||||
initialize: initializeUpdater,
|
||||
dispose: disposeUpdater,
|
||||
isPreparingUpdate,
|
||||
checkingUpdates,
|
||||
updateInfo,
|
||||
updateCheckMessage,
|
||||
@@ -226,6 +231,10 @@ const {
|
||||
restartApp,
|
||||
} = useAppUpdater({
|
||||
getActiveTaskCount: () => trackedUpdateTaskCount.value,
|
||||
prepareForUpdate: async () => {
|
||||
if (!updatePreparation) throw new Error(t("updates.preparationNotReady"));
|
||||
return prepareUpdateWithDraftRecovery(() => updatePreparation!.prepare());
|
||||
},
|
||||
});
|
||||
const { setupFileDrop } = useFileDrop();
|
||||
const { openInStreamingExecutorOnTooLarge } = useLargeSqlFileStreamingFallback();
|
||||
@@ -234,6 +243,55 @@ const isDesktop = isTauriRuntime();
|
||||
const windowContext = resolveWindowContext();
|
||||
const isDetachedWindowContext = windowContext.kind === "detached-tab";
|
||||
const detachedContextTabId = windowContext.kind === "detached-tab" ? windowContext.tabId : undefined;
|
||||
let updateWindowReady = false;
|
||||
let updatePreparation: Awaited<ReturnType<typeof setupUpdatePreparation>> | undefined;
|
||||
async function initializeUpdatePreparation() {
|
||||
if (!isDesktop || updatePreparation) return;
|
||||
updatePreparation = await setupUpdatePreparation({
|
||||
translate: (key) => t(key),
|
||||
assertSafe() {
|
||||
if (!updateWindowReady) throw new Error(t("updates.preparationNotReady"));
|
||||
if (trackedUpdateTaskCount.value > 0 || blockingDesktopAiRunsForUpdate().length > 0 || queryStore.tabs.some((tab) => tab.isCancelling || tab.isExplaining || tab.txnSessionId)) {
|
||||
throw new Error(t("updates.preparationTasks"));
|
||||
}
|
||||
if (queryStore.tabs.some((tab) => (tab.pendingDataChangeCount ?? 0) > 0 || tab.hasPendingDataEditorDraft)) {
|
||||
throw new Error(t("updates.preparationDrafts"));
|
||||
}
|
||||
if (
|
||||
pendingAppCloseAction.value ||
|
||||
showConnectionDialog.value ||
|
||||
showSaveSqlDialog.value ||
|
||||
showQueryEditorDdlDialog.value ||
|
||||
showQueryEditorObjectSourceDialog.value ||
|
||||
showSqlParameterDialog.value ||
|
||||
showDangerDialog.value ||
|
||||
showMultiDbExecuteDialog.value ||
|
||||
[
|
||||
dialogs.showTransferDialog,
|
||||
dialogs.showSqlFileDialog,
|
||||
dialogs.showTableImportDialog,
|
||||
dialogs.showTableDataGenerateDialog,
|
||||
dialogs.showDatabaseExportDialog,
|
||||
dialogs.showSchemaDiffDialog,
|
||||
dialogs.showDataCompareDialog,
|
||||
dialogs.showConfigPassphraseDialog,
|
||||
dialogs.showConfigConnectionSelectDialog,
|
||||
dialogs.showConfigUnencryptedExportConfirm,
|
||||
dialogs.showImportLayoutConfirm,
|
||||
dialogs.configExportBusy,
|
||||
dialogs.applyingImportSelection,
|
||||
].some((state) => state.value)
|
||||
) {
|
||||
throw new Error(t("updates.preparationConfig"));
|
||||
}
|
||||
},
|
||||
async persist() {
|
||||
await nextTick();
|
||||
await queryStore.flushPendingPersist();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const activeAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().length : 0));
|
||||
/** Runs waiting for a write confirmation — the panel-entry badge shows these
|
||||
* with a higher-priority indicator (parent PRD §4 line 71 / §9). */
|
||||
@@ -265,7 +323,7 @@ const driverStoreActive = ref(false);
|
||||
const driverStoreActiveTab = ref<"agent" | "jdbc" | "storage" | "runtime">("agent");
|
||||
const settingsReturnSurface = ref<"query" | "driverStore" | "welcome">("welcome");
|
||||
const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreActive.value);
|
||||
const showSettingsPage = computed(() => settingsPageTabOpen.value && settingsStore.settingsPageActive);
|
||||
const showSettingsPage = computed(() => Boolean(settingsPageTabOpen.value && settingsStore.settingsPageActive));
|
||||
const showQuickOpen = ref(false);
|
||||
const showTabSwitcher = ref(false);
|
||||
const tabSwitcherIndex = ref(0);
|
||||
@@ -1542,6 +1600,7 @@ function cancelPendingAppClose() {
|
||||
}
|
||||
|
||||
async function finishPendingAppClose(action: AppCloseAction) {
|
||||
if (isUpdatePreparationActive()) return;
|
||||
if (action === "quit" && !aiRunsQuitConfirmed && blockingAiRunCount.value > 0) {
|
||||
pendingAppCloseAction.value = action;
|
||||
showAiRunsClosePrompt.value = true;
|
||||
@@ -1594,6 +1653,7 @@ function continuePendingAppCloseAfterSave() {
|
||||
}
|
||||
|
||||
function requestAppClose(action: AppCloseAction, options: AppCloseRequestOptions = {}) {
|
||||
if (isUpdatePreparationActive()) return;
|
||||
pendingCloseActionChoice.value = !!options.requireCloseActionChoice;
|
||||
if (queryStore.hasDirtyTabs) {
|
||||
pendingAppCloseAction.value = action;
|
||||
@@ -3216,6 +3276,11 @@ async function initApp() {
|
||||
});
|
||||
}
|
||||
await settingsStore.initDesktopSettings().catch(() => {});
|
||||
if (isDesktop) {
|
||||
updateWindowReady = true;
|
||||
await initializeUpdatePreparation();
|
||||
await initializeUpdater();
|
||||
}
|
||||
|
||||
void promptTemplateStore.init();
|
||||
|
||||
@@ -3281,7 +3346,6 @@ function openDriverStoreFromEvent(event: Event) {
|
||||
|
||||
function runUpdateNotificationChecks() {
|
||||
if (!updateNotificationsEnabled.value) return;
|
||||
checkUpdates({ silent: true });
|
||||
void refreshAgentDriverUpdateCount();
|
||||
void refreshMcpUpdateStatus();
|
||||
}
|
||||
@@ -3306,12 +3370,17 @@ onMounted(async () => {
|
||||
console.log("[STARTUP] onMounted begin");
|
||||
const mountStart = performance.now();
|
||||
if (isDetachedWindowContext) {
|
||||
await initializeUpdatePreparation();
|
||||
await setupDetachedWindowEvents();
|
||||
await initDetachedWindow().catch((error) => {
|
||||
console.error("[STARTUP] detached window initialization failed", error);
|
||||
toast(error?.message || String(error), 5000);
|
||||
void finishDetachedWindowClose();
|
||||
});
|
||||
await initDetachedWindow()
|
||||
.then(() => {
|
||||
updateWindowReady = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[STARTUP] detached window initialization failed", error);
|
||||
toast(error?.message || String(error), 5000);
|
||||
void finishDetachedWindowClose();
|
||||
});
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
@@ -3390,6 +3459,8 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeUpdater();
|
||||
updatePreparation?.dispose();
|
||||
detachedEventUnlisteners.forEach((unlisten) => unlisten());
|
||||
detachedEventUnlisteners = [];
|
||||
cleanupTauriListeners();
|
||||
@@ -3439,6 +3510,7 @@ onUnmounted(() => {
|
||||
:has-update-available="toolbarHasUpdateAvailable"
|
||||
:is-downloading-update="isDownloadingUpdate"
|
||||
:download-progress="downloadProgress"
|
||||
:update-version="updateInfo?.latest_version"
|
||||
:update-ready-to-install="updateDownloaded"
|
||||
:update-ready="updateReady"
|
||||
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
|
||||
@@ -3814,6 +3886,7 @@ onUnmounted(() => {
|
||||
:is-downloading-update="isDownloadingUpdate"
|
||||
:download-progress="downloadProgress"
|
||||
:update-downloaded="updateDownloaded"
|
||||
:is-preparing-update="isPreparingUpdate"
|
||||
:is-installing-update="isInstallingUpdate"
|
||||
:update-ready="updateReady"
|
||||
:is-ignoring-update="isIgnoringUpdate"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
||||
import { computed, nextTick, onMounted, onUnmounted, onActivated, onDeactivated, ref, shallowRef, toRaw, useSlots, watch, defineAsyncComponent, type Component, type CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
@@ -11365,6 +11366,7 @@ function openGridSnapshot() {
|
||||
};
|
||||
gridSnapshotOpen.value = true;
|
||||
}
|
||||
useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.value || isSaving.value ? t("updates.preparationDrafts") : undefined));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -40,6 +40,7 @@ const props = defineProps<{
|
||||
showDriverStore: boolean;
|
||||
showSettingsPage: boolean;
|
||||
checkingUpdates: boolean;
|
||||
updateVersion?: string;
|
||||
hasUpdateAvailable: boolean;
|
||||
isDownloadingUpdate: boolean;
|
||||
downloadProgress: number | null;
|
||||
@@ -75,15 +76,12 @@ const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
|
||||
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } = useWindowControls();
|
||||
const checkingUpdates = computed(() => props.checkingUpdates);
|
||||
const updateTooltip = computed(() => {
|
||||
if (props.isDownloadingUpdate) return t("updates.downloading", { progress: props.downloadProgress ?? 0 });
|
||||
if (props.updateReady) return t("updates.restartRequiredTooltip");
|
||||
if (props.updateReadyToInstall) return t("updates.updateReadyTooltip");
|
||||
if (props.hasUpdateAvailable && props.updateReady) return t("updates.restartRequiredTooltip");
|
||||
if (props.hasUpdateAvailable && props.updateReadyToInstall) return t("updates.downloadedReady", { version: props.updateVersion ?? "" });
|
||||
return t("updates.check");
|
||||
});
|
||||
// ToolbarUpdateIcon takes a 0..1 fraction, with null meaning the download size is unknown.
|
||||
const updateDownloadProgress = computed(() => (props.downloadProgress == null ? null : Math.min(1, Math.max(0, props.downloadProgress / 100))));
|
||||
|
||||
const sqlLibrarySaveFeedbackActive = ref(false);
|
||||
const SQL_LIBRARY_BOOKMARK_PATH = "M10 2 L10 10 L13 7 L16 10 L16 2";
|
||||
const SQL_LIBRARY_CHECK_PATH = "M9 9.5 L9 9.5 L11 11.5 L15 7.5 L15 7.5";
|
||||
@@ -199,7 +197,7 @@ const collapsibleRightItemDefs = computed(() => {
|
||||
label: t("updates.check"),
|
||||
icon: CloudDownload,
|
||||
action: () => emit("check-updates"),
|
||||
disabled: checkingUpdates.value,
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
@@ -612,10 +610,9 @@ const toolbarStyle = computed(() => {
|
||||
<template v-if="toolbarItems.checkUpdates">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button v-show="isRightItemVisible('checkUpdates')" data-toolbar-update-trigger variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :disabled="checkingUpdates" @click="emit('check-updates')">
|
||||
<ToolbarUpdateIcon :loading="checkingUpdates" :downloading="isDownloadingUpdate" :progress="updateDownloadProgress" />
|
||||
<span v-if="updateReady || updateReadyToInstall" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-background" />
|
||||
<span v-else-if="hasUpdateAvailable && !isDownloadingUpdate" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
<Button v-show="isRightItemVisible('checkUpdates')" data-toolbar-update-trigger variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" @click="emit('check-updates')">
|
||||
<ToolbarUpdateIcon />
|
||||
<span v-if="hasUpdateAvailable" class="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ updateTooltip }}</TooltipContent>
|
||||
|
||||
@@ -1,82 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Cloud, CloudDownload } from "@lucide/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean;
|
||||
/** Whether an update is downloading; takes precedence over `loading`. */
|
||||
downloading?: boolean;
|
||||
/** Download completion in 0..1, or null when the backend reports no total. */
|
||||
progress?: number | null;
|
||||
}>();
|
||||
|
||||
const progressRingStyle = computed(() => {
|
||||
// Without a known total the ring cannot grow, so render a rotating partial arc
|
||||
// instead of an invisible 0% one.
|
||||
const percent = props.progress == null ? 25 : Math.round(Math.min(1, Math.max(0, props.progress)) * 100);
|
||||
return {
|
||||
background: `conic-gradient(var(--primary) ${percent}%, transparent ${percent}%)`,
|
||||
};
|
||||
});
|
||||
import { CloudDownload } from "@lucide/vue";
|
||||
// Background checking/downloading deliberately never animates the toolbar.
|
||||
defineProps<{ loading?: boolean; downloading?: boolean; progress?: number | null }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span data-toolbar-update-icon class="relative block h-4 w-4" :class="{ 'toolbar-update-icon--loading': loading }" aria-hidden="true">
|
||||
<span v-if="downloading" data-toolbar-update-progress class="toolbar-update-progress absolute inset-0 rounded-full" :class="{ 'toolbar-update-progress--indeterminate': progress == null }" :style="progressRingStyle" />
|
||||
<CloudDownload v-else-if="!loading" data-toolbar-update-idle class="toolbar-update-idle h-4 w-4" />
|
||||
<template v-else>
|
||||
<Cloud data-toolbar-update-cloud class="toolbar-update-cloud absolute inset-0 h-4 w-4" />
|
||||
<Cloud data-toolbar-update-scan class="toolbar-update-scan absolute inset-0 h-4 w-4" />
|
||||
</template>
|
||||
<span data-toolbar-update-icon class="relative block h-4 w-4" aria-hidden="true">
|
||||
<CloudDownload data-toolbar-update-idle class="h-4 w-4" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-update-cloud {
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.toolbar-update-scan {
|
||||
color: #000;
|
||||
stroke-dasharray: 7 36;
|
||||
stroke-dashoffset: 0;
|
||||
animation: toolbar-update-outline-scan 1100ms linear infinite;
|
||||
}
|
||||
|
||||
:global(.dark) .toolbar-update-scan {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toolbar-update-progress {
|
||||
/* Same 1.75px ring the toolbar previously drew inline for background downloads. */
|
||||
mask: radial-gradient(farthest-side, transparent calc(100% - 1.75px), #000 calc(100% - 1.75px));
|
||||
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 1.75px), #000 calc(100% - 1.75px));
|
||||
}
|
||||
|
||||
.toolbar-update-progress--indeterminate {
|
||||
animation: toolbar-update-progress-spin 1100ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes toolbar-update-outline-scan {
|
||||
to {
|
||||
stroke-dashoffset: -43;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toolbar-update-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toolbar-update-scan {
|
||||
animation: none;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
|
||||
.toolbar-update-progress--indeterminate {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+18
-5
@@ -14,6 +14,7 @@ const mountedApps: App[] = [];
|
||||
interface DialogState {
|
||||
open: boolean;
|
||||
portableMode: boolean;
|
||||
releaseNotes: string;
|
||||
manualUpdateOnly: boolean;
|
||||
isDownloadingUpdate: boolean;
|
||||
downloadProgress: number | null;
|
||||
@@ -32,6 +33,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
|
||||
const state = reactive<DialogState>({
|
||||
open: true,
|
||||
portableMode: false,
|
||||
releaseNotes: "",
|
||||
manualUpdateOnly: false,
|
||||
isDownloadingUpdate: false,
|
||||
downloadProgress: 0,
|
||||
@@ -76,7 +78,7 @@ async function mountDialog(activeTaskCount: number, initialState: Partial<Dialog
|
||||
manual_update_only: state.manualUpdateOnly,
|
||||
release_name: "DBX v0.5.61",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.61",
|
||||
release_notes: "",
|
||||
release_notes: state.releaseNotes,
|
||||
},
|
||||
updateCheckMessage: "",
|
||||
checkingUpdates: false,
|
||||
@@ -110,7 +112,7 @@ function buttonWithText(text: string): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
function downloadButton(): HTMLButtonElement | undefined {
|
||||
return buttonWithText("Download in Background");
|
||||
return buttonWithText("Retry Download");
|
||||
}
|
||||
|
||||
function cancelDownloadButton(): HTMLButtonElement | undefined {
|
||||
@@ -118,7 +120,7 @@ function cancelDownloadButton(): HTMLButtonElement | undefined {
|
||||
}
|
||||
|
||||
function installDownloadedButton(): HTMLButtonElement | undefined {
|
||||
return buttonWithText("Exit & Update");
|
||||
return buttonWithText("Restart & Update");
|
||||
}
|
||||
|
||||
async function pressEscape() {
|
||||
@@ -338,10 +340,10 @@ describe("UpdateDialog ignore version", () => {
|
||||
expect(ignoreVersion).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hides the ignore button once an update has been downloaded", async () => {
|
||||
it("allows ignoring a downloaded update", async () => {
|
||||
await mountDialog(0, { updateDownloaded: true, downloadProgress: 100 });
|
||||
|
||||
expect(buttonWithText("Ignore this version")).toBeUndefined();
|
||||
expect(buttonWithText("Ignore this version")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables the ignore button while the setting is being persisted", async () => {
|
||||
@@ -350,3 +352,14 @@ describe("UpdateDialog ignore version", () => {
|
||||
expect(buttonWithText("Ignore this version")?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateDialog release notes safety", () => {
|
||||
it("renders remote HTML as text and never creates unsafe links or image requests", async () => {
|
||||
await mountDialog(0, { releaseNotes: '<img src="https://example.com/tracker" onerror="alert(1)"><script>alert(1)</script> [bad](javascript:alert)  [safe](https://example.com/release)' });
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.querySelector('a[href="https://example.com/release"]')).not.toBeNull();
|
||||
});
|
||||
expect(document.body.querySelector("script, img")).toBeNull();
|
||||
expect(Array.from(document.body.querySelectorAll("a")).every((anchor) => anchor.href.startsWith("https://"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
downloadProgress: number | null;
|
||||
updateDownloaded: boolean;
|
||||
isInstallingUpdate: boolean;
|
||||
isPreparingUpdate?: boolean;
|
||||
updateReady: boolean;
|
||||
isIgnoringUpdate: boolean;
|
||||
activeTaskCount: number;
|
||||
@@ -45,7 +46,7 @@ const renderedNotes = ref("");
|
||||
// download survives the dialog closing, so closing it never cancels the download.
|
||||
const isCloseBlocked = computed(() => props.isInstallingUpdate);
|
||||
const blocksImplicitDismiss = computed(() => props.isInstallingUpdate);
|
||||
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.updateDownloaded && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
|
||||
const canIgnoreVersion = computed(() => props.updateInfo?.update_available === true && !props.isDownloadingUpdate && !props.isInstallingUpdate && !props.updateReady);
|
||||
|
||||
function handleCancel() {
|
||||
handleOpenChange(false);
|
||||
@@ -66,7 +67,7 @@ function handleReleaseNotesClick(event: MouseEvent) {
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const url = anchor.getAttribute("href");
|
||||
if (!url) return;
|
||||
if (!url || !/^https?:\/\//i.test(url)) return;
|
||||
if (isTauriRuntime()) {
|
||||
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
|
||||
} else {
|
||||
@@ -82,7 +83,19 @@ watch(
|
||||
return;
|
||||
}
|
||||
const { Marked } = await import("marked");
|
||||
const marked = new Marked({ breaks: true, gfm: true });
|
||||
const escapeHtml = (value: string) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
const marked = new Marked({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer: {
|
||||
html: ({ text }) => escapeHtml(text),
|
||||
link({ href, tokens }) {
|
||||
const text = this.parser.parseInline(tokens);
|
||||
return /^https?:\/\//i.test(href) ? `<a href="${escapeHtml(href)}" rel="noopener noreferrer">${text}</a>` : text;
|
||||
},
|
||||
image: ({ text }) => escapeHtml(text),
|
||||
},
|
||||
});
|
||||
renderedNotes.value = marked.parse(notes) as string;
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -117,7 +130,7 @@ watch(
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-else class="text-muted-foreground">
|
||||
<p v-else-if="!checkingUpdates" class="text-muted-foreground">
|
||||
{{ updateCheckMessage || t("updates.upToDate", { version: updateInfo?.current_version || "" }) }}
|
||||
</p>
|
||||
<div
|
||||
@@ -142,10 +155,13 @@ watch(
|
||||
<span>{{ t("updates.activeTasksBlockUpdate", { count: activeTaskCount }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="checkingUpdates" class="text-sm text-muted-foreground">{{ t("updates.checking") }}</p>
|
||||
<p v-if="updateDownloaded && !isInstallingUpdate" class="text-sm">{{ t("updates.downloadedReady", { version: updateInfo?.latest_version }) }}</p>
|
||||
<p v-if="updateCheckFailed && updateInfo?.update_available" role="alert" class="text-sm text-destructive">{{ updateCheckMessage }}</p>
|
||||
<DialogFooter class="relative min-w-0 sm:justify-end">
|
||||
<div v-if="updateCheckFailed && !isDownloadingUpdate && !updateReady" class="flex items-center gap-1.5 self-start sm:absolute sm:left-4 sm:top-1/2 sm:-translate-y-1/2">
|
||||
<div v-if="!updateReady && !isInstallingUpdate && !updateDownloaded && (updateInfo?.update_available || updateCheckFailed)" class="flex items-center gap-1.5 self-start sm:absolute sm:left-4 sm:top-1/2 sm:-translate-y-1/2">
|
||||
<span class="text-xs text-muted-foreground">{{ t("updates.source") }}</span>
|
||||
<Select :model-value="updateDownloadSource" :disabled="checkingUpdates" @update:model-value="(value) => value && emit('change-download-source', value as UpdateDownloadSource)">
|
||||
<Select :model-value="updateDownloadSource" :disabled="checkingUpdates || isIgnoringUpdate" @update:model-value="(value) => value && emit('change-download-source', value as UpdateDownloadSource)">
|
||||
<SelectTrigger class="h-8 w-[150px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -165,10 +181,10 @@ watch(
|
||||
<Button variant="outline" class="shrink-0" @click="emit('open-latest-release')">{{ t("updates.openRelease") }}</Button>
|
||||
</div>
|
||||
<template v-if="canDownloadAndInstallUpdate(updateInfo, isDesktop)">
|
||||
<Button v-if="updateReady" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('restart')">{{ t("updates.restart") }}</Button>
|
||||
<Button v-if="updateReady" class="shrink-0" :disabled="activeTaskCount > 0 || isIgnoringUpdate" @click="emit('restart')">{{ t("updates.restart") }}</Button>
|
||||
<Button v-else-if="isInstallingUpdate" class="shrink-0" disabled>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("updates.installing") }}
|
||||
{{ t(isPreparingUpdate ? "updates.preparing" : "updates.installing") }}
|
||||
</Button>
|
||||
<template v-else-if="isDownloadingUpdate">
|
||||
<Button variant="ghost" class="shrink-0" @click="emit('cancel-download')">{{ t("updates.cancelDownload") }}</Button>
|
||||
@@ -177,8 +193,8 @@ watch(
|
||||
{{ t("updates.downloading", { progress: downloadProgress ?? 0 }) }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button v-else-if="updateDownloaded" class="shrink-0" :disabled="activeTaskCount > 0" @click="emit('install-downloaded')">{{ t("updates.exitAndUpdate") }}</Button>
|
||||
<Button v-else class="shrink-0" @click="emit('download-in-background')">{{ t("updates.downloadInBackground") }}</Button>
|
||||
<Button v-else-if="updateDownloaded" class="shrink-0" :disabled="activeTaskCount > 0 || isIgnoringUpdate" @click="emit('install-downloaded')">{{ t("updates.restartAndUpdate") }}</Button>
|
||||
<Button v-else-if="!checkingUpdates" class="shrink-0" :disabled="isIgnoringUpdate" @click="emit('download-in-background')">{{ t("updates.retryDownload") }}</Button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -1,89 +1,14 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createApp, h } from "vue";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ToolbarUpdateIcon from "../ToolbarUpdateIcon.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ToolbarUpdateIcon", () => {
|
||||
it("uses cloud download while idle and a cloud outline scan while checking", async () => {
|
||||
const state = reactive({ loading: false });
|
||||
describe("silent toolbar update icon", () => {
|
||||
it.each([{ loading: false }, { loading: true }, { downloading: true, progress: 0.4 }, { downloading: true, progress: null }])("keeps the ordinary icon for %o", (props) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(ToolbarUpdateIcon, { loading: state.loading }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
const app = createApp({ render: () => h(ToolbarUpdateIcon, props) });
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
|
||||
|
||||
state.loading = true;
|
||||
await nextTick();
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).not.toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-scan]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows a progress ring while downloading, growing with progress", async () => {
|
||||
const state = reactive({ downloading: true, progress: 0.42 });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(ToolbarUpdateIcon, { loading: false, downloading: state.downloading, progress: state.progress }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
const ring = container.querySelector<HTMLElement>("[data-toolbar-update-progress]");
|
||||
expect(ring).not.toBeNull();
|
||||
expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(false);
|
||||
expect(ring?.style.background).toBe("conic-gradient(var(--primary) 42%, transparent 42%)");
|
||||
// The idle and checking glyphs stay hidden behind the ring.
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
|
||||
|
||||
state.progress = 1;
|
||||
await nextTick();
|
||||
expect(ring?.style.background).toBe("conic-gradient(var(--primary) 100%, transparent 100%)");
|
||||
|
||||
state.downloading = false;
|
||||
await nextTick();
|
||||
expect(container.querySelector("[data-toolbar-update-progress]")).toBeNull();
|
||||
expect(container.querySelector("[data-toolbar-update-idle]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to a rotating indeterminate arc when the download size is unknown", async () => {
|
||||
const state = reactive({ downloading: true, progress: null as number | null });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(ToolbarUpdateIcon, { loading: true, downloading: state.downloading, progress: state.progress }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
|
||||
const ring = container.querySelector<HTMLElement>("[data-toolbar-update-progress]");
|
||||
expect(ring).not.toBeNull();
|
||||
// Indeterminate downloading outranks the checking animation.
|
||||
expect(container.querySelector("[data-toolbar-update-cloud]")).toBeNull();
|
||||
expect(ring?.classList.contains("toolbar-update-progress--indeterminate")).toBe(true);
|
||||
expect(ring?.style.background).toBe("conic-gradient(var(--primary) 25%, transparent 25%)");
|
||||
expect(container.querySelector("[data-toolbar-update-progress], [data-toolbar-update-scan]")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
||||
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
|
||||
import { Compartment, StateEffect, StateField } from "@codemirror/state";
|
||||
import { ensureSyntaxTree } from "@codemirror/language";
|
||||
@@ -2659,6 +2660,29 @@ function openNacosConsole() {
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
useUpdateBlocker(() =>
|
||||
isConfigDirty.value ||
|
||||
isCreatingConfig.value ||
|
||||
savingConfig.value ||
|
||||
deletingConfig.value ||
|
||||
pendingConfigSave.value ||
|
||||
pendingDeleteConfig.value ||
|
||||
pendingBatchDelete.value ||
|
||||
pendingHistoryRollback.value ||
|
||||
rollingBackHistory.value ||
|
||||
instanceEditorOpen.value ||
|
||||
serviceEditorOpen.value ||
|
||||
registerInstanceOpen.value ||
|
||||
pendingInstanceUpdate.value ||
|
||||
pendingInstanceDeregister.value ||
|
||||
pendingServiceDelete.value ||
|
||||
deletingService.value ||
|
||||
registeringInstance.value ||
|
||||
Object.keys(updatingInstanceKeys.value).length > 0 ||
|
||||
Object.keys(instanceWeightDrafts.value).length > 0
|
||||
? t("updates.preparationDrafts")
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+4
-1
@@ -16,7 +16,10 @@ const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
nacosDeleteConfig: mocks.nacosDeleteConfig,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useUpdateBlocker } from "@/lib/app/updatePreparation";
|
||||
import { computed, ref, shallowRef, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -2710,6 +2711,7 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
useUpdateBlocker(() => (hasUnsavedRedisDraft.value || editingTtl.value || savingTtl.value || savingString.value || savingJson.value || savingMember.value || savingZsetMember.value || savingHashFieldTtl.value || showRenameKeyDialog.value ? t("updates.preparationDrafts") : undefined));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+255
-323
@@ -1,59 +1,32 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, ref, type App } from "vue";
|
||||
import { createApp, defineComponent, h, reactive, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import { useAppUpdater } from "@/composables/useAppUpdater";
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
cancelUpdateDownload: vi.fn<() => Promise<void>>(),
|
||||
downloadUpdate: vi.fn<() => Promise<void>>(),
|
||||
installDownloadedUpdate: vi.fn<() => Promise<void>>(),
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
cancelUpdateDownload: vi.fn(),
|
||||
installDownloadedUpdate: vi.fn(),
|
||||
getDownloadedUpdate: vi.fn(),
|
||||
discardDownloadedUpdate: vi.fn(),
|
||||
getAppVersion: vi.fn(),
|
||||
listen: vi.fn(),
|
||||
relaunch: vi.fn(),
|
||||
persist: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
const listenMock = vi.hoisted(() => vi.fn());
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({
|
||||
isTauriRuntime: () => true,
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
const settingsStoreMock = vi.hoisted(() => ({
|
||||
editorSettings: { updateDownloadSource: "official", ignoredUpdateVersion: "" },
|
||||
updateEditorSettingsAndPersist: vi.fn<() => Promise<void>>(),
|
||||
}));
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => settingsStoreMock,
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: listenMock,
|
||||
}));
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: Deferred<T>["resolve"];
|
||||
let reject!: Deferred<T>["reject"];
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
let app: App | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
function mountUpdater(options: { getActiveTaskCount?: () => number } = {}) {
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
|
||||
vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: mocks.relaunch }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
const settings = reactive({ updateDownloadSource: "official", ignoredUpdateVersion: "", updateNotificationsEnabled: true });
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: settings, updateEditorSettingsAndPersist: mocks.persist }) }));
|
||||
const info = { current_version: "1.0.0", latest_version: "1.1.0", update_available: true, portable_mode: false, manual_update_only: false, release_name: "v1.1.0", release_url: "https://example.com", release_notes: "Changes" };
|
||||
const cache = { cache_id: "cached", version: "1.1.0", portable_mode: false, release_url: "https://example.com", release_notes: "Changes", downloaded_at: 1 };
|
||||
let app: App;
|
||||
function mount(options: Parameters<typeof useAppUpdater>[0] = {}) {
|
||||
let updater!: ReturnType<typeof useAppUpdater>;
|
||||
app = createApp(
|
||||
defineComponent({
|
||||
@@ -64,299 +37,258 @@ function mountUpdater(options: { getActiveTaskCount?: () => number } = {}) {
|
||||
}),
|
||||
);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
updater.updateInfo.value = {
|
||||
current_version: "0.5.69",
|
||||
latest_version: "0.5.70",
|
||||
update_available: true,
|
||||
release_name: "DBX v0.5.70",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
|
||||
release_notes: "",
|
||||
};
|
||||
app.mount(document.createElement("div"));
|
||||
return updater;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
async function flush() {
|
||||
for (let i = 0; i < 12; i++) await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
settingsStoreMock.editorSettings.ignoredUpdateVersion = "";
|
||||
settingsStoreMock.updateEditorSettingsAndPersist.mockResolvedValue();
|
||||
listenMock.mockResolvedValue(vi.fn());
|
||||
apiMock.installDownloadedUpdate.mockResolvedValue();
|
||||
vi.resetAllMocks();
|
||||
settings.updateDownloadSource = "official";
|
||||
settings.ignoredUpdateVersion = "";
|
||||
settings.updateNotificationsEnabled = true;
|
||||
mocks.checkForUpdates.mockResolvedValue(info);
|
||||
mocks.downloadUpdate.mockResolvedValue(cache);
|
||||
mocks.getDownloadedUpdate.mockResolvedValue(null);
|
||||
mocks.getAppVersion.mockResolvedValue("1.0.0");
|
||||
mocks.listen.mockResolvedValue(vi.fn());
|
||||
mocks.persist.mockImplementation(async (values) => Object.assign(settings, values));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
container?.remove();
|
||||
app = undefined;
|
||||
container = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("useAppUpdater download attempts", () => {
|
||||
it("waits for cancellation and ignores stale completion from the previous attempt", async () => {
|
||||
const firstDownload = deferred<void>();
|
||||
const secondDownload = deferred<void>();
|
||||
const cancellation = deferred<void>();
|
||||
apiMock.downloadUpdate.mockImplementationOnce(() => firstDownload.promise).mockImplementationOnce(() => secondDownload.promise);
|
||||
apiMock.cancelUpdateDownload.mockReturnValueOnce(cancellation.promise);
|
||||
|
||||
const updater = mountUpdater();
|
||||
|
||||
const firstAttempt = updater.downloadUpdateInBackground();
|
||||
await vi.waitFor(() => expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(1));
|
||||
|
||||
const cancelAttempt = updater.cancelDownload();
|
||||
expect(updater.isDownloadingUpdate.value).toBe(false);
|
||||
|
||||
const retryAttempt = updater.downloadUpdateInBackground();
|
||||
await Promise.resolve();
|
||||
expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(updater.isDownloadingUpdate.value).toBe(true);
|
||||
|
||||
cancellation.resolve();
|
||||
await cancelAttempt;
|
||||
await vi.waitFor(() => expect(apiMock.downloadUpdate).toHaveBeenCalledTimes(2));
|
||||
|
||||
firstDownload.reject(new Error("Download canceled by user."));
|
||||
await firstAttempt;
|
||||
expect(updater.isDownloadingUpdate.value).toBe(true);
|
||||
// The retry is in flight with no progress event yet, so progress is indeterminate.
|
||||
expect(updater.downloadProgress.value).toBeNull();
|
||||
|
||||
secondDownload.resolve();
|
||||
await retryAttempt;
|
||||
|
||||
// No active tasks by default, so the successful download auto-installs and lands on
|
||||
// "ready to restart" instead of waiting for a manual "Install Now" click.
|
||||
expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce();
|
||||
expect(updater.isDownloadingUpdate.value).toBe(false);
|
||||
expect(updater.downloadProgress.value).toBe(100);
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
expect(updater.updateReady.value).toBe(true);
|
||||
expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) }));
|
||||
});
|
||||
|
||||
it("auto-installs once the download finishes while idle, without waiting for a manual click", async () => {
|
||||
apiMock.downloadUpdate.mockResolvedValueOnce();
|
||||
const updater = mountUpdater();
|
||||
updater.showUpdateDialog.value = true;
|
||||
|
||||
const attempt = updater.downloadUpdateInBackground();
|
||||
describe("silent update lifecycle", () => {
|
||||
it("automatically downloads without surfacing or installing, even while idle", async () => {
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes");
|
||||
expect(updater.phase.value).toBe("ready");
|
||||
expect(updater.hasUpdateAvailable.value).toBe(true);
|
||||
expect(updater.showUpdateDialog.value).toBe(false);
|
||||
await attempt;
|
||||
|
||||
expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce();
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
expect(updater.updateReady.value).toBe(true);
|
||||
expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) }));
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
|
||||
expect(mocks.relaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a manual Install Now toast when tasks are still active once the download finishes", async () => {
|
||||
apiMock.downloadUpdate.mockResolvedValueOnce();
|
||||
// Must be a reactive ref, not a plain closure variable — useAppUpdater's activeTaskCount
|
||||
// is a computed(), which only invalidates its cache when a tracked reactive source changes.
|
||||
const activeTaskCount = ref(2);
|
||||
const updater = mountUpdater({ getActiveTaskCount: () => activeTaskCount.value });
|
||||
|
||||
await updater.downloadUpdateInBackground();
|
||||
|
||||
expect(apiMock.installDownloadedUpdate).not.toHaveBeenCalled();
|
||||
it("shows no badge until a download finishes, and closing does not cancel", async () => {
|
||||
const pending = deferred<typeof cache>();
|
||||
mocks.downloadUpdate.mockReturnValue(pending.promise);
|
||||
const updater = mount();
|
||||
const checking = updater.checkUpdates();
|
||||
await flush();
|
||||
expect(updater.hasUpdateAvailable.value).toBe(false);
|
||||
updater.showUpdateDialog.value = false;
|
||||
pending.resolve(cache);
|
||||
await checking;
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
expect(toastMock).toHaveBeenLastCalledWith("DBX v0.5.70 is ready to install.", 10000, expect.objectContaining({ label: "Install Now", onClick: expect.any(Function) }));
|
||||
|
||||
activeTaskCount.value = 0;
|
||||
const [, , action] = toastMock.mock.lastCall!;
|
||||
action.onClick();
|
||||
|
||||
await vi.waitFor(() => expect(apiMock.installDownloadedUpdate).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(toastMock).toHaveBeenLastCalledWith("DBX has been updated. Restart to finish.", 10000, expect.objectContaining({ label: "Exit & Restart", onClick: expect.any(Function) })));
|
||||
expect(updater.updateReady.value).toBe(true);
|
||||
expect(mocks.cancelUpdateDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tracks download progress and stays indeterminate when the backend reports no total", async () => {
|
||||
const download = deferred<void>();
|
||||
apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
|
||||
const updater = mountUpdater();
|
||||
|
||||
const downloadAttempt = updater.downloadUpdateInBackground();
|
||||
await vi.waitFor(() => expect(listenMock).toHaveBeenCalledOnce());
|
||||
// No progress event yet — the size is unknown, so progress stays indeterminate.
|
||||
expect(updater.downloadProgress.value).toBeNull();
|
||||
|
||||
const onProgress = listenMock.mock.calls[0][1] as (event: { payload: { downloaded: number; total: number | null } }) => void;
|
||||
onProgress({ payload: { downloaded: 25, total: 100 } });
|
||||
expect(updater.downloadProgress.value).toBe(25);
|
||||
|
||||
// Mirrors may stream chunks without a total; progress must not freeze at a stale percentage.
|
||||
onProgress({ payload: { downloaded: 64, total: null } });
|
||||
expect(updater.downloadProgress.value).toBeNull();
|
||||
|
||||
download.resolve();
|
||||
await downloadAttempt;
|
||||
expect(updater.downloadProgress.value).toBe(100);
|
||||
it("restores offline and one click saves before installing and restarting without downloading", async () => {
|
||||
mocks.getDownloadedUpdate.mockResolvedValue(cache);
|
||||
const release = vi.fn();
|
||||
const prepare = vi.fn(async () => release);
|
||||
const updater = mount({ prepareForUpdate: prepare });
|
||||
await updater.initialize();
|
||||
await updater.installDownloadedUpdate();
|
||||
expect(mocks.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(mocks.downloadUpdate).not.toHaveBeenCalled();
|
||||
expect(mocks.installDownloadedUpdate).toHaveBeenCalledWith("cached", "1.1.0");
|
||||
expect(prepare.mock.invocationCallOrder[0]).toBeLessThan(mocks.installDownloadedUpdate.mock.invocationCallOrder[0]);
|
||||
expect(mocks.installDownloadedUpdate.mock.invocationCallOrder[0]).toBeLessThan(mocks.relaunch.mock.invocationCallOrder[0]);
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAppUpdater reopening the dialog from the toolbar", () => {
|
||||
it("resurfaces the dialog without re-checking while a download is in progress", async () => {
|
||||
const download = deferred<void>();
|
||||
apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
|
||||
const updater = mountUpdater();
|
||||
|
||||
const downloadAttempt = updater.downloadUpdateInBackground();
|
||||
await vi.waitFor(() => expect(updater.isDownloadingUpdate.value).toBe(true));
|
||||
|
||||
await updater.checkUpdates();
|
||||
|
||||
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(updater.showUpdateDialog.value).toBe(true);
|
||||
|
||||
download.resolve();
|
||||
await downloadAttempt;
|
||||
it("preparation failure retains the package and repeated clicks cannot bypass preparation", async () => {
|
||||
mocks.getDownloadedUpdate.mockResolvedValue(cache);
|
||||
const pending = deferred<() => void>();
|
||||
const updater = mount({ prepareForUpdate: () => pending.promise });
|
||||
await updater.checkUpdates({ silent: true });
|
||||
const install = updater.installDownloadedUpdate();
|
||||
await updater.installDownloadedUpdate();
|
||||
pending.reject(new Error("unsaved grid"));
|
||||
await install;
|
||||
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
|
||||
expect(updater.phase.value).toBe("ready");
|
||||
expect(updater.updateCheckMessage.value).toContain("unsaved grid");
|
||||
});
|
||||
|
||||
it("resurfaces the dialog without re-checking once the update is downloaded and ready to install", async () => {
|
||||
apiMock.downloadUpdate.mockResolvedValueOnce();
|
||||
// Busy at download-completion time so the install step stays pending instead of auto-running.
|
||||
const updater = mountUpdater({ getActiveTaskCount: () => 1 });
|
||||
|
||||
await updater.downloadUpdateInBackground();
|
||||
it("blocks installation for active work but never blocks automatic download", async () => {
|
||||
const updater = mount({ getActiveTaskCount: () => 2 });
|
||||
await updater.checkUpdates({ silent: true });
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
await updater.installDownloadedUpdate();
|
||||
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
it("retries only restart after relaunch fails", async () => {
|
||||
mocks.relaunch.mockRejectedValueOnce(new Error("restart failed"));
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.installDownloadedUpdate();
|
||||
await updater.restartApp();
|
||||
expect(mocks.installDownloadedUpdate).toHaveBeenCalledOnce();
|
||||
expect(mocks.relaunch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("persists ignore before deleting a ready cache", async () => {
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.ignoreCurrentVersion();
|
||||
expect(mocks.persist).toHaveBeenCalledWith({ ignoredUpdateVersion: "1.1.0" });
|
||||
expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith("cached");
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
});
|
||||
it("keeps ready cache if ignoring cannot persist", async () => {
|
||||
mocks.persist.mockRejectedValue(new Error("disk full"));
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.ignoreCurrentVersion();
|
||||
expect(mocks.discardDownloadedUpdate).not.toHaveBeenCalled();
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
});
|
||||
it("hides ready badge when notifications disabled but preserves manual installation", async () => {
|
||||
mocks.getDownloadedUpdate.mockResolvedValue(cache);
|
||||
const updater = mount();
|
||||
await updater.initialize();
|
||||
settings.updateNotificationsEnabled = false;
|
||||
await flush();
|
||||
expect(updater.hasUpdateAvailable.value).toBe(false);
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
expect(mocks.discardDownloadedUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
it("ignores progress belonging to another version or download attempt", async () => {
|
||||
const pending = deferred<typeof cache>();
|
||||
mocks.downloadUpdate.mockReturnValue(pending.promise);
|
||||
const updater = mount();
|
||||
const checking = updater.checkUpdates({ silent: true });
|
||||
await vi.waitFor(() => expect(mocks.listen).toHaveBeenCalled());
|
||||
const callback = mocks.listen.mock.calls[0][1];
|
||||
const attempt = mocks.downloadUpdate.mock.calls[0][2];
|
||||
callback({ payload: { downloaded: 90, total: 100, attempt_id: "old", version: "1.1.0" } });
|
||||
expect(updater.downloadProgress.value).toBeNull();
|
||||
callback({ payload: { downloaded: 30, total: 100, attempt_id: attempt, version: "1.1.0" } });
|
||||
expect(updater.downloadProgress.value).toBe(30);
|
||||
pending.resolve(cache);
|
||||
await checking;
|
||||
});
|
||||
it("silently backs off after failed downloads at 1, 5 and 15 minutes", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.downloadUpdate.mockRejectedValue(new Error("network down"));
|
||||
const updater = mount();
|
||||
await updater.initialize();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1);
|
||||
for (const [index, delay] of [60_000, 300_000, 900_000].entries()) {
|
||||
await vi.advanceTimersByTimeAsync(delay);
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(index + 2);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(4);
|
||||
expect(updater.showUpdateDialog.value).toBe(false);
|
||||
|
||||
await updater.checkUpdates();
|
||||
|
||||
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(updater.showUpdateDialog.value).toBe(true);
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
it("changing source keeps an already prepared package", async () => {
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.changeUpdateDownloadSource("cnb");
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledOnce();
|
||||
expect(mocks.checkForUpdates).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("reconciles a download that commits just before cancellation without installing it", async () => {
|
||||
const pending = deferred<typeof cache>();
|
||||
mocks.downloadUpdate.mockReturnValue(pending.promise);
|
||||
mocks.getDownloadedUpdate.mockResolvedValue(cache);
|
||||
mocks.cancelUpdateDownload.mockImplementation(async () => {
|
||||
pending.resolve(cache);
|
||||
});
|
||||
const updater = mount();
|
||||
const check = updater.checkUpdates({ silent: true });
|
||||
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalled());
|
||||
await updater.cancelDownload();
|
||||
await check;
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not resurrect a logically ignored package if removing its files fails", async () => {
|
||||
mocks.discardDownloadedUpdate.mockRejectedValue(new Error("file busy"));
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.ignoreCurrentVersion();
|
||||
expect(settings.ignoredUpdateVersion).toBe("1.1.0");
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
expect(updater.hasUpdateAvailable.value).toBe(false);
|
||||
expect(updater.updateCheckMessage.value).toContain("file busy");
|
||||
});
|
||||
it("clears a corrupt installation cache so download can be retried", async () => {
|
||||
mocks.installDownloadedUpdate.mockRejectedValue(new Error("signature invalid"));
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.installDownloadedUpdate();
|
||||
expect(updater.phase.value).toBe("idle");
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
expect(mocks.relaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resurfaces the dialog without re-checking once the update is installed and awaiting restart", async () => {
|
||||
apiMock.downloadUpdate.mockResolvedValueOnce();
|
||||
const updater = mountUpdater();
|
||||
it("holds the preparation barrier through install and releases it only on failure", async () => {
|
||||
const release = vi.fn();
|
||||
mocks.installDownloadedUpdate.mockRejectedValue(new Error("installer failed"));
|
||||
mocks.getDownloadedUpdate.mockResolvedValue(cache);
|
||||
const updater = mount({ prepareForUpdate: async () => release });
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.installDownloadedUpdate();
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
});
|
||||
it("checks fresh metadata immediately once when the downloaded version changes", async () => {
|
||||
mocks.downloadUpdate.mockRejectedValueOnce(new Error("Update version changed; check for updates again."));
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.checkForUpdates).toHaveBeenCalledTimes(2);
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot install or switch sources while an ignore setting is being persisted", async () => {
|
||||
const persist = deferred<void>();
|
||||
mocks.persist.mockReturnValue(persist.promise);
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
const ignoring = updater.ignoreCurrentVersion();
|
||||
await updater.installDownloadedUpdate();
|
||||
await updater.changeUpdateDownloadSource("cnb");
|
||||
await updater.checkUpdates({ silent: true });
|
||||
await updater.downloadUpdateInBackground();
|
||||
expect(updater.updateReady.value).toBe(true);
|
||||
|
||||
await updater.checkUpdates();
|
||||
|
||||
expect(apiMock.checkForUpdates).not.toHaveBeenCalled();
|
||||
expect(updater.showUpdateDialog.value).toBe(true);
|
||||
expect(mocks.checkForUpdates).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
|
||||
expect(mocks.persist).toHaveBeenCalledTimes(1);
|
||||
persist.resolve();
|
||||
await ignoring;
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
});
|
||||
|
||||
it("still checks the network for a silent background check even mid-download", async () => {
|
||||
const download = deferred<void>();
|
||||
apiMock.downloadUpdate.mockReturnValueOnce(download.promise);
|
||||
apiMock.checkForUpdates.mockResolvedValueOnce({
|
||||
current_version: "0.5.69",
|
||||
latest_version: "0.5.70",
|
||||
update_available: true,
|
||||
release_name: "DBX v0.5.70",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
|
||||
release_notes: "",
|
||||
});
|
||||
const updater = mountUpdater();
|
||||
|
||||
const downloadAttempt = updater.downloadUpdateInBackground();
|
||||
await vi.waitFor(() => expect(updater.isDownloadingUpdate.value).toBe(true));
|
||||
|
||||
await updater.checkUpdates({ silent: true });
|
||||
|
||||
expect(apiMock.checkForUpdates).toHaveBeenCalledOnce();
|
||||
|
||||
download.resolve();
|
||||
await downloadAttempt;
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAppUpdater failure state handling", () => {
|
||||
interface UpdateInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
update_available: boolean;
|
||||
release_name: string;
|
||||
release_url: string;
|
||||
release_notes: string;
|
||||
}
|
||||
|
||||
it("keeps the failed state visible while a retry is in flight and clears it on success", async () => {
|
||||
apiMock.checkForUpdates.mockRejectedValueOnce(new Error("boom"));
|
||||
const updater = mountUpdater();
|
||||
|
||||
await updater.checkUpdates();
|
||||
expect(updater.updateCheckFailed.value).toBe(true);
|
||||
expect(updater.updateCheckMessage.value).not.toBe("");
|
||||
|
||||
const retry = deferred<UpdateInfo>();
|
||||
apiMock.checkForUpdates.mockReturnValueOnce(retry.promise);
|
||||
const pending = updater.checkUpdates();
|
||||
await vi.waitFor(() => expect(updater.checkingUpdates.value).toBe(true));
|
||||
|
||||
// Mid-retry: the source switcher stays mounted (updateCheckFailed) and
|
||||
// the dialog must not fall through to "up to date" with an empty version.
|
||||
expect(updater.updateCheckFailed.value).toBe(true);
|
||||
expect(updater.updateCheckMessage.value).not.toBe("");
|
||||
|
||||
retry.resolve({
|
||||
current_version: "0.5.69",
|
||||
latest_version: "0.5.69",
|
||||
update_available: false,
|
||||
release_name: "",
|
||||
release_url: "",
|
||||
release_notes: "",
|
||||
});
|
||||
await pending;
|
||||
|
||||
expect(updater.updateCheckFailed.value).toBe(false);
|
||||
expect(updater.updateCheckMessage.value).toContain("0.5.69");
|
||||
});
|
||||
|
||||
it("keeps the last update info when a silent background check fails", async () => {
|
||||
apiMock.checkForUpdates.mockResolvedValueOnce({
|
||||
current_version: "0.5.69",
|
||||
latest_version: "0.5.70",
|
||||
update_available: true,
|
||||
release_name: "DBX v0.5.70",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.5.70",
|
||||
release_notes: "",
|
||||
});
|
||||
const updater = mountUpdater();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
expect(updater.updateInfo.value?.update_available).toBe(true);
|
||||
|
||||
apiMock.checkForUpdates.mockRejectedValueOnce(new Error("network down"));
|
||||
await updater.checkUpdates({ silent: true });
|
||||
|
||||
expect(updater.updateInfo.value?.update_available).toBe(true);
|
||||
expect(updater.updateCheckFailed.value).toBe(false);
|
||||
expect(updater.showUpdateDialog.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAppUpdater ignore version", () => {
|
||||
it("persists the ignored latest version and closes the update dialog", async () => {
|
||||
const updater = mountUpdater();
|
||||
updater.showUpdateDialog.value = true;
|
||||
|
||||
await updater.ignoreCurrentVersion();
|
||||
|
||||
expect(settingsStoreMock.updateEditorSettingsAndPersist).toHaveBeenCalledWith({ ignoredUpdateVersion: "0.5.70" });
|
||||
expect(updater.showUpdateDialog.value).toBe(false);
|
||||
expect(toastMock).toHaveBeenCalledWith("Version v0.5.70 ignored. You'll be reminded when the next version releases.", 5000);
|
||||
});
|
||||
|
||||
it("keeps the dialog open and allows retry when persistence fails", async () => {
|
||||
settingsStoreMock.updateEditorSettingsAndPersist.mockRejectedValueOnce(new Error("storage unavailable")).mockResolvedValueOnce();
|
||||
const updater = mountUpdater();
|
||||
updater.showUpdateDialog.value = true;
|
||||
|
||||
await updater.ignoreCurrentVersion();
|
||||
|
||||
expect(updater.showUpdateDialog.value).toBe(true);
|
||||
expect(updater.isIgnoringUpdate.value).toBe(false);
|
||||
expect(toastMock).toHaveBeenLastCalledWith("Failed to save the ignored version: storage unavailable", 5000);
|
||||
|
||||
await updater.ignoreCurrentVersion();
|
||||
|
||||
expect(settingsStoreMock.updateEditorSettingsAndPersist).toHaveBeenCalledTimes(2);
|
||||
expect(updater.showUpdateDialog.value).toBe(false);
|
||||
it("resumes after notifications are reenabled while cancellation is pending", async () => {
|
||||
const pendingDownload = deferred<typeof cache>();
|
||||
const pendingCancel = deferred<void>();
|
||||
mocks.downloadUpdate.mockReturnValueOnce(pendingDownload.promise);
|
||||
mocks.cancelUpdateDownload.mockReturnValue(pendingCancel.promise);
|
||||
const updater = mount();
|
||||
await updater.initialize();
|
||||
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(1));
|
||||
settings.updateNotificationsEnabled = false;
|
||||
await nextTick();
|
||||
settings.updateNotificationsEnabled = true;
|
||||
await nextTick();
|
||||
pendingDownload.reject(new Error("cancelled"));
|
||||
pendingCancel.resolve();
|
||||
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalledTimes(2));
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
});
|
||||
});
|
||||
Vendored
+2
-1
@@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key),
|
||||
}),
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch, onScopeDispose } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
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 { installDownloadedUpdateWhenIdle } from "@/lib/app/appUpdateInstallFlow";
|
||||
|
||||
interface UseAppUpdaterOptions {
|
||||
getActiveTaskCount?: () => number;
|
||||
prepareForUpdate?: () => Promise<() => void>;
|
||||
}
|
||||
|
||||
export function shouldOpenUpdateDialog(options: { silent?: boolean }) {
|
||||
@@ -110,252 +109,324 @@ export async function resolveUpdaterProxy(): Promise<string | undefined> {
|
||||
|
||||
export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const checkingUpdates = ref(false);
|
||||
type Phase = "idle" | "checking" | "downloading" | "ready" | "preparing" | "installing" | "restart";
|
||||
const phase = ref<Phase>("idle");
|
||||
const downloaded = ref<api.DownloadedUpdate | null>(null);
|
||||
const updateInfo = ref<api.UpdateInfo | null>(null);
|
||||
const updateCheckMessage = ref("");
|
||||
const updateCheckFailed = ref(false);
|
||||
const showUpdateDialog = ref(false);
|
||||
const isDownloadingUpdate = ref(false);
|
||||
// null while the download size is unknown (backend reported no total) — the UI shows an indeterminate state.
|
||||
const downloadProgress = ref<number | null>(0);
|
||||
const updateDownloaded = ref(false);
|
||||
const isInstallingUpdate = ref(false);
|
||||
const updateReady = ref(false);
|
||||
const downloadProgress = ref<number | null>(null);
|
||||
const isIgnoringUpdate = ref(false);
|
||||
const checkingUpdates = computed(() => phase.value === "checking");
|
||||
const isDownloadingUpdate = computed(() => phase.value === "downloading");
|
||||
const updateDownloaded = computed(() => downloaded.value !== null);
|
||||
const isPreparingUpdate = computed(() => phase.value === "preparing");
|
||||
const isInstallingUpdate = computed(() => phase.value === "installing" || isPreparingUpdate.value);
|
||||
const updateReady = computed(() => phase.value === "restart");
|
||||
const activeTaskCount = computed(() => Math.max(0, Math.trunc(options.getActiveTaskCount?.() ?? 0)));
|
||||
const hasUpdateAvailable = computed(() => updateInfo.value?.update_available === true && !isUpdateIgnored(updateInfo.value, settingsStore.editorSettings.ignoredUpdateVersion));
|
||||
const notificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled !== false);
|
||||
const hasUpdateAvailable = computed(
|
||||
() => notificationsEnabled.value && (updateDownloaded.value || updateReady.value || (updateInfo.value?.update_available === true && (!isTauriRuntime() || updateInfo.value.manual_update_only))) && !isUpdateIgnored(updateInfo.value, settingsStore.editorSettings.ignoredUpdateVersion),
|
||||
);
|
||||
const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
|
||||
let activeDownloadAttempt = 0;
|
||||
let pendingCancellation: Promise<void> | undefined;
|
||||
let generation = 0;
|
||||
let activeDownload: Promise<void> | undefined;
|
||||
let cancellation: Promise<void> | undefined;
|
||||
let cancelOperation: Promise<void> | undefined;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let hourlyTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let retries = 0;
|
||||
let initialized = false;
|
||||
let disposed = false;
|
||||
let versionRecheckPending = false;
|
||||
let updatePreparationRelease: (() => void) | undefined;
|
||||
|
||||
function clearRetry() {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = undefined;
|
||||
}
|
||||
function fail(error: unknown) {
|
||||
updateCheckFailed.value = true;
|
||||
updateCheckMessage.value = formatUpdateError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
function clearError() {
|
||||
updateCheckFailed.value = false;
|
||||
updateCheckMessage.value = "";
|
||||
}
|
||||
function scheduleRetry() {
|
||||
clearRetry();
|
||||
if (!initialized || disposed || !notificationsEnabled.value || retries >= 3) return;
|
||||
const delay = [60_000, 300_000, 900_000][retries++];
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = undefined;
|
||||
void checkUpdates({ silent: true });
|
||||
}, delay);
|
||||
}
|
||||
function setDownloaded(cache: api.DownloadedUpdate) {
|
||||
downloaded.value = cache;
|
||||
updateInfo.value = {
|
||||
current_version: updateInfo.value?.current_version ?? "",
|
||||
latest_version: cache.version,
|
||||
update_available: true,
|
||||
portable_mode: cache.portable_mode,
|
||||
manual_update_only: false,
|
||||
release_name: tagVersion(cache.version),
|
||||
release_url: cache.release_url,
|
||||
release_notes: cache.release_notes,
|
||||
};
|
||||
phase.value = "ready";
|
||||
downloadProgress.value = 100;
|
||||
retries = 0;
|
||||
clearRetry();
|
||||
clearError();
|
||||
}
|
||||
function openUrl(url: string) {
|
||||
if (isTauriRuntime()) {
|
||||
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
|
||||
} else {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
if (isTauriRuntime()) void import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
|
||||
else window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async function checkUpdates(options: { silent?: boolean } = {}) {
|
||||
if (checkingUpdates.value) return;
|
||||
// The toolbar icon reuses this as its click handler. If a download/install is already
|
||||
// in flight or finished, just resurface the dialog instead of hitting the network again.
|
||||
if (!options.silent && (isDownloadingUpdate.value || updateDownloaded.value || updateReady.value)) {
|
||||
showUpdateDialog.value = true;
|
||||
return;
|
||||
}
|
||||
checkingUpdates.value = true;
|
||||
// Keep the previous message/failed state visible while the check is in
|
||||
// flight: pre-clearing would flip the dialog to "up to date" with an empty
|
||||
// version and unmount the source switcher until the request settles.
|
||||
try {
|
||||
const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource));
|
||||
updateInfo.value = info;
|
||||
updateCheckMessage.value = "";
|
||||
updateCheckFailed.value = false;
|
||||
if (info.update_available) {
|
||||
if (shouldOpenUpdateDialog({ silent: options.silent })) {
|
||||
showUpdateDialog.value = true;
|
||||
}
|
||||
} else if (!options.silent) {
|
||||
updateCheckMessage.value = t("updates.upToDate", { version: info.current_version });
|
||||
showUpdateDialog.value = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!options.silent) {
|
||||
// Drop the stale update info only on a surfaced failure; silent
|
||||
// background checks keep the last known state so the toolbar
|
||||
// indicator and an in-flight download's portable-mode flag survive
|
||||
// a single failed poll.
|
||||
updateInfo.value = null;
|
||||
updateCheckMessage.value = formatUpdateError(String(e));
|
||||
updateCheckFailed.value = true;
|
||||
showUpdateDialog.value = true;
|
||||
}
|
||||
} finally {
|
||||
checkingUpdates.value = false;
|
||||
}
|
||||
function openLatestRelease() {
|
||||
openUrl(resolveUpdateReleaseUrl(updateInfo.value, settingsStore.editorSettings.updateDownloadSource, latestReleaseUrl));
|
||||
}
|
||||
|
||||
function formatUpdateError(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("canceled") || lower.includes("cancelled")) {
|
||||
return t("updates.downloadCanceled");
|
||||
}
|
||||
if (lower.includes("403") || lower.includes("rate limit")) {
|
||||
return t("updates.rateLimited");
|
||||
}
|
||||
if (lower.includes("stalled") || lower.includes("timeout") || lower.includes("all available mirrors") || lower.includes("error sending request") || lower.includes("failed to download")) {
|
||||
return t("updates.networkOrMirrorFailed");
|
||||
}
|
||||
if (lower.includes("cancel")) return t("updates.downloadCanceled");
|
||||
if (lower.includes("403") || lower.includes("rate limit")) return t("updates.rateLimited");
|
||||
if (["stalled", "timeout", "all available mirrors", "error sending request", "failed to download"].some((part) => lower.includes(part))) return t("updates.networkOrMirrorFailed");
|
||||
return t("updates.downloadFailed", { error: message });
|
||||
}
|
||||
|
||||
function openLatestRelease() {
|
||||
const url = resolveUpdateReleaseUrl(updateInfo.value, settingsStore.editorSettings.updateDownloadSource, latestReleaseUrl);
|
||||
openUrl(url);
|
||||
async function checkUpdates(checkOptions: { silent?: boolean } = {}) {
|
||||
if (disposed || isIgnoringUpdate.value) return;
|
||||
if (!checkOptions.silent) showUpdateDialog.value = true;
|
||||
if (phase.value !== "idle" || downloaded.value || (checkOptions.silent && !notificationsEnabled.value)) return;
|
||||
clearRetry();
|
||||
const token = ++generation;
|
||||
phase.value = "checking";
|
||||
clearError();
|
||||
try {
|
||||
const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource));
|
||||
if (token !== generation || disposed) return;
|
||||
updateInfo.value = info;
|
||||
phase.value = "idle";
|
||||
if (!info.update_available) updateCheckMessage.value = t("updates.upToDate", { version: info.current_version });
|
||||
if (canDownloadAndInstallUpdate(info, isTauriRuntime()) && !isUpdateIgnored(info, settingsStore.editorSettings.ignoredUpdateVersion)) {
|
||||
await downloadUpdateInBackground();
|
||||
}
|
||||
} catch (error) {
|
||||
if (token !== generation || disposed) return;
|
||||
phase.value = "idle";
|
||||
fail(error);
|
||||
scheduleRetry();
|
||||
}
|
||||
}
|
||||
async function downloadUpdateInBackground() {
|
||||
if (disposed || isIgnoringUpdate.value || phase.value !== "idle" || downloaded.value || !canDownloadAndInstallUpdate(updateInfo.value, isTauriRuntime())) return;
|
||||
const version = updateInfo.value!.latest_version;
|
||||
const token = ++generation;
|
||||
const attemptId = crypto.randomUUID();
|
||||
phase.value = "downloading";
|
||||
downloadProgress.value = null;
|
||||
clearError();
|
||||
clearRetry();
|
||||
const run = async () => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
try {
|
||||
await cancellation;
|
||||
if (token !== generation || disposed) return;
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unlisten = await listen<UpdateDownloadProgress>("update-download-progress", ({ payload }) => {
|
||||
if (token !== generation || payload.attempt_id !== attemptId || payload.version !== version) return;
|
||||
downloadProgress.value = payload.total && payload.total > 0 ? Math.min(100, Math.round((payload.downloaded / payload.total) * 100)) : null;
|
||||
});
|
||||
if (token !== generation || disposed) return;
|
||||
const cache = await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), version, attemptId, updateInfo.value?.release_notes);
|
||||
if (token !== generation || disposed) return;
|
||||
if (cache.version !== version) throw new Error("Downloaded update version changed; check for updates again.");
|
||||
setDownloaded(cache);
|
||||
} catch (error) {
|
||||
if (token !== generation || disposed) return;
|
||||
phase.value = "idle";
|
||||
fail(error);
|
||||
if ((String(error).includes("VERSION_CHANGED") || String(error).toLowerCase().includes("version changed")) && !versionRecheckPending) {
|
||||
versionRecheckPending = true;
|
||||
setTimeout(() => {
|
||||
void checkUpdates({ silent: true }).finally(() => {
|
||||
versionRecheckPending = false;
|
||||
});
|
||||
});
|
||||
} else scheduleRetry();
|
||||
} finally {
|
||||
unlisten?.();
|
||||
}
|
||||
};
|
||||
activeDownload = run();
|
||||
await activeDownload;
|
||||
activeDownload = undefined;
|
||||
}
|
||||
function cancelDownload(): Promise<void> {
|
||||
if (cancelOperation) return cancelOperation;
|
||||
cancelOperation = cancelDownloadImpl().finally(() => {
|
||||
cancelOperation = undefined;
|
||||
});
|
||||
return cancelOperation;
|
||||
}
|
||||
async function cancelDownloadImpl() {
|
||||
clearRetry();
|
||||
generation++;
|
||||
const running = activeDownload;
|
||||
if (phase.value === "checking") phase.value = "idle";
|
||||
if (!running) return;
|
||||
const pending = api.cancelUpdateDownload();
|
||||
cancellation = pending;
|
||||
try {
|
||||
await pending;
|
||||
await running;
|
||||
if (!disposed) {
|
||||
const cache = await api.getDownloadedUpdate();
|
||||
if (cache && !isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion)) setDownloaded(cache);
|
||||
}
|
||||
} finally {
|
||||
if (cancellation === pending) cancellation = undefined;
|
||||
if (phase.value === "downloading") phase.value = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
async function changeUpdateDownloadSource(source: SettingsUpdateDownloadSource) {
|
||||
await settingsStore.updateEditorSettingsAndPersist({ updateDownloadSource: source });
|
||||
await checkUpdates();
|
||||
if (isIgnoringUpdate.value || isInstallingUpdate.value) return;
|
||||
try {
|
||||
await settingsStore.updateEditorSettingsAndPersist({ updateDownloadSource: source });
|
||||
if (downloaded.value || updateReady.value || isInstallingUpdate.value) return;
|
||||
await cancelDownload();
|
||||
retries = 0;
|
||||
await checkUpdates();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function ignoreCurrentVersion() {
|
||||
const latest = updateInfo.value?.latest_version;
|
||||
if (!latest || isIgnoringUpdate.value) return;
|
||||
const version = updateInfo.value?.latest_version;
|
||||
if (!version || isIgnoringUpdate.value || isInstallingUpdate.value || updateReady.value) return;
|
||||
isIgnoringUpdate.value = true;
|
||||
try {
|
||||
await settingsStore.updateEditorSettingsAndPersist({ ignoredUpdateVersion: latest });
|
||||
await cancelDownload();
|
||||
await settingsStore.updateEditorSettingsAndPersist({ ignoredUpdateVersion: version });
|
||||
const cache = downloaded.value;
|
||||
downloaded.value = null;
|
||||
phase.value = "idle";
|
||||
if (cache) await api.discardDownloadedUpdate(cache.cache_id);
|
||||
showUpdateDialog.value = false;
|
||||
toast(t("updates.versionIgnored", { version: tagVersion(latest) }), 5000);
|
||||
} catch (error) {
|
||||
toast(t("updates.ignoreVersionFailed", { error: error instanceof Error ? error.message : String(error) }), 5000);
|
||||
fail(error);
|
||||
} finally {
|
||||
isIgnoringUpdate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function blockUpdateForActiveTasks(): boolean {
|
||||
if (!shouldBlockAppUpdate(activeTaskCount.value)) return false;
|
||||
toast(t("updates.activeTasksBlockUpdate", { count: activeTaskCount.value }), 5000);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function downloadUpdateInBackground() {
|
||||
if (!isTauriRuntime() || isDownloadingUpdate.value || isInstallingUpdate.value || updateDownloaded.value || updateReady.value) return;
|
||||
if (!canDownloadAndInstallUpdate(updateInfo.value, true)) {
|
||||
openLatestRelease();
|
||||
return;
|
||||
}
|
||||
// Starting the download never has to wait on active work — only installing
|
||||
// (file replacement) is disruptive, so that guard applies at install time instead.
|
||||
showUpdateDialog.value = false;
|
||||
const attempt = ++activeDownloadAttempt;
|
||||
isDownloadingUpdate.value = true;
|
||||
// The total is unknown until the first progress event arrives.
|
||||
downloadProgress.value = null;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const latestVersion = updateInfo.value?.latest_version;
|
||||
toast(t("updates.downloadStarted", { version: latestVersion ? tagVersion(latestVersion) : "" }), 3000);
|
||||
async function performInstall(restartOnly: boolean) {
|
||||
if (!isTauriRuntime() || isInstallingUpdate.value || isIgnoringUpdate.value || (!restartOnly && !downloaded.value)) return;
|
||||
const cache = downloaded.value;
|
||||
let release: (() => void) | undefined;
|
||||
phase.value = "preparing";
|
||||
clearError();
|
||||
let installed = restartOnly;
|
||||
let failed = false;
|
||||
try {
|
||||
await pendingCancellation;
|
||||
if (attempt !== activeDownloadAttempt) return;
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unlisten = await listen<UpdateDownloadProgress>("update-download-progress", (event) => {
|
||||
if (attempt !== activeDownloadAttempt) return;
|
||||
const total = event.payload.total ?? 0;
|
||||
downloadProgress.value = total > 0 ? Math.round((event.payload.downloaded / total) * 100) : null;
|
||||
});
|
||||
await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), latestVersion);
|
||||
if (attempt !== activeDownloadAttempt) return;
|
||||
downloadProgress.value = 100;
|
||||
updateDownloaded.value = true;
|
||||
// The download phase is truly finished — flip this now so the install guard (which
|
||||
// checks isDownloadingUpdate) can evaluate freely instead of treating us as still busy.
|
||||
isDownloadingUpdate.value = false;
|
||||
if (shouldBlockAppUpdate(activeTaskCount.value)) {
|
||||
// Matches the pre-refactor behavior: installing waits for active work to finish, but
|
||||
// unlike before, only installing waits — the download itself never blocked on it.
|
||||
toast(t("updates.readyToInstall", { version: latestVersion ? tagVersion(latestVersion) : "" }), 10000, {
|
||||
label: t("updates.installNow"),
|
||||
onClick: () => {
|
||||
void installDownloadedUpdate();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Restores the pre-refactor auto-install-when-idle behavior for the install step.
|
||||
await installDownloadedUpdate();
|
||||
release = await options.prepareForUpdate?.();
|
||||
updatePreparationRelease = release;
|
||||
if (shouldBlockAppUpdate(activeTaskCount.value)) throw new Error(t("updates.activeTasksBlockUpdate", { count: activeTaskCount.value }));
|
||||
phase.value = "installing";
|
||||
if (!restartOnly && cache) {
|
||||
await api.installDownloadedUpdate(cache.cache_id, cache.version);
|
||||
installed = true;
|
||||
downloaded.value = null;
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (attempt !== activeDownloadAttempt) return;
|
||||
downloadProgress.value = 0;
|
||||
const rawMsg = e?.message || String(e);
|
||||
console.error("[DBX update error]", rawMsg);
|
||||
const lower = rawMsg.toLowerCase();
|
||||
if (lower.includes("canceled") || lower.includes("cancelled")) {
|
||||
return;
|
||||
// Windows installers and the portable helper own process replacement/relaunch.
|
||||
if (!cache?.portable_mode && !navigator.userAgent.includes("Windows")) {
|
||||
phase.value = "restart";
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
await relaunch();
|
||||
}
|
||||
toast(formatUpdateError(rawMsg), 5000);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
phase.value = installed ? "restart" : "ready";
|
||||
updateCheckFailed.value = true;
|
||||
updateCheckMessage.value = t(installed ? "updates.restartFailed" : "updates.installFailed", { error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
unlisten?.();
|
||||
if (attempt === activeDownloadAttempt) {
|
||||
isDownloadingUpdate.value = false;
|
||||
// Successful handoff must keep every window frozen until the process exits.
|
||||
if (failed) {
|
||||
release?.();
|
||||
updatePreparationRelease = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installPendingUpdate() {
|
||||
isInstallingUpdate.value = true;
|
||||
try {
|
||||
const portableMode = updateInfo.value?.portable_mode === true;
|
||||
await api.installDownloadedUpdate();
|
||||
updateDownloaded.value = false;
|
||||
// The portable helper exits and relaunches DBX after the invoke response is delivered.
|
||||
updateReady.value = !portableMode;
|
||||
} 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();
|
||||
return;
|
||||
}
|
||||
if (updateReady.value) {
|
||||
toast(t("updates.installedReadyToRestart"), 10000, {
|
||||
label: t("updates.restart"),
|
||||
onClick: () => {
|
||||
void restartApp();
|
||||
},
|
||||
});
|
||||
}
|
||||
} 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();
|
||||
} catch (e: any) {
|
||||
toast(t("updates.restartFailed", { error: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDownload() {
|
||||
if (isDownloadingUpdate.value) {
|
||||
activeDownloadAttempt += 1;
|
||||
isDownloadingUpdate.value = false;
|
||||
downloadProgress.value = 0;
|
||||
if (isTauriRuntime()) {
|
||||
const cancellation = api.cancelUpdateDownload().catch(() => {});
|
||||
pendingCancellation = cancellation;
|
||||
if (!installed && cache) {
|
||||
try {
|
||||
await cancellation;
|
||||
} finally {
|
||||
if (pendingCancellation === cancellation) {
|
||||
pendingCancellation = undefined;
|
||||
const restored = await api.getDownloadedUpdate();
|
||||
if (!restored || restored.cache_id !== cache.cache_id) {
|
||||
downloaded.value = null;
|
||||
phase.value = "idle";
|
||||
}
|
||||
} catch {
|
||||
/* Keep the original installation error available for retry. */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installDownloadedUpdate() {
|
||||
await performInstall(false);
|
||||
}
|
||||
async function restartApp() {
|
||||
if (updateReady.value) await performInstall(true);
|
||||
}
|
||||
async function initialize() {
|
||||
if (initialized || disposed) return;
|
||||
initialized = true;
|
||||
if (isTauriRuntime()) {
|
||||
phase.value = "checking";
|
||||
try {
|
||||
const cache = await api.getDownloadedUpdate();
|
||||
if (disposed) return;
|
||||
if (cache) {
|
||||
const ignored = isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion);
|
||||
if (ignored) await api.discardDownloadedUpdate(cache.cache_id);
|
||||
else {
|
||||
setDownloaded(cache);
|
||||
updateInfo.value!.current_version = await api.getAppVersion();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
if (phase.value === "checking") phase.value = "idle";
|
||||
}
|
||||
}
|
||||
if (disposed) return;
|
||||
hourlyTimer = setInterval(() => {
|
||||
if (!retryTimer) {
|
||||
retries = 0;
|
||||
void checkUpdates({ silent: true });
|
||||
}
|
||||
}, 3_600_000);
|
||||
void checkUpdates({ silent: true });
|
||||
}
|
||||
const stopSettingsWatch = watch(notificationsEnabled, (enabled) => {
|
||||
if (!initialized || disposed) return;
|
||||
const settle = enabled ? (cancelOperation ?? Promise.resolve()) : cancelDownload();
|
||||
void settle
|
||||
.then(() => {
|
||||
if (notificationsEnabled.value && !disposed) void checkUpdates({ silent: true });
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
function dispose() {
|
||||
disposed = true;
|
||||
clearRetry();
|
||||
clearInterval(hourlyTimer);
|
||||
stopSettingsWatch();
|
||||
updatePreparationRelease?.();
|
||||
updatePreparationRelease = undefined;
|
||||
void cancelDownload().catch(() => {});
|
||||
}
|
||||
onScopeDispose(dispose);
|
||||
return {
|
||||
phase,
|
||||
checkingUpdates,
|
||||
updateInfo,
|
||||
updateCheckMessage,
|
||||
@@ -365,6 +436,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
downloadProgress,
|
||||
updateDownloaded,
|
||||
isInstallingUpdate,
|
||||
isPreparingUpdate,
|
||||
updateReady,
|
||||
isIgnoringUpdate,
|
||||
activeTaskCount,
|
||||
@@ -380,5 +452,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
cancelDownload,
|
||||
installDownloadedUpdate,
|
||||
restartApp,
|
||||
initialize,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -285,6 +285,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "Fayl yadda saxlanılarkən yenidən dəyişdirildi. Yenidən cəhd edin.",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "Yeniləmədən əvvəl cari pəncərə əməliyyatının bitməsini gözləyin.",
|
||||
preparationBusy: "Yeniləmə artıq hazırlanır.",
|
||||
preparationCancelled: "Yeniləmə hazırlığı ləğv edildi.",
|
||||
preparationTimeout: "Pəncərə 5 saniyə ərzində saxlanmadı. Yenidən sınayın.",
|
||||
preparationWindowsChanged: "Açıq pəncərələr dəyişdi. Yenidən sınayın.",
|
||||
preparationTasks: "Aktiv tapşırıqları və tranzaksiyaları bitirin.",
|
||||
preparationDrafts: "Dəyişiklikləri saxlayın və ya ləğv edin.",
|
||||
preparationConfig: "Konfiqurasiya redaktəsini bitirin.",
|
||||
preparationNotReady: "Yeniləmə hazır deyil. Yenidən sınayın.",
|
||||
downloadedReady: "v{version} versiyası endirilib. Yeniləmək üçün yenidən başladın.",
|
||||
restartAndUpdate: "Yenidən başladın və yeniləyin",
|
||||
preparing: "Yeniləmə hazırlanır…",
|
||||
retryDownload: "Endirməni yenidən sınayın",
|
||||
title: "Yeniləmələr",
|
||||
check: "Yeniləmələri yoxla",
|
||||
updateReadyTooltip: "Yeniləmə hazırdır — quraşdırmaq üçün klikləyin",
|
||||
@@ -6743,7 +6756,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "DBX-dən çıx",
|
||||
closeActionMinimize: "Sistem treyinə kiçilt",
|
||||
updateNotificationsEnabled: "Yeniləmə xatırlatmalarını aktiv et",
|
||||
updateNotificationsEnabledDescription: "Deaktiv olduqda DBX tətbiq və ya drayver yeniləmələrini avtomatik yoxlamayacaq və yeniləmə nişanlarını göstərməyəcək. Əl ilə yoxlama yenə mümkündür.",
|
||||
updateNotificationsEnabledDescription: "Tətbiq və sürücü yeniləmələrini avtomatik yoxlayır, tətbiq yeniləmələrini səssiz endirir. Söndürüldükdə avtomatik yoxlamalar, endirmələr və nişanlar dayanır. Əl ilə yoxlama və endirilmiş yeniləmələrin quraşdırılması mümkündür.",
|
||||
updateDownloadSource: "Yeniləmələrin endirmə mənbəyi",
|
||||
updateDownloadSourceDescription: "Tətbiq yeniləmələrinin, verilənlər bazası agentlərinin, drayverlərin və idarə olunan JRE-lərin haradan endiriləcəyini seçin. Rəsmi mənbə tövsiyə olunur; CNB materik Çin şəbəkələrində daha sürətli ola bilər.",
|
||||
updateDownloadSourceOfficial: "Rəsmi mənbə (tövsiyə olunur)",
|
||||
|
||||
@@ -286,6 +286,19 @@ export default {
|
||||
changedAgain: "The file changed again while it was being saved. Try again.",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "Please wait for the current window operation to finish before updating.",
|
||||
preparationBusy: "An update is already being prepared.",
|
||||
preparationCancelled: "Update preparation was cancelled.",
|
||||
preparationTimeout: "A window did not save within 5 seconds. Try again.",
|
||||
preparationWindowsChanged: "Open windows changed. Try the update again.",
|
||||
preparationTasks: "Finish active tasks and transactions before updating.",
|
||||
preparationDrafts: "Save or discard unsaved changes before updating.",
|
||||
preparationConfig: "Finish editing the current configuration before updating.",
|
||||
preparationNotReady: "Update preparation is not ready. Try again.",
|
||||
downloadedReady: "New version v{version} is downloaded. Restart to update.",
|
||||
restartAndUpdate: "Restart & Update",
|
||||
preparing: "Preparing update…",
|
||||
retryDownload: "Retry Download",
|
||||
title: "Updates",
|
||||
check: "Check for updates",
|
||||
updateReadyTooltip: "Update ready — click to install",
|
||||
@@ -6740,7 +6753,7 @@ export default {
|
||||
closeActionQuit: "Quit DBX",
|
||||
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.",
|
||||
updateNotificationsEnabledDescription: "Automatically check for app and driver updates and silently download app updates. When disabled, automatic checks, app downloads and update badges stop. You can still check manually and install downloaded updates.",
|
||||
updateDownloadSource: "Update download source",
|
||||
updateDownloadSourceDescription: "Choose where app updates, database agents, drivers, and managed JREs are downloaded from. The official source is recommended; CNB can be faster on mainland China networks.",
|
||||
updateDownloadSourceOfficial: "Official source (recommended)",
|
||||
|
||||
@@ -288,6 +288,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "El archivo volvió a cambiar durante el guardado. Inténtalo de nuevo.",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "Espera a que termine la operación de ventana actual antes de actualizar.",
|
||||
preparationBusy: "Ya se está preparando una actualización.",
|
||||
preparationCancelled: "Se canceló la preparación.",
|
||||
preparationTimeout: "Una ventana no se guardó en 5 segundos. Reintenta.",
|
||||
preparationWindowsChanged: "Las ventanas abiertas cambiaron. Reintenta.",
|
||||
preparationTasks: "Finaliza las tareas y transacciones activas.",
|
||||
preparationDrafts: "Guarda o descarta los cambios pendientes.",
|
||||
preparationConfig: "Finaliza la edición de configuración.",
|
||||
preparationNotReady: "La actualización no está lista. Reintenta.",
|
||||
downloadedReady: "La versión v{version} está descargada. Reinicia para actualizar.",
|
||||
restartAndUpdate: "Reiniciar y actualizar",
|
||||
preparing: "Preparando actualización…",
|
||||
retryDownload: "Reintentar descarga",
|
||||
title: "Actualizaciones",
|
||||
check: "Buscar actualizaciones",
|
||||
updateReadyTooltip: "Actualización lista, haz clic para instalar",
|
||||
@@ -6314,7 +6327,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "Salir de DBX",
|
||||
closeActionMinimize: "Minimizar a la bandeja",
|
||||
updateNotificationsEnabled: "Activar recordatorios de actualización",
|
||||
updateNotificationsEnabledDescription: "Al desactivarlo, DBX no comprobará automáticamente actualizaciones de la app ni de los controladores, ni mostrará indicadores. Las comprobaciones manuales siguen disponibles.",
|
||||
updateNotificationsEnabledDescription: "Comprueba actualizaciones de la app y los controladores y descarga las de la app en silencio. Al desactivar se detienen las comprobaciones, descargas e indicadores. Puedes comprobar manualmente e instalar actualizaciones descargadas.",
|
||||
updateDownloadSource: "Origen de descarga de actualizaciones",
|
||||
updateDownloadSourceDescription: "Elige de dónde se descargan los instaladores de actualización. Se recomienda la fuente oficial; CNB puede ser más rápido en redes de China continental.",
|
||||
updateDownloadSourceOfficial: "Fuente oficial (recomendada)",
|
||||
|
||||
@@ -287,6 +287,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "Il file è cambiato di nuovo durante il salvataggio. Riprova.",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "Attendi il completamento dell’operazione sulla finestra prima di aggiornare.",
|
||||
preparationBusy: "Un aggiornamento è già in preparazione.",
|
||||
preparationCancelled: "Preparazione annullata.",
|
||||
preparationTimeout: "Una finestra non ha salvato entro 5 secondi. Riprova.",
|
||||
preparationWindowsChanged: "Le finestre aperte sono cambiate. Riprova.",
|
||||
preparationTasks: "Termina attività e transazioni in corso.",
|
||||
preparationDrafts: "Salva o scarta le modifiche non salvate.",
|
||||
preparationConfig: "Completa la modifica della configurazione.",
|
||||
preparationNotReady: "Aggiornamento non pronto. Riprova.",
|
||||
downloadedReady: "La versione v{version} è scaricata. Riavvia per aggiornare.",
|
||||
restartAndUpdate: "Riavvia e aggiorna",
|
||||
preparing: "Preparazione aggiornamento…",
|
||||
retryDownload: "Riprova download",
|
||||
title: "Aggiornamenti",
|
||||
check: "Verifica aggiornamenti",
|
||||
updateReadyTooltip: "Aggiornamento pronto, clicca per installare",
|
||||
@@ -6314,7 +6327,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "Esci da DBX",
|
||||
closeActionMinimize: "Riduci a icona",
|
||||
updateNotificationsEnabled: "Abilita promemoria aggiornamenti",
|
||||
updateNotificationsEnabledDescription: "Se disattivato, DBX non controllerà automaticamente gli aggiornamenti dell'app o dei driver e non mostrerà badge. I controlli manuali restano disponibili.",
|
||||
updateNotificationsEnabledDescription: "Controlla gli aggiornamenti di app e driver e scarica quelli dell’app senza notifiche. Disattivando, controlli automatici, download e indicatori si fermano. Restano disponibili controlli manuali e installazione degli aggiornamenti scaricati.",
|
||||
updateDownloadSource: "Fonte download aggiornamenti",
|
||||
updateDownloadSourceDescription: "Scegli da dove scaricare gli installer degli aggiornamenti. La fonte ufficiale è consigliata; CNB può essere più veloce sulle reti della Cina continentale.",
|
||||
updateDownloadSourceOfficial: "Fonte ufficiale (consigliata)",
|
||||
|
||||
@@ -288,6 +288,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "保存中にファイルが再度変更されました。もう一度お試しください。",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "更新する前に、現在のウィンドウ操作が完了するまでお待ちください。",
|
||||
preparationBusy: "更新を準備中です。",
|
||||
preparationCancelled: "更新準備をキャンセルしました。",
|
||||
preparationTimeout: "5 秒以内に保存できないウィンドウがあります。再試行してください。",
|
||||
preparationWindowsChanged: "ウィンドウ構成が変わりました。再試行してください。",
|
||||
preparationTasks: "実行中のタスクとトランザクションを終了してください。",
|
||||
preparationDrafts: "未保存の変更を保存または破棄してください。",
|
||||
preparationConfig: "設定の編集を終了してください。",
|
||||
preparationNotReady: "更新の準備ができていません。再試行してください。",
|
||||
downloadedReady: "新バージョン v{version} をダウンロード済みです。再起動して更新できます。",
|
||||
restartAndUpdate: "再起動して更新",
|
||||
preparing: "更新を準備中…",
|
||||
retryDownload: "ダウンロードを再試行",
|
||||
title: "アップデート",
|
||||
check: "アップデートを確認",
|
||||
updateReadyTooltip: "アップデートの準備ができました。クリックしてインストール",
|
||||
@@ -6352,7 +6365,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "DBXを終了",
|
||||
closeActionMinimize: "トレイに最小化",
|
||||
updateNotificationsEnabled: "アップデート通知を有効にする",
|
||||
updateNotificationsEnabledDescription: "無効時、DBXはアプリやドライバーのアップデートを自動確認せず、アップデートバッジも表示しません。手動確認は引き続き利用可能です。",
|
||||
updateNotificationsEnabledDescription: "アプリとドライバーの更新を自動確認し、アプリ更新を通知せずにダウンロードします。無効にすると自動確認、ダウンロード、更新バッジが停止します。手動確認とダウンロード済み更新のインストールは可能です。",
|
||||
debugLoggingEnabled: "デバッグログを有効にする",
|
||||
debugLoggingEnabledDescription: "トラブルシューティング時に、より詳細なローカル診断ログを記録します。デフォルトでは無効です。",
|
||||
metadataCacheMemoryLimit: "メタデータメモリキャッシュ上限",
|
||||
|
||||
@@ -286,6 +286,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "저장하는 동안 파일이 다시 변경되었습니다. 다시 시도하세요.",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "업데이트하기 전에 현재 창 작업이 완료될 때까지 기다려 주세요.",
|
||||
preparationBusy: "업데이트를 준비 중입니다.",
|
||||
preparationCancelled: "업데이트 준비가 취소되었습니다.",
|
||||
preparationTimeout: "창이 5초 안에 저장되지 않았습니다. 다시 시도하세요.",
|
||||
preparationWindowsChanged: "열린 창이 변경되었습니다. 다시 시도하세요.",
|
||||
preparationTasks: "실행 중인 작업과 트랜잭션을 종료하세요.",
|
||||
preparationDrafts: "변경 사항을 저장하거나 버리세요.",
|
||||
preparationConfig: "현재 설정 편집을 완료하세요.",
|
||||
preparationNotReady: "업데이트 준비가 안 되었습니다. 다시 시도하세요.",
|
||||
downloadedReady: "새 버전 v{version} 다운로드 완료. 다시 시작하여 업데이트하세요.",
|
||||
restartAndUpdate: "다시 시작 및 업데이트",
|
||||
preparing: "업데이트 준비 중…",
|
||||
retryDownload: "다운로드 재시도",
|
||||
title: "업데이트",
|
||||
check: "업데이트 확인",
|
||||
updateReadyTooltip: "업데이트 준비 완료 — 클릭하여 설치",
|
||||
@@ -6057,7 +6070,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "DBX 종료",
|
||||
closeActionMinimize: "트레이로 최소화",
|
||||
updateNotificationsEnabled: "업데이트 알림 활성화",
|
||||
updateNotificationsEnabledDescription: "비활성화하면 DBX가 앱이나 드라이버 업데이트를 자동으로 확인하거나 업데이트 배지를 표시하지 않습니다. 수동 확인은 여전히 가능합니다.",
|
||||
updateNotificationsEnabledDescription: "앱과 드라이버 업데이트를 자동 확인하고 앱 업데이트를 조용히 다운로드합니다. 끄면 자동 확인, 앱 다운로드와 배지가 중지됩니다. 수동 확인 및 다운로드된 업데이트 설치는 가능합니다.",
|
||||
updateDownloadSource: "업데이트 다운로드 소스",
|
||||
updateDownloadSourceDescription: "앱 업데이트, 데이터베이스 에이전트, 드라이버, 관리 JRE를 다운로드할 위치를 선택하세요. 공식 소스를 권장합니다. 중국 본토 네트워크에서는 CNB가 더 빠를 수 있습니다.",
|
||||
updateDownloadSourceOfficial: "공식 소스 (권장)",
|
||||
|
||||
@@ -288,6 +288,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "O arquivo mudou novamente durante o salvamento. Tente outra vez.",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "Aguarde a operação atual da janela terminar antes de atualizar.",
|
||||
preparationBusy: "Uma atualização já está sendo preparada.",
|
||||
preparationCancelled: "Preparação cancelada.",
|
||||
preparationTimeout: "Uma janela não salvou em 5 segundos. Tente novamente.",
|
||||
preparationWindowsChanged: "As janelas abertas mudaram. Tente novamente.",
|
||||
preparationTasks: "Conclua as tarefas e transações ativas.",
|
||||
preparationDrafts: "Salve ou descarte as alterações pendentes.",
|
||||
preparationConfig: "Conclua a edição da configuração.",
|
||||
preparationNotReady: "A atualização não está pronta. Tente novamente.",
|
||||
downloadedReady: "A versão v{version} foi baixada. Reinicie para atualizar.",
|
||||
restartAndUpdate: "Reiniciar e atualizar",
|
||||
preparing: "Preparando atualização…",
|
||||
retryDownload: "Tentar baixar novamente",
|
||||
title: "Atualizações",
|
||||
check: "Verificar atualizações",
|
||||
updateReadyTooltip: "Atualização pronta, clique para instalar",
|
||||
@@ -6316,7 +6329,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "Sair do DBX",
|
||||
closeActionMinimize: "Minimizar para a bandeja",
|
||||
updateNotificationsEnabled: "Ativar lembretes de atualização",
|
||||
updateNotificationsEnabledDescription: "Quando desativado, o DBX não verificará automaticamente atualizações do app ou dos drivers nem mostrará indicadores. Verificações manuais continuam disponíveis.",
|
||||
updateNotificationsEnabledDescription: "Verifica atualizações do app e dos drivers e baixa as do app silenciosamente. Ao desativar, as verificações, downloads e indicadores param. Você ainda pode verificar manualmente e instalar atualizações baixadas.",
|
||||
updateDownloadSource: "Fonte de download de atualização",
|
||||
updateDownloadSourceDescription: "Escolha de onde os instaladores de atualização no aplicativo são baixados. A fonte oficial é recomendada; CNB pode ser mais rápido em redes da China continental.",
|
||||
updateDownloadSourceOfficial: "Fonte oficial (recomendada)",
|
||||
|
||||
@@ -287,6 +287,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "Dosya kaydedilirken yeniden değişti. Tekrar deneyin.",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "Güncellemeden önce geçerli pencere işleminin tamamlanmasını bekleyin.",
|
||||
preparationBusy: "Güncelleme zaten hazırlanıyor.",
|
||||
preparationCancelled: "Güncelleme hazırlığı iptal edildi.",
|
||||
preparationTimeout: "Bir pencere 5 saniyede kaydetmedi. Yeniden deneyin.",
|
||||
preparationWindowsChanged: "Açık pencereler değişti. Yeniden deneyin.",
|
||||
preparationTasks: "Etkin görevleri ve işlemleri bitirin.",
|
||||
preparationDrafts: "Kaydedilmemiş değişiklikleri kaydedin veya atın.",
|
||||
preparationConfig: "Yapılandırma düzenlemesini bitirin.",
|
||||
preparationNotReady: "Güncelleme hazır değil. Yeniden deneyin.",
|
||||
downloadedReady: "v{version} sürümü indirildi. Güncellemek için yeniden başlatın.",
|
||||
restartAndUpdate: "Yeniden başlat ve güncelle",
|
||||
preparing: "Güncelleme hazırlanıyor…",
|
||||
retryDownload: "İndirmeyi yeniden dene",
|
||||
title: "Güncellemeler",
|
||||
check: "Güncellemeleri denetle",
|
||||
updateReadyTooltip: "Güncelleme hazır — kurmak için tıklayın",
|
||||
@@ -6625,7 +6638,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "DBX'ten çık",
|
||||
closeActionMinimize: "Tepsiye küçült",
|
||||
updateNotificationsEnabled: "Güncelleme anımsatıcılarını etkinleştir",
|
||||
updateNotificationsEnabledDescription: "Kapatıldığında DBX, uygulama veya sürücü güncellemelerini otomatik denetlemez ve güncelleme rozetleri göstermez. Elle denetim yine kullanılabilir.",
|
||||
updateNotificationsEnabledDescription: "Uygulama ve sürücü güncellemelerini otomatik kontrol eder, uygulama güncellemelerini sessizce indirir. Kapatıldığında otomatik kontroller, indirmeler ve rozetler durur. Elle kontrol ve indirilen güncellemeleri yükleme kullanılabilir.",
|
||||
updateDownloadSource: "Güncelleme indirme kaynağı",
|
||||
updateDownloadSourceDescription: "Uygulama güncellemelerinin, veritabanı agent'larının, sürücülerin ve yönetilen JRE'lerin nereden indirileceğini seçin. Resmî kaynak önerilir; Çin anakarası ağlarında CNB daha hızlı olabilir.",
|
||||
updateDownloadSourceOfficial: "Resmî kaynak (önerilen)",
|
||||
|
||||
@@ -211,6 +211,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "保存过程中该文件再次发生变化,请重试。",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "请等待当前窗口操作完成后再更新。",
|
||||
preparationBusy: "正在准备更新,请稍候。",
|
||||
preparationCancelled: "更新准备已取消。",
|
||||
preparationTimeout: "有窗口未在 5 秒内完成保存,请重试。",
|
||||
preparationWindowsChanged: "打开的窗口发生变化,请重新尝试更新。",
|
||||
preparationTasks: "请先结束正在运行的任务和事务。",
|
||||
preparationDrafts: "请先保存或放弃尚未保存的修改。",
|
||||
preparationConfig: "请先完成当前配置编辑。",
|
||||
preparationNotReady: "更新准备尚未就绪,请重试。",
|
||||
downloadedReady: "新版本 v{version} 已下载,重启即可更新",
|
||||
restartAndUpdate: "重启并更新",
|
||||
preparing: "正在准备更新…",
|
||||
retryDownload: "重试下载",
|
||||
title: "更新",
|
||||
check: "检查更新",
|
||||
updateReadyTooltip: "更新已就绪,点击安装",
|
||||
@@ -6723,7 +6736,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "退出程序",
|
||||
closeActionMinimize: "最小化到托盘",
|
||||
updateNotificationsEnabled: "启用更新提醒",
|
||||
updateNotificationsEnabledDescription: "关闭后,DBX 不会自动检查应用和驱动更新,也不会显示更新红点;仍可手动检查更新。",
|
||||
updateNotificationsEnabledDescription: "自动检查应用和驱动更新,并静默下载应用更新。关闭后停止自动检查、应用下载和更新红点;仍可手动检查并安装已下载的更新。",
|
||||
updateDownloadSource: "更新下载源",
|
||||
updateDownloadSourceDescription: "选择应用更新、数据库 Agent、驱动和托管 JRE 的下载来源。官方源为推荐选项,CNB 适合国内网络环境。",
|
||||
updateDownloadSourceOfficial: "官方源(推荐)",
|
||||
|
||||
@@ -288,6 +288,19 @@ export default withEnglishFallback({
|
||||
changedAgain: "儲存期間檔案再次發生變更,請重試。",
|
||||
},
|
||||
updates: {
|
||||
preparationWindowOperation: "請等待目前視窗操作完成後再更新。",
|
||||
preparationBusy: "正在準備更新,請稍候。",
|
||||
preparationCancelled: "更新準備已取消。",
|
||||
preparationTimeout: "有視窗未在 5 秒內完成儲存,請重試。",
|
||||
preparationWindowsChanged: "開啟的視窗發生變化,請重新嘗試更新。",
|
||||
preparationTasks: "請先結束正在執行的工作和交易。",
|
||||
preparationDrafts: "請先儲存或捨棄尚未儲存的修改。",
|
||||
preparationConfig: "請先完成目前設定編輯。",
|
||||
preparationNotReady: "更新準備尚未就緒,請重試。",
|
||||
downloadedReady: "新版本 v{version} 已下載,重新啟動即可更新",
|
||||
restartAndUpdate: "重新啟動並更新",
|
||||
preparing: "正在準備更新…",
|
||||
retryDownload: "重試下載",
|
||||
title: "更新",
|
||||
check: "檢查更新",
|
||||
updateReadyTooltip: "更新已就緒,點擊安裝",
|
||||
@@ -5633,7 +5646,7 @@ export default withEnglishFallback({
|
||||
closeActionQuit: "退出程式",
|
||||
closeActionMinimize: "最小化到系統匣",
|
||||
updateNotificationsEnabled: "啟用更新提醒",
|
||||
updateNotificationsEnabledDescription: "關閉後,DBX 不會自動檢查應用程式和驅動程式更新,也不會顯示更新紅點;仍可手動檢查更新。",
|
||||
updateNotificationsEnabledDescription: "自動檢查應用程式和驅動程式更新,並靜默下載應用程式更新。關閉後停止自動檢查、應用程式下載和更新紅點;仍可手動檢查並安裝已下載的更新。",
|
||||
debugLoggingEnabled: "啟用偵錯日誌",
|
||||
debugLoggingEnabledDescription: "開啟後在本機記錄更詳細的使用者側診斷日誌,回報問題時可暫時開啟;預設關閉。",
|
||||
metadataCacheMemoryLimit: "中繼資料記憶體快取上限",
|
||||
|
||||
@@ -21,6 +21,12 @@ function roundTrip(tabs: QueryTab[]) {
|
||||
}
|
||||
|
||||
describe("openTabsPersistence originalSql round-trip", () => {
|
||||
it("keeps clean saved SQL tabs eligible for file hydration", () => {
|
||||
const [restored] = roundTrip([queryTab({ savedSqlId: "saved", sql: "SELECT 1", originalSql: "SELECT 1" })]);
|
||||
expect(restored.sql).toBe("");
|
||||
expect(restored.originalSql).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves read-only source intent without adding editable source metadata", () => {
|
||||
const [restored] = roundTrip([queryTab({ sourceView: true, sql: "CREATE SEQUENCE seq_users" })]);
|
||||
expect(restored.sourceView).toBe(true);
|
||||
|
||||
@@ -128,6 +128,11 @@ export function hasActiveDesktopAiRun(conversationId: string): boolean {
|
||||
return run.status === "preparing" || run.status === "queued" || run.status === "running" || run.status === "awaiting_write_confirmation" || run.status === "pending_recoverable";
|
||||
}
|
||||
|
||||
/** Restart has no opportunity to recover an in-memory AI run after process exit. */
|
||||
export function blockingDesktopAiRunsForUpdate(): DesktopAiRunRuntime[] {
|
||||
return [...runsByConversation.values()].filter((run) => !isTerminalDesktopAiRunStatus(run.status));
|
||||
}
|
||||
|
||||
export function blockingDesktopAiRunsForQuit(): DesktopAiRunRuntime[] {
|
||||
return [...runsByConversation.values()].filter((run) => run.status === "preparing" || run.status === "queued" || run.status === "running");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { acquireUpdateBarrier, assertUpdateSafe } from "../updatePreparation";
|
||||
import { openDetachedTabWindow } from "../detachedTabWindow";
|
||||
const mocks = vi.hoisted(() => ({ getByLabel: vi.fn(), created: vi.fn(), callbacks: new Map<string, (event?: unknown) => unknown>(), show: vi.fn(), focus: vi.fn(), destroy: vi.fn() }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@/lib/app/windowContext", () => ({ detachedWindowLabel: (id: string) => `detached-${id}`, detachedWindowUrl: () => "http://localhost/" }));
|
||||
vi.mock("@tauri-apps/api/webviewWindow", () => ({
|
||||
WebviewWindow: class {
|
||||
static getByLabel = mocks.getByLabel;
|
||||
constructor() {
|
||||
mocks.created();
|
||||
}
|
||||
once(event: string, callback: (event?: unknown) => unknown) {
|
||||
mocks.callbacks.set(event, callback);
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
show = mocks.show;
|
||||
setFocus = mocks.focus;
|
||||
destroy = mocks.destroy;
|
||||
},
|
||||
}));
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.callbacks.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
function pendingLookup() {
|
||||
let resolve!: (value: unknown) => void;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
mocks.getByLabel.mockReturnValueOnce(promise);
|
||||
return resolve;
|
||||
}
|
||||
describe("detached window update exclusion", () => {
|
||||
it("grants the capability required to remove never-shown late windows", () => {
|
||||
const capability = JSON.parse(readFileSync("src-tauri/capabilities/default.json", "utf8"));
|
||||
expect(capability.permissions).toContain("core:window:allow-destroy");
|
||||
});
|
||||
|
||||
it("blocks update preparation while the asynchronous existing-window lookup is pending", async () => {
|
||||
const resolve = pendingLookup();
|
||||
const operation = openDetachedTabWindow("one", "SQL");
|
||||
await vi.waitFor(() => expect(mocks.getByLabel).toHaveBeenCalled());
|
||||
expect(assertUpdateSafe).toThrow("window operation");
|
||||
resolve({ show: async () => {}, setFocus: async () => {} });
|
||||
expect(await operation).toEqual({ opened: true });
|
||||
expect(assertUpdateSafe).not.toThrow();
|
||||
});
|
||||
it("does not create a window if preparation starts during the awaited lookup", async () => {
|
||||
const resolve = pendingLookup();
|
||||
const operation = openDetachedTabWindow("two", "SQL");
|
||||
await vi.waitFor(() => expect(mocks.getByLabel).toHaveBeenCalled());
|
||||
const release = acquireUpdateBarrier();
|
||||
resolve(null);
|
||||
const result = await operation;
|
||||
expect(result.opened).toBe(false);
|
||||
expect(result.error).toContain("Update preparation");
|
||||
expect(mocks.created).not.toHaveBeenCalled();
|
||||
release();
|
||||
expect(assertUpdateSafe).not.toThrow();
|
||||
});
|
||||
it("keeps a timed-out native creation blocked and closes a late child without showing it", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.getByLabel.mockResolvedValueOnce(null);
|
||||
mocks.destroy.mockResolvedValue(undefined);
|
||||
const operation = openDetachedTabWindow("late", "SQL");
|
||||
await vi.waitFor(() => expect(mocks.created).toHaveBeenCalled());
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect((await operation).opened).toBe(false);
|
||||
expect(assertUpdateSafe).toThrow("window operation");
|
||||
await mocks.callbacks.get("tauri://created")?.();
|
||||
expect(mocks.destroy).toHaveBeenCalledOnce();
|
||||
expect(mocks.show).not.toHaveBeenCalled();
|
||||
expect(mocks.focus).not.toHaveBeenCalled();
|
||||
expect(assertUpdateSafe).not.toThrow();
|
||||
});
|
||||
it("rejects a new detach before starting native lookup while preparation is active", async () => {
|
||||
const release = acquireUpdateBarrier();
|
||||
expect((await openDetachedTabWindow("three", "SQL")).opened).toBe(false);
|
||||
expect(mocks.getByLabel).not.toHaveBeenCalled();
|
||||
release();
|
||||
expect(assertUpdateSafe).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { effectScope } from "vue";
|
||||
import { acquireUpdateBarrier, beginUpdateSensitiveOperation, prepareUpdateWithDraftRecovery, UPDATE_RESTORE_KEY, assertUpdateAllowsCommand, assertUpdateSafe, setupUpdatePreparation, useUpdateBlocker } from "../updatePreparation";
|
||||
import i18n, { loadLocaleMessages } from "@/i18n";
|
||||
|
||||
const bus = vi.hoisted(() => ({ label: "main", members: ["main"] as string[], listeners: new Map<string, Set<{ label: string; callback: (event: any) => unknown }>>() }));
|
||||
vi.mock("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow: () => ({ label: bus.label }), getAllWebviewWindows: async () => bus.members.map((label) => ({ label })) }));
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: async (event: string, callback: (event: any) => unknown) => {
|
||||
const entry = { label: bus.label, callback };
|
||||
const listeners = bus.listeners.get(event) ?? new Set();
|
||||
listeners.add(entry);
|
||||
bus.listeners.set(event, listeners);
|
||||
return () => listeners.delete(entry);
|
||||
},
|
||||
emit: async (event: string, payload: unknown) => {
|
||||
for (const listener of bus.listeners.get(event) ?? []) void listener.callback({ payload });
|
||||
},
|
||||
emitTo: async (label: string, event: string, payload: unknown) => {
|
||||
for (const listener of bus.listeners.get(event) ?? []) if (listener.label === label) void listener.callback({ payload });
|
||||
},
|
||||
}));
|
||||
afterEach(() => {
|
||||
bus.listeners.clear();
|
||||
bus.members = ["main"];
|
||||
bus.label = "main";
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("update restart preparation", () => {
|
||||
it("translates a pending window operation using the current locale", async () => {
|
||||
const previousLocale = i18n.global.locale.value;
|
||||
await loadLocaleMessages("zh-CN");
|
||||
const release = beginUpdateSensitiveOperation();
|
||||
try {
|
||||
i18n.global.locale.value = "zh-CN";
|
||||
expect(assertUpdateSafe).toThrow("请等待当前窗口操作完成后再更新。");
|
||||
i18n.global.locale.value = "en";
|
||||
expect(assertUpdateSafe).toThrow("Please wait for the current window operation to finish before updating.");
|
||||
} finally {
|
||||
release();
|
||||
i18n.global.locale.value = previousLocale;
|
||||
}
|
||||
});
|
||||
|
||||
it("marks recovery only after all preparation succeeds and preserves recovery when relaunch fails", async () => {
|
||||
const storage = new Map<string, string>();
|
||||
const disk = {
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
prepareUpdateWithDraftRecovery(async () => {
|
||||
throw new Error("companion failed");
|
||||
}, disk),
|
||||
).rejects.toThrow("companion failed");
|
||||
expect(storage.has(UPDATE_RESTORE_KEY)).toBe(false);
|
||||
const release = vi.fn();
|
||||
const undo = await prepareUpdateWithDraftRecovery(async () => release, disk);
|
||||
expect(storage.get(UPDATE_RESTORE_KEY)).toBe("1");
|
||||
undo();
|
||||
expect(storage.get(UPDATE_RESTORE_KEY)).toBe("1");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("releases the barrier when saving the recovery marker fails", async () => {
|
||||
const release = vi.fn();
|
||||
const disk = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("storage unavailable");
|
||||
},
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
await expect(prepareUpdateWithDraftRecovery(async () => release, disk)).rejects.toThrow("storage unavailable");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks native task commands and input until explicitly released", () => {
|
||||
const release = acquireUpdateBarrier();
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).toThrow();
|
||||
expect(() => assertUpdateAllowsCommand("unknown_future_mutation")).toThrow();
|
||||
expect(() => assertUpdateAllowsCommand("save_open_tabs_state")).not.toThrow();
|
||||
expect(() => assertUpdateAllowsCommand("install_downloaded_update")).not.toThrow();
|
||||
const event = new Event("beforeinput", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
release();
|
||||
release();
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
|
||||
});
|
||||
it("removes editor vetoes when their scope is disposed", () => {
|
||||
const scope = effectScope();
|
||||
scope.run(() => useUpdateBlocker(() => "unsaved Redis value"));
|
||||
expect(assertUpdateSafe).toThrow("unsaved Redis value");
|
||||
scope.stop();
|
||||
expect(assertUpdateSafe).not.toThrow();
|
||||
});
|
||||
it("waits for every window to save and holds the barrier until release", async () => {
|
||||
bus.members = ["main", "detached-tab-one"];
|
||||
const persist = vi.fn(async () => {});
|
||||
const main = await setupUpdatePreparation({ assertSafe() {}, persist });
|
||||
bus.label = "detached-tab-one";
|
||||
const detached = await setupUpdatePreparation({ assertSafe() {}, persist });
|
||||
const release = await main.prepare();
|
||||
expect(persist).toHaveBeenCalledTimes(2);
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).toThrow();
|
||||
release();
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
|
||||
main.dispose();
|
||||
detached.dispose();
|
||||
});
|
||||
it("releases all windows if a draft write fails", async () => {
|
||||
const main = await setupUpdatePreparation({
|
||||
assertSafe() {},
|
||||
persist: async () => {
|
||||
throw new Error("disk full");
|
||||
},
|
||||
});
|
||||
await expect(main.prepare()).rejects.toThrow("disk full");
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
|
||||
main.dispose();
|
||||
});
|
||||
it("fails if a new window appears while saving", async () => {
|
||||
const main = await setupUpdatePreparation({
|
||||
assertSafe() {},
|
||||
persist: async () => {
|
||||
bus.members.push("late-window");
|
||||
},
|
||||
});
|
||||
await expect(main.prepare()).rejects.toThrow("Open windows changed");
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
|
||||
main.dispose();
|
||||
});
|
||||
it("times out a hung local disk write even without other windows", async () => {
|
||||
vi.useFakeTimers();
|
||||
const main = await setupUpdatePreparation({ assertSafe() {}, persist: () => new Promise(() => {}) });
|
||||
const result = expect(main.prepare()).rejects.toThrow("5 seconds");
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await result;
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
|
||||
main.dispose();
|
||||
});
|
||||
it("times out an unresponsive window after five seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
bus.members.push("unresponsive");
|
||||
const main = await setupUpdatePreparation({ assertSafe() {}, persist: async () => {} });
|
||||
const result = expect(main.prepare()).rejects.toThrow("5 seconds");
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await result;
|
||||
expect(() => assertUpdateAllowsCommand("execute_query")).not.toThrow();
|
||||
main.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertUpdateAllowsInteraction, beginUpdateSensitiveOperation } from "@/lib/app/updatePreparation";
|
||||
import { detachedWindowLabel, detachedWindowUrl } from "@/lib/app/windowContext";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
@@ -19,7 +20,9 @@ function errorMessage(error: unknown): string {
|
||||
|
||||
export async function openDetachedTabWindow(tabId: string, title: string, position?: DetachedWindowOpenPosition): Promise<DetachedWindowOpenResult> {
|
||||
if (!isTauriRuntime()) return { opened: false, error: "Detached windows are only available in the desktop app." };
|
||||
let finishOperation: (() => void) | undefined;
|
||||
try {
|
||||
finishOperation = beginUpdateSensitiveOperation();
|
||||
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
const label = detachedWindowLabel(tabId);
|
||||
const existing = await WebviewWindow.getByLabel(label);
|
||||
@@ -29,6 +32,7 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
|
||||
return { opened: true };
|
||||
}
|
||||
|
||||
assertUpdateAllowsInteraction();
|
||||
const child = new WebviewWindow(label, {
|
||||
url: detachedWindowUrl(tabId),
|
||||
title,
|
||||
@@ -42,14 +46,32 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
|
||||
center: false,
|
||||
});
|
||||
|
||||
// A caller timeout does not prove native window creation has stopped.
|
||||
// Keep this second reservation until a native terminal event arrives.
|
||||
const finishNativeCreation = beginUpdateSensitiveOperation();
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (result: DetachedWindowOpenResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
};
|
||||
void child.once("tauri://created", async () => {
|
||||
if (timedOut) {
|
||||
try {
|
||||
// The caller already restored its tab; never surface a duplicate.
|
||||
await child.destroy();
|
||||
finishNativeCreation();
|
||||
} catch (error) {
|
||||
// Keep the reservation if removal is uncertain, rather than
|
||||
// allowing restart to race a still-initializing webview.
|
||||
console.error("[DBX][detached-tab:late-create:close-error]", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (position) {
|
||||
const { PhysicalPosition } = await import("@tauri-apps/api/dpi");
|
||||
@@ -60,18 +82,26 @@ export async function openDetachedTabWindow(tabId: string, title: string, positi
|
||||
finish({ opened: true });
|
||||
} catch (error) {
|
||||
finish({ opened: false, error: errorMessage(error) });
|
||||
} finally {
|
||||
finishNativeCreation();
|
||||
}
|
||||
});
|
||||
void child.once("tauri://error", (event) => {
|
||||
const error = errorMessage(event?.payload);
|
||||
console.error("[DBX][detached-tab:create:error]", error);
|
||||
finish({ opened: false, error });
|
||||
finishNativeCreation();
|
||||
});
|
||||
setTimeout(() => finish({ opened: false, error: "Timed out while creating the detached window." }), 10_000);
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
finish({ opened: false, error: "Timed out while creating the detached window." });
|
||||
}, 10_000);
|
||||
});
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
console.error("[DBX][detached-tab:create:error]", error);
|
||||
return { opened: false, error: message };
|
||||
} finally {
|
||||
finishOperation?.();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export interface SavedOpenTab {
|
||||
nacosNamespace?: string;
|
||||
nacosNamespaceName?: string;
|
||||
structureTableName?: string;
|
||||
structureDraft?: QueryTab["structureDraft"];
|
||||
objectBrowser?: QueryTab["objectBrowser"];
|
||||
objectSource?: QueryTab["objectSource"];
|
||||
sourceView?: boolean;
|
||||
@@ -102,7 +103,7 @@ function shouldPersistTabSql(tab: QueryTab) {
|
||||
function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: string) {
|
||||
if (mode !== "query") return undefined;
|
||||
if (tab.externalSqlPath) return tab.originalSql ?? sql;
|
||||
if (tab.savedSqlId) return sql ? "" : undefined;
|
||||
if (tab.savedSqlId) return tab.originalSql ?? (sql ? "" : undefined);
|
||||
// Prefer the persisted originalSql so a clean prefilled query tab (sql === originalSql)
|
||||
// restores clean instead of being marked dirty. Older saved state without this field
|
||||
// falls through to "" (preserving prior behavior for user-edited scratch tabs).
|
||||
@@ -141,9 +142,9 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
||||
sql: shouldPersistTabSql(tab) ? tab.sql : "",
|
||||
...(tab.editorViewport ? { editorViewport: tab.editorViewport } : {}),
|
||||
...(tab.editorSelection ? { editorSelection: tab.editorSelection } : {}),
|
||||
// Plain query tabs always round-trip originalSql. External-file tabs only persist it
|
||||
// while dirty so their disk baseline survives restart without duplicating clean SQL.
|
||||
...(tab.originalSql !== undefined && !tab.savedSqlId && (!tab.externalSqlPath || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}),
|
||||
// File-backed drafts retain their baseline, including when the edited SQL is empty.
|
||||
// Clean file-backed tabs omit it so startup can hydrate the current file content.
|
||||
...(tab.originalSql !== undefined && ((!tab.savedSqlId && !tab.externalSqlPath) || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}),
|
||||
savedSqlId: tab.savedSqlId,
|
||||
externalSqlPath: tab.externalSqlPath,
|
||||
...(tab.externalSqlFileVersion ? { externalSqlFileVersion: tab.externalSqlFileVersion } : {}),
|
||||
@@ -168,6 +169,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
||||
...(tab.nacosNamespace !== undefined ? { nacosNamespace: tab.nacosNamespace } : {}),
|
||||
...(tab.nacosNamespaceName !== undefined ? { nacosNamespaceName: tab.nacosNamespaceName } : {}),
|
||||
...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}),
|
||||
...(tab.structureDraft ? { structureDraft: JSON.parse(JSON.stringify(tab.structureDraft)) } : {}),
|
||||
objectBrowser: tab.objectBrowser,
|
||||
objectSource: tab.objectSource,
|
||||
...(tab.sourceView ? { sourceView: true } : {}),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { onScopeDispose } from "vue";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
const blockers = new Set<() => string | undefined>();
|
||||
let barrierDepth = 0;
|
||||
export const UPDATE_RESTORE_KEY = "dbx-update-restore-tabs";
|
||||
|
||||
/** Editors with non-durable drafts must explicitly veto a restart. */
|
||||
export function useUpdateBlocker(blocker: () => string | undefined): void {
|
||||
blockers.add(blocker);
|
||||
onScopeDispose(() => blockers.delete(blocker));
|
||||
}
|
||||
|
||||
/** Reserve a window/task transition before its first asynchronous boundary. */
|
||||
export function beginUpdateSensitiveOperation(reason?: string): () => void {
|
||||
assertUpdateAllowsInteraction();
|
||||
const blocker = () => reason ?? i18n.global.t("updates.preparationWindowOperation");
|
||||
blockers.add(blocker);
|
||||
return () => {
|
||||
blockers.delete(blocker);
|
||||
};
|
||||
}
|
||||
|
||||
export function assertUpdateSafe(): void {
|
||||
for (const blocker of blockers) {
|
||||
const reason = blocker();
|
||||
if (reason) throw new Error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertUpdateAllowsCommand(command: string): void {
|
||||
if (!barrierDepth) return;
|
||||
// During the short install barrier only updater and durable-draft I/O are allowed.
|
||||
const allowed = new Set(["save_open_tabs_state", "save_detached_tab_handoff", "load_detached_tab_handoff", "save_tab_result_snapshot", "get_downloaded_update", "install_downloaded_update", "discard_downloaded_update"]);
|
||||
if (!allowed.has(command)) throw new Error("Update preparation is in progress. Please wait.");
|
||||
}
|
||||
|
||||
export function isUpdatePreparationActive(): boolean {
|
||||
return barrierDepth > 0;
|
||||
}
|
||||
|
||||
export function assertUpdateAllowsInteraction(): void {
|
||||
if (barrierDepth) throw new Error("Update preparation is in progress. Please wait.");
|
||||
}
|
||||
|
||||
// Register during module evaluation, before App mounts its capture-phase shortcuts.
|
||||
// Registering only at installation time would let older listeners run first.
|
||||
const inputEvents = ["keydown", "keyup", "pointerdown", "mousedown", "click", "dblclick", "beforeinput", "paste", "cut", "drop", "contextmenu"];
|
||||
const stopDuringPreparation = (event: Event) => {
|
||||
if (!barrierDepth) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
if (typeof window !== "undefined" && window.addEventListener) {
|
||||
for (const event of inputEvents) window.addEventListener(event, stopDuringPreparation, true);
|
||||
}
|
||||
|
||||
export function acquireUpdateBarrier(): () => void {
|
||||
barrierDepth++;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
barrierDepth--;
|
||||
};
|
||||
}
|
||||
|
||||
/** Mark only fully prepared attempts; retain recovery if install succeeds but relaunch fails. */
|
||||
export async function prepareUpdateWithDraftRecovery(prepare: () => Promise<() => void>, storage: Pick<Storage, "setItem"> = window.localStorage): Promise<() => void> {
|
||||
const release = await prepare();
|
||||
try {
|
||||
storage.setItem(UPDATE_RESTORE_KEY, "1");
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
// The release callback does not know whether native installation succeeded.
|
||||
// Conservatively keep drafts recoverable after any fully prepared attempt.
|
||||
return release;
|
||||
}
|
||||
|
||||
export interface UpdatePreparationParticipant {
|
||||
assertSafe(): void;
|
||||
persist(): Promise<void>;
|
||||
translate?(key: string): string;
|
||||
}
|
||||
|
||||
/** ACK every live webview before the installer can terminate the process. */
|
||||
export async function setupUpdatePreparation(participant: UpdatePreparationParticipant): Promise<{ prepare(): Promise<() => void>; dispose(): void }> {
|
||||
const { listen, emit, emitTo } = await import("@tauri-apps/api/event");
|
||||
const { getAllWebviewWindows, getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||
const label = getCurrentWebviewWindow().label;
|
||||
const message = (key: string, fallback: string) => participant.translate?.(`updates.${key}`) ?? fallback;
|
||||
const held = new Map<string, () => void>();
|
||||
const released = new Set<string>();
|
||||
const pending = new Map<string, (payload: { label: string; error?: string }) => void>();
|
||||
const prepareLocal = async (id: string) => {
|
||||
if (released.has(id)) throw new Error(message("preparationCancelled", "Update preparation was cancelled"));
|
||||
if (held.has(id)) throw new Error(message("preparationBusy", "Update preparation already in progress"));
|
||||
held.set(id, acquireUpdateBarrier());
|
||||
participant.assertSafe();
|
||||
assertUpdateSafe();
|
||||
await participant.persist();
|
||||
participant.assertSafe();
|
||||
assertUpdateSafe();
|
||||
};
|
||||
const release = (id: string) => {
|
||||
released.add(id);
|
||||
held.get(id)?.();
|
||||
held.delete(id);
|
||||
};
|
||||
const unlisteners = [
|
||||
await listen<{ id: string; owner: string }>("dbx:update-prepare", async ({ payload }) => {
|
||||
if (payload.owner === label) return;
|
||||
let error: string | undefined;
|
||||
try {
|
||||
await prepareLocal(payload.id);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
release(payload.id);
|
||||
}
|
||||
await emitTo(payload.owner, "dbx:update-prepared", { id: payload.id, label, error });
|
||||
}),
|
||||
await listen<{ id: string; label: string; error?: string }>("dbx:update-prepared", ({ payload }) => pending.get(payload.id)?.(payload)),
|
||||
await listen<{ id: string }>("dbx:update-release", ({ payload }) => release(payload.id)),
|
||||
];
|
||||
return {
|
||||
async prepare() {
|
||||
if (held.size) throw new Error(message("preparationBusy", "Update preparation already in progress"));
|
||||
const id = crypto.randomUUID();
|
||||
const members = (await getAllWebviewWindows()).map((item) => item.label).sort();
|
||||
const expected = new Set(members.filter((item) => item !== label));
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const acknowledgements = new Promise<void>((resolve, reject) => {
|
||||
pending.set(id, (payload) => {
|
||||
if (!expected.has(payload.label)) return;
|
||||
if (payload.error) {
|
||||
reject(new Error(payload.error));
|
||||
return;
|
||||
}
|
||||
expected.delete(payload.label);
|
||||
if (!expected.size) resolve();
|
||||
});
|
||||
if (!expected.size) resolve();
|
||||
});
|
||||
// Attach the rejection handler immediately while the local disk flush runs.
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message("preparationTimeout", "A window did not save its drafts within 5 seconds. Please retry."))), 5000);
|
||||
});
|
||||
const work = Promise.race([Promise.all([acknowledgements, prepareLocal(id), emit("dbx:update-prepare", { id, owner: label })]), timeout]);
|
||||
try {
|
||||
await work;
|
||||
const current = (await getAllWebviewWindows()).map((item) => item.label).sort();
|
||||
if (JSON.stringify(current) !== JSON.stringify(members)) throw new Error(message("preparationWindowsChanged", "Open windows changed during update preparation. Please retry."));
|
||||
participant.assertSafe();
|
||||
assertUpdateSafe();
|
||||
return () => {
|
||||
release(id);
|
||||
void emit("dbx:update-release", { id });
|
||||
};
|
||||
} catch (error) {
|
||||
release(id);
|
||||
await emit("dbx:update-release", { id });
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
pending.delete(id);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
unlisteners.forEach((unlisten) => unlisten());
|
||||
held.forEach((unlock) => unlock());
|
||||
held.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -833,6 +833,8 @@ export const fetchChangelog = forward("fetchChangelog");
|
||||
export const getSystemProxyUrl = forward("getSystemProxyUrl");
|
||||
export const downloadUpdate = forward("downloadUpdate");
|
||||
export const cancelUpdateDownload = forward("cancelUpdateDownload");
|
||||
export const getDownloadedUpdate = forward("getDownloadedUpdate");
|
||||
export const discardDownloadedUpdate = forward("discardDownloadedUpdate");
|
||||
export const installDownloadedUpdate = forward("installDownloadedUpdate");
|
||||
export const getAppVersion = forward("getAppVersion");
|
||||
export const getAppSupportInfo = forward("getAppSupportInfo");
|
||||
@@ -891,6 +893,7 @@ export type {
|
||||
McpHttpServerStatus,
|
||||
WebMcpHttpStatus,
|
||||
UpdateInfo,
|
||||
DownloadedUpdate,
|
||||
RedisBlob,
|
||||
RedisCollectionPage,
|
||||
RedisDatabaseInfo,
|
||||
|
||||
@@ -77,6 +77,7 @@ import type {
|
||||
DriverInstallProgress,
|
||||
JavaRuntimeConfig,
|
||||
UpdateInfo,
|
||||
DownloadedUpdate,
|
||||
UpdateDownloadSource,
|
||||
RedisCollectionPage,
|
||||
RedisDatabaseInfo,
|
||||
@@ -4482,13 +4483,19 @@ export async function getSystemProxyUrl(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion?: string): Promise<void> {
|
||||
export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion: string, _attemptId: string, _releaseNotes?: string): Promise<DownloadedUpdate> {
|
||||
throw new Error("In-app update downloads are only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function cancelUpdateDownload(): Promise<void> {}
|
||||
|
||||
export async function installDownloadedUpdate(): Promise<void> {
|
||||
export async function getDownloadedUpdate(): Promise<DownloadedUpdate | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function discardDownloadedUpdate(_cacheId: string): Promise<void> {}
|
||||
|
||||
export async function installDownloadedUpdate(_cacheId: string, _expectedVersion: string): Promise<void> {
|
||||
throw new Error("In-app update installation is only available in the desktop app.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
||||
import { assertUpdateAllowsCommand } from "@/lib/app/updatePreparation";
|
||||
|
||||
function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
assertUpdateAllowsCommand(command);
|
||||
return tauriInvoke<T>(command, args);
|
||||
}
|
||||
import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff";
|
||||
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
@@ -2469,7 +2475,18 @@ export interface UpdateInfo {
|
||||
|
||||
export type UpdateDownloadSource = "official" | "cnb";
|
||||
|
||||
export interface DownloadedUpdate {
|
||||
cache_id: string;
|
||||
version: string;
|
||||
portable_mode: boolean;
|
||||
release_url: string;
|
||||
release_notes: string;
|
||||
downloaded_at: number;
|
||||
}
|
||||
|
||||
export interface UpdateDownloadProgress {
|
||||
attempt_id: string;
|
||||
version: string;
|
||||
downloaded: number;
|
||||
total: number | null;
|
||||
}
|
||||
@@ -2516,16 +2533,24 @@ export async function getSystemProxyUrl(): Promise<string | null> {
|
||||
return invoke("get_system_proxy_url");
|
||||
}
|
||||
|
||||
export async function downloadUpdate(source: UpdateDownloadSource, latestVersion?: string): Promise<void> {
|
||||
return invoke("download_update", { source, latestVersion });
|
||||
export async function downloadUpdate(source: UpdateDownloadSource, latestVersion: string, attemptId: string, releaseNotes?: string): Promise<DownloadedUpdate> {
|
||||
return invoke("download_update", { source, latestVersion, attemptId, releaseNotes });
|
||||
}
|
||||
|
||||
export async function cancelUpdateDownload(): Promise<void> {
|
||||
return invoke("cancel_update_download");
|
||||
}
|
||||
|
||||
export async function installDownloadedUpdate(): Promise<void> {
|
||||
return invoke("install_downloaded_update");
|
||||
export async function getDownloadedUpdate(): Promise<DownloadedUpdate | null> {
|
||||
return invoke("get_downloaded_update");
|
||||
}
|
||||
|
||||
export async function discardDownloadedUpdate(cacheId: string): Promise<void> {
|
||||
return invoke("discard_downloaded_update", { cacheId });
|
||||
}
|
||||
|
||||
export async function installDownloadedUpdate(cacheId: string, expectedVersion: string): Promise<void> {
|
||||
return invoke("install_downloaded_update", { cacheId, expectedVersion });
|
||||
}
|
||||
|
||||
export async function getAppVersion(): Promise<string> {
|
||||
|
||||
@@ -71,15 +71,15 @@ describe("queryStore adoptDetachedTab", () => {
|
||||
expect(queryStore.isTabDirty(tab)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not touch the backend when the handoff already carries unsaved edits", async () => {
|
||||
it.each(["SELECT 2;", ""])("does not replace unsaved edits %j with file content", async (sql) => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
const tabId = await queryStore.adoptDetachedTab(handoffFor({ sql: "SELECT 2;", originalSql: "SELECT 1;" }));
|
||||
const tabId = await queryStore.adoptDetachedTab(handoffFor({ sql, originalSql: "SELECT 1;" }));
|
||||
|
||||
expect(mocks.loadSavedSqlFile).not.toHaveBeenCalled();
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId)!;
|
||||
expect(tab.sql).toBe("SELECT 2;");
|
||||
expect(tab.sql).toBe(sql);
|
||||
expect(queryStore.isTabDirty(tab)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const savedFile = { id: "saved", connectionId: "conn", database: "db", name: "saved.sql", sql: "SELECT 1;", createdAt: "2026-09-10T00:00:00Z", updatedAt: "2026-09-10T00:00:00Z" };
|
||||
const mocks = vi.hoisted(() => ({ saved: null as any }));
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/backend/api")>()),
|
||||
loadOpenTabsState: async () => mocks.saved,
|
||||
saveOpenTabsState: async (payload: unknown) => {
|
||||
mocks.saved = payload;
|
||||
},
|
||||
listDetachedTabHandoffs: async () => [],
|
||||
}));
|
||||
describe("update draft recovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.saved = null;
|
||||
const storage = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) });
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
it.each(["", "SELECT 2;"])("preserves a saved SQL draft %j through recovery and file hydration", async (sql) => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const { useSavedSqlStore } = await import("@/stores/savedSqlStore");
|
||||
const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
|
||||
const store = useQueryStore();
|
||||
const id = store.createTab("conn", "db");
|
||||
Object.assign(store.tabs.find((tab) => tab.id === id)!, { savedSqlId: "saved", sql, originalSql: "SELECT 1;" });
|
||||
await store.flushPendingPersist();
|
||||
localStorage.setItem(UPDATE_RESTORE_KEY, "1");
|
||||
setActivePinia(createPinia());
|
||||
useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
|
||||
const ensureFileContent = vi.spyOn(useSavedSqlStore(), "ensureFileContent");
|
||||
const restored = useQueryStore();
|
||||
await restored.initOpenTabs({ validConnectionIds: ["conn"] });
|
||||
await restored.hydrateSavedSqlTabs();
|
||||
const tab = restored.tabs.find((tab) => tab.id === id)!;
|
||||
expect(tab.sql).toBe(sql);
|
||||
expect(tab.originalSql).toBe("SELECT 1;");
|
||||
expect(restored.isTabDirty(tab)).toBe(true);
|
||||
expect(ensureFileContent).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(UPDATE_RESTORE_KEY)).toBeNull();
|
||||
expect(restored.openSavedSql(savedFile)).toBe(id);
|
||||
expect(tab.sql).toBe(sql);
|
||||
expect(tab.originalSql).toBe("SELECT 1;");
|
||||
expect(restored.isTabDirty(tab)).toBe(true);
|
||||
ensureFileContent.mockRestore();
|
||||
});
|
||||
|
||||
it("loads file content when reopening a clean restored SQL library tab", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
|
||||
const store = useQueryStore();
|
||||
const id = store.openSavedSql(savedFile);
|
||||
await store.flushPendingPersist();
|
||||
localStorage.setItem(UPDATE_RESTORE_KEY, "1");
|
||||
setActivePinia(createPinia());
|
||||
const restored = useQueryStore();
|
||||
await restored.initOpenTabs({ validConnectionIds: ["conn"] });
|
||||
const tab = restored.tabs.find((tab) => tab.id === id)!;
|
||||
expect(tab.sql).toBe("");
|
||||
expect(tab.originalSql).toBeUndefined();
|
||||
expect(restored.openSavedSql(savedFile)).toBe(id);
|
||||
expect(tab.sql).toBe(savedFile.sql);
|
||||
expect(tab.originalSql).toBe(savedFile.sql);
|
||||
expect(restored.isTabDirty(tab)).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers SQL and structure drafts once even when normal restore and close protection are disabled", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const { UPDATE_RESTORE_KEY } = await import("@/lib/app/updatePreparation");
|
||||
const settings = useSettingsStore();
|
||||
settings.updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
|
||||
const store = useQueryStore();
|
||||
const sqlId = store.createTab("conn", "db");
|
||||
store.updateSql(sqlId, "select unsaved;");
|
||||
const structureId = store.openTableStructure("conn", "db", undefined, "users");
|
||||
const draft = { dirty: true, initialized: true, ddlDraft: "alter table users add column name text" };
|
||||
store.tabs.find((tab) => tab.id === structureId)!.structureDraft = draft as any;
|
||||
await store.flushPendingPersist();
|
||||
localStorage.setItem(UPDATE_RESTORE_KEY, "1");
|
||||
setActivePinia(createPinia());
|
||||
useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none", confirmUnsavedSqlClose: false });
|
||||
const restored = useQueryStore();
|
||||
await restored.initOpenTabs({ validConnectionIds: ["conn"] });
|
||||
expect(restored.tabs.find((tab) => tab.id === sqlId)?.sql).toBe("select unsaved;");
|
||||
expect(restored.tabs.find((tab) => tab.id === structureId)?.structureDraft).toEqual(draft);
|
||||
expect(localStorage.getItem(UPDATE_RESTORE_KEY)).toBeNull();
|
||||
setActivePinia(createPinia());
|
||||
useSettingsStore().updateEditorSettings({ openTabsRestoreMode: "none" });
|
||||
const subsequent = useQueryStore();
|
||||
await subsequent.initOpenTabs({ validConnectionIds: ["conn"] });
|
||||
expect(subsequent.tabs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UPDATE_RESTORE_KEY, assertUpdateAllowsInteraction } from "@/lib/app/updatePreparation";
|
||||
import { defineStore } from "pinia";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
|
||||
@@ -741,7 +742,7 @@ function restoreSavedTabsFromPayload(
|
||||
payload: { tabs?: unknown; activeTabId?: unknown; groups?: unknown; focusedGroupId?: unknown; orientation?: unknown; sizes?: unknown } | null | undefined,
|
||||
options: { validConnectionIds?: Iterable<string> } = {},
|
||||
): { tabs: QueryTab[]; activeTabId: string | null; workspace?: unknown } {
|
||||
const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
|
||||
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
|
||||
if (restoreMode === "none") {
|
||||
return { tabs: [], activeTabId: null, workspace: undefined };
|
||||
}
|
||||
@@ -764,7 +765,7 @@ function restoreSavedTabsFromPayload(
|
||||
}
|
||||
|
||||
function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable<string> } = {}): { tabs: QueryTab[]; activeTabId: string | null; workspace?: undefined } {
|
||||
const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode;
|
||||
const restoreMode = safeLocalStorageGet(UPDATE_RESTORE_KEY) === "1" ? "all" : useSettingsStore().editorSettings.openTabsRestoreMode;
|
||||
if (restoreMode === "none") {
|
||||
return { tabs: [], activeTabId: null, workspace: undefined };
|
||||
}
|
||||
@@ -1034,6 +1035,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
* the new tab ownerless until a post-flush watcher repaired the groups.
|
||||
*/
|
||||
function registerOpenTab(tab: QueryTab, options: { activate?: boolean; insertAfterTabId?: string } = {}): string {
|
||||
assertUpdateAllowsInteraction();
|
||||
const anchorIndex = options.insertAfterTabId ? tabs.value.findIndex((item) => item.id === options.insertAfterTabId) : -1;
|
||||
if (anchorIndex >= 0) {
|
||||
tabs.value.splice(anchorIndex + 1, 0, tab);
|
||||
@@ -2215,7 +2217,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (saved?.tabs && Array.isArray(saved.tabs)) {
|
||||
const restored = restoreSavedTabsFromPayload(saved, options);
|
||||
applyRestoredOpenTabs(restored);
|
||||
if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
|
||||
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
|
||||
// Restore is explicitly disabled, so stale saved payloads should not
|
||||
// reappear if the user later changes the setting.
|
||||
clearLegacySavedTabs();
|
||||
@@ -2223,6 +2225,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
await recoverDetachedTabsToMain(options);
|
||||
await saveTabs(tabs.value, activeTabId.value).catch(() => undefined);
|
||||
safeLocalStorageRemove(UPDATE_RESTORE_KEY);
|
||||
isOpenTabsLoaded.value = true;
|
||||
scheduleResultCacheMaintenance();
|
||||
return;
|
||||
@@ -2232,7 +2235,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (legacy.rawTabs || legacy.rawActiveTabId) {
|
||||
const restored = restoreLegacySavedTabs(options);
|
||||
applyRestoredOpenTabs(restored);
|
||||
if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
|
||||
if (safeLocalStorageGet(UPDATE_RESTORE_KEY) !== "1" && useSettingsStore().editorSettings.openTabsRestoreMode === "none") {
|
||||
// Restore is explicitly disabled, so keeping the legacy startup payload
|
||||
// would resurrect old tabs if the user later changes the setting.
|
||||
clearLegacySavedTabs();
|
||||
@@ -2287,6 +2290,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
autoCommit: t.autoCommit,
|
||||
resultAutoSave: t.resultAutoSave,
|
||||
structureTableName: t.structureTableName,
|
||||
structureDraft: t.structureDraft,
|
||||
objectBrowser: t.objectBrowser,
|
||||
objectSource: t.objectSource,
|
||||
sourceView: t.sourceView,
|
||||
@@ -2431,7 +2435,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
// A freshly created detached window never runs the main-window bootstrap that
|
||||
// populates savedSqlStore's local file index, so useSavedSqlStore().ensureFileContent()
|
||||
// can't find the file locally either — go straight to the backend instead.
|
||||
if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql) {
|
||||
if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql && restoredTab.originalSql === undefined) {
|
||||
const file = await api.loadSavedSqlFile(restoredTab.savedSqlId).catch(() => undefined);
|
||||
if (file) {
|
||||
restoredTab.title = restoredTab.customTitle ? restoredTab.title : file.name;
|
||||
@@ -3155,7 +3159,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
return !!tab.structureDraft && tab.structureDraft.dirty !== false;
|
||||
}
|
||||
if (tab.mode !== "query") return false;
|
||||
if (!tab.externalSqlPath && !tab.sql.trim()) return false;
|
||||
if (!tab.externalSqlPath && !tab.sql.trim() && !(tab.savedSqlId && tab.originalSql !== undefined)) return false;
|
||||
const original = tab.originalSql;
|
||||
if (original === undefined) return !!tab.savedSqlId;
|
||||
return tab.sql !== original;
|
||||
@@ -4277,7 +4281,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const existing = tabs.value.find((tab) => tab.savedSqlId === file.id);
|
||||
if (existing) {
|
||||
persistSavedSqlEditorPosition(existing);
|
||||
if (!existing.sql && file.sql) {
|
||||
if (!existing.sql && file.sql && existing.originalSql === undefined) {
|
||||
existing.sql = file.sql;
|
||||
existing.originalSql = file.sql;
|
||||
const restored = restoreSavedSqlEditorPosition(file.id, file.sql);
|
||||
@@ -4317,7 +4321,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
async function hydrateSavedSqlTabs() {
|
||||
await initSavedSqlEditorPositions();
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "");
|
||||
const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === "" && tab.originalSql === undefined);
|
||||
for (const tab of linkedTabs) {
|
||||
const file = await savedSqlStore.ensureFileContent(tab.savedSqlId!);
|
||||
if (!file) continue;
|
||||
@@ -4602,6 +4606,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
async function executeCurrentTab() {
|
||||
assertUpdateAllowsInteraction();
|
||||
const tab = tabs.value.find((t) => t.id === activeTabId.value);
|
||||
if (!tab || !tab.sql.trim()) return;
|
||||
|
||||
@@ -5525,6 +5530,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
batchResume?: BatchSqlResumeOptions;
|
||||
},
|
||||
) {
|
||||
assertUpdateAllowsInteraction();
|
||||
const tab = findExecutionTab(id);
|
||||
if (!tab || !sql.trim()) return;
|
||||
|
||||
@@ -6780,6 +6786,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
|
||||
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
|
||||
assertUpdateAllowsInteraction();
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab) return { ok: false as const, reason: "empty" as const };
|
||||
const conn = useConnectionStore().getConfig(tab.connectionId);
|
||||
|
||||
Reference in new issue
Block a user