mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(updater): download and prepare desktop updates in background
This commit is contained in:
Generated
-2
@@ -8847,8 +8847,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
|
||||
exclude = ["vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
|
||||
exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/wry"]
|
||||
|
||||
[patch.crates-io]
|
||||
# Offline signed update restoration and checked Windows installer launch.
|
||||
tauri-plugin-updater = { path = "vendor/tauri-plugin-updater" }
|
||||
# Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors
|
||||
# upstream rust-ctor#443 until tauri-utils moves to ctor 1.x.
|
||||
ctor = { path = "vendor/ctor" }
|
||||
|
||||
+1
-1
@@ -526,7 +526,7 @@ DBX 是免费开源项目,但持续维护、数据库兼容性测试、基础
|
||||
|
||||
<details>
|
||||
<summary><strong>DBX 会收集用户数据吗?</strong></summary>
|
||||
不会。DBX 不收集任何遥测数据。自动更新功能仅检查 GitHub Releases 获取新版本——你可以在设置中禁用它。
|
||||
不会。DBX 不收集任何遥测数据。开启更新通知时,桌面端会通过所选更新源检查新版本并静默下载安装包;下载并校验完成后,更新入口显示提示,只有点击“重启并更新”才会安装。已下载的安装包会保留到下次启动,也可忽略该版本。你可以在设置中关闭自动检查和下载。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# 桌面更新验收
|
||||
|
||||
本清单用于打包后的真实升级测试。单元测试不能证明系统安装器能够替换文件、重新启动应用或恢复窗口内容。使用测试签名与独立测试配置发布两个连续版本,避免给正式用户推送测试版本。
|
||||
|
||||
## 环境
|
||||
|
||||
- macOS:从安装到 Applications 的旧版应用升级。
|
||||
- Windows:当前用户安装的 NSIS 版本,以及带 `portable.dbx` 的便携版本;发布 MSI 时追加 MSI 验收。
|
||||
- 两种 CPU 架构分别验证实际发布的安装产物;不支持自动更新的 Windows 7 包继续手动升级。
|
||||
- 升级前记录应用版本、用户数据目录、连接列表、SQL 草稿和窗口布局;使用可丢弃的测试数据库。
|
||||
|
||||
## 静默下载与就绪
|
||||
|
||||
1. 开启更新通知,启动旧版本,发布或提供新版本元数据。
|
||||
2. 不操作更新入口:确认没有通知、弹窗、工具栏下载动画,也没有安装器启动或应用退出。
|
||||
3. 主动打开更新面板,确认可见下载进度;关闭面板,下载继续。
|
||||
4. 包完成下载、验签和缓存提交后才出现红点,面板显示准确版本与“重启并更新”。
|
||||
5. 不点击安装,等待任务完成、关闭面板、正常退出应用,均不得开始安装。
|
||||
|
||||
## 本地缓存与失败恢复
|
||||
|
||||
1. 下载就绪后退出应用、断网、重新启动:应恢复同一版本的就绪状态。
|
||||
2. 点击“重启并更新”,确认安装过程不请求更新元数据或重新下载安装包。
|
||||
3. 分别截断安装包、修改签名、放入未完成缓存、使用不匹配架构或已安装版本:不能显示为可安装,不能启动安装器。
|
||||
4. 模拟网络断开、下载超时、磁盘满:后台无提示;主动打开面板可查看原因、重试或切源。
|
||||
5. 下载中取消或切源,确认旧批次的进度/完成事件不覆盖新批次;缓存已经就绪时切源不重下。
|
||||
6. 忽略就绪版本后,重开应用不再提示或下载该版本;关闭更新通知停止未完成下载,但保留完整缓存供手动安装。
|
||||
|
||||
## 工作与草稿保护
|
||||
|
||||
1. 分别保留新 SQL、已保存 SQL 的修改、外部 SQL 文件修改和可恢复的结构草稿;普通退出设置切换到不保留草稿,仍应在更新后恢复本次编辑。
|
||||
2. 在分离窗口修改 SQL;更新前必须收到该窗口的持久化成功确认,升级后核对实际内容。
|
||||
3. 模拟保存失败或分离窗口无响应超过 5 秒:安装不执行,已有缓存不丢失,界面恢复操作能力。
|
||||
4. 保留未提交网格修改、活动事务、无法恢复的配置编辑:更新被阻止,不得自动提交或丢弃内容。
|
||||
5. 查询、导入导出或 AI 任务运行时不能安装;任务结束后仍需主动点击。
|
||||
6. 在准备保存期间尝试启动任务、编辑或打开新分离窗口:不能漏过保护;准备失败后这些操作应恢复正常。
|
||||
|
||||
## 安装与重启
|
||||
|
||||
1. macOS:点击一次后安装并立即重启;不再要求第二次点击。模拟重启失败后,再次操作只重启、不重复安装。
|
||||
2. Windows 安装版:只在明确点击后启动一次安装器并退出应用;安装成功后自动启动新版。
|
||||
3. Windows:取消系统权限请求或模拟安装器启动失败,应用不能直接退出或永久停在安装中;允许重试。
|
||||
4. Windows 便携版:替换并重启后继续使用原用户数据目录;模拟替换/启动失败,检查原程序回退。
|
||||
5. 重启后确认版本、草稿、连接列表与用户数据正确;旧缓存已清理。
|
||||
|
||||
## 记录
|
||||
|
||||
每次验收记录旧版/新版号、系统与架构、安装类型、结果以及失败日志。只有实际执行通过的项目才能标记通过;尚未执行的平台明确标记“未验证”。
|
||||
|
||||
## 当前保护边界
|
||||
|
||||
更新准备屏障覆盖主窗口、分离窗口及桌面 API 调用,不是数据库引擎的全局任务锁。独立 MCP 客户端或原生后端直接发起的工作不受这一前端屏障原子控制。没有可靠编辑基线的配置对话框会保守地阻止更新,关闭该对话框后才能继续。
|
||||
+2
-2
@@ -122,9 +122,9 @@ test("wires the active task guard into update installation and restart, but not
|
||||
assert.match(appSource, /getActiveTaskCount: \(\) => trackedUpdateTaskCount\.value/);
|
||||
// Only restarting/exiting to finish the install has to wait on active work;
|
||||
// starting the background download itself never blocks.
|
||||
assert.equal(updaterSource.match(/if \(blockUpdateForActiveTasks\(\)\) return;/g)?.length, 1);
|
||||
assert.match(updaterSource, /shouldBlockAppUpdate\(activeTaskCount.value\)/);
|
||||
assert.match(dialogSource, /role="alert"[\s\S]*updates\.activeTasksBlockUpdate/);
|
||||
assert.equal(dialogSource.match(/:disabled="activeTaskCount > 0"/g)?.length, 2);
|
||||
assert.equal(dialogSource.match(/:disabled="activeTaskCount > 0 \|\| isIgnoringUpdate"/g)?.length, 2);
|
||||
assert.match(dialogSource, /ignore-version/);
|
||||
assert.match(updaterSource, /ignoreCurrentVersion/);
|
||||
});
|
||||
|
||||
+2
@@ -515,7 +515,9 @@ test("hydrating saved SQL content preserves its restored runtime target", async
|
||||
store.createTab("current-connection", "runtime_database", "current.sql", "query", "runtime_schema");
|
||||
const tabId = store.openSavedSql(file);
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
// Clean restored tabs omit both fields until their saved file is hydrated.
|
||||
tab.sql = "";
|
||||
tab.originalSql = undefined;
|
||||
|
||||
await store.hydrateSavedSqlTabs();
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ tauri-plugin-dialog = "2.7.0"
|
||||
tauri-plugin-fs = "2.5.0"
|
||||
tauri-plugin-single-instance = "2.4.2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-updater = "=2.10.1"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
redis = { version = "0.32.2", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp", "cluster-async"] }
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-position",
|
||||
|
||||
@@ -58,6 +58,7 @@ pub mod text_export;
|
||||
pub mod transfer;
|
||||
pub mod tunnel_profiles;
|
||||
pub mod update;
|
||||
mod update_cache;
|
||||
mod update_portable;
|
||||
pub mod vector_cmd;
|
||||
pub mod window_controls;
|
||||
|
||||
@@ -5,7 +5,11 @@ use std::sync::{
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::update_portable;
|
||||
pub use super::update_cache::DownloadedUpdate;
|
||||
use super::{
|
||||
update_cache::{self, CacheRecord},
|
||||
update_portable,
|
||||
};
|
||||
pub use dbx_core::update::UpdateInfo;
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -37,6 +41,8 @@ pub enum UpdateDownloadSource {
|
||||
pub struct UpdateDownloadProgress {
|
||||
pub downloaded: u64,
|
||||
pub total: Option<u64>,
|
||||
pub attempt_id: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -64,7 +70,12 @@ impl UpdateDownloadProgressGate {
|
||||
enum PendingUpdate {
|
||||
Downloading(Arc<DownloadCancellation>),
|
||||
Installing,
|
||||
Ready(ReadyUpdate),
|
||||
Ready(Box<CachedUpdate>),
|
||||
}
|
||||
|
||||
struct CachedUpdate {
|
||||
record: CacheRecord,
|
||||
ready: ReadyUpdate,
|
||||
}
|
||||
|
||||
enum ReadyUpdate {
|
||||
@@ -128,7 +139,12 @@ impl PendingUpdateState {
|
||||
Ok(cancellation)
|
||||
}
|
||||
|
||||
fn finish_download(&self, cancellation: &Arc<DownloadCancellation>, update: ReadyUpdate) -> Result<(), String> {
|
||||
fn finish_download(
|
||||
&self,
|
||||
cancellation: &Arc<DownloadCancellation>,
|
||||
update: CachedUpdate,
|
||||
root: &std::path::Path,
|
||||
) -> Result<(), String> {
|
||||
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
|
||||
let is_current = matches!(
|
||||
pending.as_ref(),
|
||||
@@ -138,7 +154,12 @@ impl PendingUpdateState {
|
||||
if !is_current {
|
||||
return Err(DOWNLOAD_CANCELED_ERROR.to_string());
|
||||
}
|
||||
*pending = Some(PendingUpdate::Ready(update));
|
||||
let bytes = match &update.ready {
|
||||
ReadyUpdate::Installer { bytes, .. } => bytes,
|
||||
ReadyUpdate::Portable { archive, .. } => archive,
|
||||
};
|
||||
update_cache::commit(root, &update.record, bytes)?;
|
||||
*pending = Some(PendingUpdate::Ready(Box::new(update)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -163,12 +184,14 @@ impl PendingUpdateState {
|
||||
}
|
||||
}
|
||||
|
||||
fn take_ready(&self) -> Result<ReadyUpdate, String> {
|
||||
fn take_ready(&self, cache_id: &str, expected_version: &str) -> Result<CachedUpdate, String> {
|
||||
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
|
||||
match pending.take() {
|
||||
Some(PendingUpdate::Ready(update)) => {
|
||||
Some(PendingUpdate::Ready(update))
|
||||
if update.record.info.cache_id == cache_id && update.record.info.version == expected_version =>
|
||||
{
|
||||
*pending = Some(PendingUpdate::Installing);
|
||||
Ok(update)
|
||||
Ok(*update)
|
||||
}
|
||||
other => {
|
||||
*pending = other;
|
||||
@@ -177,12 +200,24 @@ impl PendingUpdateState {
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_ready(&self, update: ReadyUpdate) -> Result<(), String> {
|
||||
fn restore_ready(&self, update: CachedUpdate) -> Result<(), String> {
|
||||
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
|
||||
*pending = Some(PendingUpdate::Ready(update));
|
||||
*pending = Some(PendingUpdate::Ready(Box::new(update)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn discard_ready(&self, cache_id: &str, root: &std::path::Path) -> Result<(), String> {
|
||||
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.")?;
|
||||
match pending.as_ref() {
|
||||
Some(PendingUpdate::Ready(cached)) if cached.record.info.cache_id == cache_id => {}
|
||||
_ => return Err("Cached update changed or is busy.".into()),
|
||||
}
|
||||
// Logical eviction must succeed even if disk cleanup fails. A later download
|
||||
// retries cleanup before any network work or cache commit.
|
||||
*pending = None;
|
||||
update_cache::discard(root)
|
||||
}
|
||||
|
||||
fn finish_install(&self) -> Result<(), String> {
|
||||
let mut pending = self.pending.lock().map_err(|_| "Update state is unavailable.".to_string())?;
|
||||
*pending = None;
|
||||
@@ -351,47 +386,175 @@ pub fn cancel_update_download(state: tauri::State<'_, PendingUpdateState>) {
|
||||
state.cancel_download();
|
||||
}
|
||||
|
||||
fn package_kind(app: &AppHandle) -> Result<String, String> {
|
||||
if crate::data_dir::is_portable_mode() {
|
||||
return Ok("portable".into());
|
||||
}
|
||||
Ok(app.updater_builder().build().map_err(|e| e.to_string())?.package_kind().into())
|
||||
}
|
||||
|
||||
fn validate_cache_target(record: &CacheRecord, portable_mode: bool, package_kind: &str) -> Result<(), String> {
|
||||
if record.package_kind != package_kind
|
||||
|| record.os != std::env::consts::OS
|
||||
|| record.arch != std::env::consts::ARCH
|
||||
|| record.info.portable_mode != portable_mode
|
||||
{
|
||||
return Err("Cached update does not match this installation.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_cached(app: &AppHandle) -> Result<Option<CachedUpdate>, String> {
|
||||
let root = update_cache::root(app)?;
|
||||
if requires_manual_update(IS_WINDOWS_7_TARGET) {
|
||||
update_cache::discard(&root)?;
|
||||
return Ok(None);
|
||||
}
|
||||
let restored = (|| {
|
||||
let Some((record, bytes)) = update_cache::read(&root)? else { return Ok(None) };
|
||||
validate_cache_target(&record, crate::data_dir::is_portable_mode(), &package_kind(app)?)?;
|
||||
let version =
|
||||
update_portable::validate_requested_portable_version(&record.info.version, env!("CARGO_PKG_VERSION"))?;
|
||||
let ready = if record.info.portable_mode {
|
||||
update_portable::verify_portable_archive(
|
||||
&bytes,
|
||||
record.signature.as_deref().ok_or("Missing cached signature")?,
|
||||
&version,
|
||||
std::env::consts::ARCH,
|
||||
)?;
|
||||
ReadyUpdate::Portable { archive: bytes, version }
|
||||
} else {
|
||||
let update = app
|
||||
.updater_builder()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?
|
||||
.restore_update(record.manifest.clone().ok_or("Missing cached release metadata")?)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or("Cached update is no longer newer")?;
|
||||
if update.version != record.info.version {
|
||||
return Err("Cached release version mismatch".into());
|
||||
}
|
||||
update.verify(&bytes).map_err(|e| e.to_string())?;
|
||||
ReadyUpdate::Installer { update: Box::new(update), bytes }
|
||||
};
|
||||
Ok(Some(CachedUpdate { record, ready }))
|
||||
})();
|
||||
if restored.is_err() {
|
||||
update_cache::discard(&root)?;
|
||||
}
|
||||
restored
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn get_downloaded_update(
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, PendingUpdateState>,
|
||||
) -> Result<Option<DownloadedUpdate>, String> {
|
||||
let mut pending = state.pending.lock().map_err(|_| "Update state is unavailable.")?;
|
||||
match pending.as_ref() {
|
||||
Some(PendingUpdate::Ready(cached)) => return Ok(Some(cached.record.info.clone())),
|
||||
Some(_) => return Ok(None),
|
||||
None => {}
|
||||
}
|
||||
update_cache::cleanup_partial(&update_cache::root(&app)?)?;
|
||||
match restore_cached(&app) {
|
||||
Ok(Some(cached)) => {
|
||||
let info = cached.record.info.clone();
|
||||
*pending = Some(PendingUpdate::Ready(Box::new(cached)));
|
||||
Ok(Some(info))
|
||||
}
|
||||
Ok(None) => Ok(None),
|
||||
Err(error) => {
|
||||
eprintln!("[DBX updater] discarded invalid cache: {error}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn discard_downloaded_update(
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, PendingUpdateState>,
|
||||
cache_id: String,
|
||||
) -> Result<(), String> {
|
||||
state.discard_ready(&cache_id, &update_cache::root(&app)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_update(
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, PendingUpdateState>,
|
||||
source: UpdateDownloadSource,
|
||||
latest_version: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
latest_version: String,
|
||||
attempt_id: String,
|
||||
release_notes: Option<String>,
|
||||
) -> Result<DownloadedUpdate, String> {
|
||||
let portable_mode = crate::data_dir::is_portable_mode();
|
||||
if requires_manual_update(IS_WINDOWS_7_TARGET) {
|
||||
return Err("Windows 7 builds must be updated with the dedicated Windows 7 offline installer.".to_string());
|
||||
}
|
||||
let portable_version = if portable_mode {
|
||||
let requested_version =
|
||||
latest_version.as_deref().ok_or_else(|| "Latest version is required for portable updates.".to_string())?;
|
||||
Some(update_portable::validate_requested_portable_version(requested_version, env!("CARGO_PKG_VERSION"))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cancellation = state.begin_download()?;
|
||||
let result = if let Some(version) = portable_version {
|
||||
download_portable_update_inner(&app, &source, &version, &cancellation)
|
||||
.await
|
||||
.map(|archive| ReadyUpdate::Portable { archive, version })
|
||||
} else {
|
||||
download_update_inner(&app, &source, latest_version.as_deref(), &cancellation)
|
||||
.await
|
||||
.map(|(update, bytes)| ReadyUpdate::Installer { update: Box::new(update), bytes })
|
||||
};
|
||||
match result {
|
||||
Ok(update) => state.finish_download(&cancellation, update),
|
||||
Err(error) => {
|
||||
state.finish_failed_download(&cancellation)?;
|
||||
Err(error)
|
||||
}
|
||||
let version = update_portable::validate_requested_portable_version(&latest_version, env!("CARGO_PKG_VERSION"))?;
|
||||
if attempt_id.is_empty() || attempt_id.len() > 128 {
|
||||
return Err("Invalid download attempt identifier.".into());
|
||||
}
|
||||
let cancellation = state.begin_download()?;
|
||||
let result = async {
|
||||
update_cache::discard(&update_cache::root(&app)?)?;
|
||||
let (ready, manifest, signature, notes) = if portable_mode {
|
||||
let (archive, signature) =
|
||||
download_portable_update_inner(&app, &source, &version, &attempt_id, &cancellation).await?;
|
||||
(ReadyUpdate::Portable { archive, version: version.clone() }, None, Some(signature), String::new())
|
||||
} else {
|
||||
let (update, bytes) =
|
||||
download_update_inner(&app, &source, Some(&latest_version), &attempt_id, &cancellation).await?;
|
||||
let manifest = Some(update.raw_json.clone());
|
||||
let notes = update.body.clone().unwrap_or_default();
|
||||
(ReadyUpdate::Installer { update: Box::new(update), bytes }, manifest, None, notes)
|
||||
};
|
||||
let info = DownloadedUpdate {
|
||||
cache_id: uuid::Uuid::new_v4().to_string(),
|
||||
version: version.to_string(),
|
||||
portable_mode,
|
||||
release_url: format!("https://github.com/t8y2/dbx/releases/tag/v{version}"),
|
||||
release_notes: release_notes.unwrap_or(notes),
|
||||
downloaded_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_millis() as u64,
|
||||
};
|
||||
let bytes = match &ready {
|
||||
ReadyUpdate::Installer { bytes, .. } => bytes,
|
||||
ReadyUpdate::Portable { archive, .. } => archive,
|
||||
};
|
||||
let cached = CachedUpdate {
|
||||
record: CacheRecord {
|
||||
schema_version: 1,
|
||||
package_kind: package_kind(&app)?,
|
||||
package_length: bytes.len() as u64,
|
||||
package_sha256: update_cache::digest(bytes),
|
||||
info: info.clone(),
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
manifest,
|
||||
signature,
|
||||
},
|
||||
ready,
|
||||
};
|
||||
state.finish_download(&cancellation, cached, &update_cache::root(&app)?)?;
|
||||
Ok(info)
|
||||
}
|
||||
.await;
|
||||
if result.is_err() {
|
||||
state.finish_failed_download(&cancellation)?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn download_update_inner(
|
||||
app: &AppHandle,
|
||||
source: &UpdateDownloadSource,
|
||||
latest_version: Option<&str>,
|
||||
attempt_id: &str,
|
||||
cancellation: &Arc<DownloadCancellation>,
|
||||
) -> Result<(Update, Vec<u8>), String> {
|
||||
let endpoint_urls = source.endpoints(latest_version)?;
|
||||
@@ -409,11 +572,21 @@ async fn download_update_inner(
|
||||
}
|
||||
|
||||
let updater = builder.build().map_err(|e| format!("Failed to create updater: {e}"))?;
|
||||
let update = updater.check().await.map_err(|e| format!("Failed to check updates: {e}"))?;
|
||||
let update = wait_for_download_step(
|
||||
updater.check(),
|
||||
cancellation,
|
||||
Duration::from_secs(30),
|
||||
"Update check timed out.".into(),
|
||||
)
|
||||
.await?
|
||||
.map_err(|e| format!("Failed to check updates: {e}"))?;
|
||||
let Some(update) = update else {
|
||||
return Err("No update available.".to_string());
|
||||
};
|
||||
|
||||
if Some(update.version.as_str()) != latest_version.map(|v| v.trim().trim_start_matches('v')) {
|
||||
return Err("Update version changed; check for updates again.".into());
|
||||
}
|
||||
let candidates = source.installer_asset_candidates(update.download_url.as_str(), latest_version);
|
||||
println!("[DBX updater] candidates for installer download: {:?}", candidates);
|
||||
|
||||
@@ -440,6 +613,10 @@ async fn download_update_inner(
|
||||
|
||||
let (progress_tx, progress_rx) = tokio::sync::mpsc::channel::<()>(16);
|
||||
|
||||
let attempt_id = attempt_id.to_string();
|
||||
let version = update.version.clone();
|
||||
let finish_attempt_id = attempt_id.clone();
|
||||
let finish_version = version.clone();
|
||||
let download_result = {
|
||||
let mut candidate_update = update.clone();
|
||||
candidate_update.download_url = parsed_url.clone();
|
||||
@@ -452,8 +629,15 @@ async fn download_update_inner(
|
||||
.fetch_add(chunk_len as u64, Ordering::Relaxed)
|
||||
.saturating_add(chunk_len as u64);
|
||||
if progress_gate.should_emit(downloaded, total) {
|
||||
let _ = progress_app_chunk
|
||||
.emit(UPDATE_DOWNLOAD_PROGRESS_EVENT, UpdateDownloadProgress { downloaded, total });
|
||||
let _ = progress_app_chunk.emit(
|
||||
UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
UpdateDownloadProgress {
|
||||
downloaded,
|
||||
total,
|
||||
attempt_id: attempt_id.clone(),
|
||||
version: version.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let _ = progress_tx.try_send(());
|
||||
},
|
||||
@@ -461,7 +645,12 @@ async fn download_update_inner(
|
||||
let downloaded = finished_downloaded.load(Ordering::Relaxed);
|
||||
let _ = progress_app_finish.emit(
|
||||
UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
UpdateDownloadProgress { downloaded, total: Some(downloaded) },
|
||||
UpdateDownloadProgress {
|
||||
downloaded,
|
||||
total: Some(downloaded),
|
||||
attempt_id: finish_attempt_id,
|
||||
version: finish_version,
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
@@ -495,8 +684,9 @@ async fn download_portable_update_inner(
|
||||
app: &AppHandle,
|
||||
source: &UpdateDownloadSource,
|
||||
latest_version: &Version,
|
||||
attempt_id: &str,
|
||||
cancellation: &Arc<DownloadCancellation>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
) -> Result<(Vec<u8>, String), String> {
|
||||
let latest_version_text = latest_version.to_string();
|
||||
let candidates = source.portable_asset_candidates(&latest_version_text, std::env::consts::ARCH)?;
|
||||
let client = portable_update_http_client()?;
|
||||
@@ -513,6 +703,8 @@ async fn download_portable_update_inner(
|
||||
&candidate.signature_url,
|
||||
MAX_PORTABLE_SIGNATURE_BYTES,
|
||||
None,
|
||||
attempt_id,
|
||||
&latest_version_text,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
@@ -523,11 +715,13 @@ async fn download_portable_update_inner(
|
||||
&candidate.archive_url,
|
||||
MAX_PORTABLE_ARCHIVE_BYTES,
|
||||
Some(app),
|
||||
attempt_id,
|
||||
&latest_version_text,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
update_portable::verify_portable_archive(&archive, &signature, latest_version, std::env::consts::ARCH)?;
|
||||
Ok::<Vec<u8>, String>(archive)
|
||||
Ok::<(Vec<u8>, String), String>((archive, signature))
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -563,6 +757,8 @@ async fn download_bounded_bytes(
|
||||
url: &str,
|
||||
max_bytes: usize,
|
||||
progress_app: Option<&AppHandle>,
|
||||
attempt_id: &str,
|
||||
version: &str,
|
||||
cancellation: &DownloadCancellation,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if cancellation.is_canceled() {
|
||||
@@ -589,7 +785,10 @@ async fn download_bounded_bytes(
|
||||
}
|
||||
|
||||
if let Some(app) = progress_app {
|
||||
let _ = app.emit(UPDATE_DOWNLOAD_PROGRESS_EVENT, UpdateDownloadProgress { downloaded: 0, total });
|
||||
let _ = app.emit(
|
||||
UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
UpdateDownloadProgress { downloaded: 0, total, attempt_id: attempt_id.into(), version: version.into() },
|
||||
);
|
||||
}
|
||||
let mut progress_gate = UpdateDownloadProgressGate::default();
|
||||
progress_gate.should_emit(0, total);
|
||||
@@ -623,7 +822,12 @@ async fn download_bounded_bytes(
|
||||
if let Some(app) = progress_app {
|
||||
let _ = app.emit(
|
||||
UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
UpdateDownloadProgress { downloaded: bytes.len() as u64, total },
|
||||
UpdateDownloadProgress {
|
||||
downloaded: bytes.len() as u64,
|
||||
total,
|
||||
attempt_id: attempt_id.into(),
|
||||
version: version.into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -672,11 +876,30 @@ async fn wait_for_download_step<T>(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_downloaded_update(app: AppHandle, state: tauri::State<'_, PendingUpdateState>) -> Result<(), String> {
|
||||
let ready = state.take_ready()?;
|
||||
let portable = matches!(&ready, ReadyUpdate::Portable { .. });
|
||||
let install_result = match &ready {
|
||||
#[tauri::command(async)]
|
||||
pub fn install_downloaded_update(
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, PendingUpdateState>,
|
||||
cache_id: String,
|
||||
expected_version: String,
|
||||
) -> Result<(), String> {
|
||||
let cached = state.take_ready(&cache_id, &expected_version)?;
|
||||
// Read and reverify disk bytes immediately before installation; never trust memory alone.
|
||||
let refreshed = match restore_cached(&app) {
|
||||
Ok(Some(ready)) if ready.record.info.cache_id == cache_id && ready.record.info.version == expected_version => {
|
||||
ready
|
||||
}
|
||||
other => {
|
||||
state.finish_install()?;
|
||||
return Err(match other {
|
||||
Err(e) => e,
|
||||
_ => "Cached update is missing or changed.".into(),
|
||||
});
|
||||
}
|
||||
};
|
||||
let ready = &refreshed.ready;
|
||||
let portable = matches!(ready, ReadyUpdate::Portable { .. });
|
||||
let install_result = match ready {
|
||||
ReadyUpdate::Installer { update, bytes } => {
|
||||
update.install(bytes).map_err(|error| format!("Failed to install update: {error}"))
|
||||
}
|
||||
@@ -686,12 +909,16 @@ pub fn install_downloaded_update(app: AppHandle, state: tauri::State<'_, Pending
|
||||
}
|
||||
};
|
||||
if let Err(error) = install_result {
|
||||
state.restore_ready(ready)?;
|
||||
state.restore_ready(cached)?;
|
||||
return Err(error);
|
||||
}
|
||||
state.finish_install()?;
|
||||
if portable {
|
||||
// The helper can still fail or roll back after this process exits. Keep
|
||||
// the verified package until the upgraded application validates its version.
|
||||
schedule_portable_update_exit(app);
|
||||
} else {
|
||||
state.finish_install()?;
|
||||
let _ = update_cache::discard(&update_cache::root(&app)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -716,6 +943,80 @@ mod tests {
|
||||
};
|
||||
use std::{future::pending, sync::Arc, time::Duration};
|
||||
|
||||
fn cached_fixture() -> super::CachedUpdate {
|
||||
super::CachedUpdate {
|
||||
record: super::CacheRecord {
|
||||
schema_version: 1,
|
||||
package_kind: "portable".into(),
|
||||
package_length: 0,
|
||||
package_sha256: super::update_cache::digest(&[]),
|
||||
info: super::DownloadedUpdate {
|
||||
cache_id: "cached-id".into(),
|
||||
version: "99.0.0".into(),
|
||||
portable_mode: true,
|
||||
release_url: String::new(),
|
||||
release_notes: String::new(),
|
||||
downloaded_at: 0,
|
||||
},
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
manifest: None,
|
||||
signature: None,
|
||||
},
|
||||
ready: super::ReadyUpdate::Portable { archive: vec![], version: semver::Version::new(99, 0, 0) },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_cache_identity_and_double_install_are_rejected() {
|
||||
let state = PendingUpdateState::default();
|
||||
state.restore_ready(cached_fixture()).unwrap();
|
||||
assert!(state.take_ready("wrong-id", "99.0.0").is_err());
|
||||
assert!(state.take_ready("cached-id", "98.0.0").is_err());
|
||||
let ready = state.take_ready("cached-id", "99.0.0").unwrap();
|
||||
assert!(state.take_ready("cached-id", "99.0.0").is_err());
|
||||
state.restore_ready(ready).unwrap();
|
||||
assert!(state.take_ready("cached-id", "99.0.0").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_attempt_cannot_commit_or_publish_cache() {
|
||||
let state = PendingUpdateState::default();
|
||||
let attempt = state.begin_download().unwrap();
|
||||
state.cancel_download();
|
||||
let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
|
||||
assert!(state.finish_download(&attempt, cached_fixture(), &root).is_err());
|
||||
assert!(!root.exists());
|
||||
assert!(state.begin_download().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_failure_does_not_leave_ready_state_or_block_new_attempts() {
|
||||
let state = PendingUpdateState::default();
|
||||
state.restore_ready(cached_fixture()).unwrap();
|
||||
let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
|
||||
// A file in place of the cache root makes deletion fail on every platform.
|
||||
std::fs::write(&root, b"not a directory").unwrap();
|
||||
assert!(state.discard_ready("cached-id", &root).is_err());
|
||||
assert!(state.take_ready("cached-id", "99.0.0").is_err());
|
||||
assert!(state.begin_download().is_ok());
|
||||
std::fs::remove_file(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_target_requires_architecture_os_and_installation_type() {
|
||||
let record = cached_fixture().record;
|
||||
assert!(super::validate_cache_target(&record, true, "portable").is_ok());
|
||||
assert!(super::validate_cache_target(&record, false, "portable").is_err());
|
||||
assert!(super::validate_cache_target(&record, true, "nsis").is_err());
|
||||
let mut wrong_arch = record.clone();
|
||||
wrong_arch.arch = "different-architecture".into();
|
||||
assert!(super::validate_cache_target(&wrong_arch, true, "portable").is_err());
|
||||
let mut wrong_os = record;
|
||||
wrong_os.os = "different-os".into();
|
||||
assert!(super::validate_cache_target(&wrong_os, true, "portable").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_windows_7_builds_require_manual_updates() {
|
||||
assert!(requires_manual_update(true));
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! A single committed update. The directory rename is the commit point; partial writes are never read.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DownloadedUpdate {
|
||||
pub cache_id: String,
|
||||
pub version: String,
|
||||
pub portable_mode: bool,
|
||||
pub release_url: String,
|
||||
pub release_notes: String,
|
||||
pub downloaded_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(super) struct CacheRecord {
|
||||
pub schema_version: u32,
|
||||
pub package_kind: String,
|
||||
pub package_length: u64,
|
||||
pub package_sha256: String,
|
||||
pub info: DownloadedUpdate,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let mut file = fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(|e| e.to_string())?;
|
||||
file.write_all(bytes).and_then(|_| file.sync_all()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn commit(root: &Path, record: &CacheRecord, bytes: &[u8]) -> Result<(), String> {
|
||||
let metadata = serde_json::to_vec(record).map_err(|e| e.to_string())?;
|
||||
if metadata.len() > 4 * 1024 * 1024 || bytes.len() > 512 * 1024 * 1024 {
|
||||
return Err("Cached update exceeds size limits.".into());
|
||||
}
|
||||
fs::create_dir_all(root).map_err(|e| e.to_string())?;
|
||||
let staging = root.join(format!("partial-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir(&staging).map_err(|e| e.to_string())?;
|
||||
let result = (|| {
|
||||
write_synced(&staging.join("package"), bytes)?;
|
||||
write_synced(&staging.join("metadata.json"), &metadata)?;
|
||||
#[cfg(unix)]
|
||||
fs::File::open(&staging).and_then(|dir| dir.sync_all()).map_err(|e| e.to_string())?;
|
||||
fs::rename(&staging, root.join("ready")).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
fs::File::open(root).and_then(|dir| dir.sync_all()).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(staging);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn digest(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn read_bounded(path: &Path, limit: u64) -> Result<Vec<u8>, String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|e| e.to_string())?;
|
||||
if !metadata.file_type().is_file() || metadata.len() > limit {
|
||||
return Err("Invalid cached update file.".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
fs::File::open(path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.take(limit + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > limit {
|
||||
return Err("Cached update file exceeds limit.".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(super) fn read(root: &Path) -> Result<Option<(CacheRecord, Vec<u8>)>, String> {
|
||||
let ready = root.join("ready");
|
||||
let metadata = match fs::symlink_metadata(&ready) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
if !metadata.file_type().is_dir() {
|
||||
return Err("Invalid cached update directory.".into());
|
||||
}
|
||||
let record: CacheRecord = serde_json::from_slice(&read_bounded(&ready.join("metadata.json"), 4 * 1024 * 1024)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if record.schema_version != 1 {
|
||||
return Err("Unsupported cached update schema.".into());
|
||||
}
|
||||
let bytes = read_bounded(&ready.join("package"), 512 * 1024 * 1024)?;
|
||||
if record.package_length != bytes.len() as u64 || record.package_sha256 != digest(&bytes) {
|
||||
return Err("Cached update package is corrupt.".into());
|
||||
}
|
||||
Ok(Some((record, bytes)))
|
||||
}
|
||||
|
||||
/// Only called while the updater state is idle and exclusively locked.
|
||||
pub(super) fn cleanup_partial(root: &Path) -> Result<(), String> {
|
||||
let entries = match fs::read_dir(root) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
if entry.file_name().to_string_lossy().starts_with("partial-") {
|
||||
if entry.file_type().map_err(|e| e.to_string())?.is_dir() {
|
||||
fs::remove_dir_all(entry.path()).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
fs::remove_file(entry.path()).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn discard(root: &Path) -> Result<(), String> {
|
||||
let ready = root.join("ready");
|
||||
let result = if fs::symlink_metadata(&ready).is_ok_and(|m| !m.file_type().is_dir()) {
|
||||
fs::remove_file(ready)
|
||||
} else {
|
||||
fs::remove_dir_all(ready)
|
||||
};
|
||||
match result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
use tauri::Manager;
|
||||
app.path().app_cache_dir().map(|p| p.join("signed-update-v1")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlink_without_deleting_its_target() {
|
||||
let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
|
||||
let outside = root.join("outside");
|
||||
fs::create_dir_all(&outside).unwrap();
|
||||
fs::write(outside.join("keep"), b"unchanged").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, root.join("ready")).unwrap();
|
||||
assert!(read(&root).is_err());
|
||||
discard(&root).unwrap();
|
||||
assert!(outside.join("keep").exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_writes_are_invisible_and_commit_roundtrips() {
|
||||
let root = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
|
||||
fs::create_dir_all(root.join("partial-abandoned")).unwrap();
|
||||
assert!(read(&root).unwrap().is_none());
|
||||
let record = CacheRecord {
|
||||
schema_version: 1,
|
||||
package_kind: "test".into(),
|
||||
package_length: 14,
|
||||
package_sha256: digest(b"signed payload"),
|
||||
info: DownloadedUpdate {
|
||||
cache_id: "test".into(),
|
||||
version: "99.0.0".into(),
|
||||
portable_mode: false,
|
||||
release_url: String::new(),
|
||||
release_notes: String::new(),
|
||||
downloaded_at: 1,
|
||||
},
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
manifest: None,
|
||||
signature: None,
|
||||
};
|
||||
commit(&root, &record, b"signed payload").unwrap();
|
||||
assert_eq!(read(&root).unwrap().unwrap().1, b"signed payload");
|
||||
assert!(commit(&root, &record, b"replacement").is_err());
|
||||
fs::write(root.join("ready/package"), b"changed payload").unwrap();
|
||||
assert!(read(&root).unwrap_err().contains("corrupt"));
|
||||
fs::write(root.join("ready/package"), b"signed payload").unwrap();
|
||||
let mut wrong_schema = record.clone();
|
||||
wrong_schema.schema_version = 99;
|
||||
fs::write(root.join("ready/metadata.json"), serde_json::to_vec(&wrong_schema).unwrap()).unwrap();
|
||||
assert!(read(&root).unwrap_err().contains("schema"));
|
||||
fs::write(root.join("ready/metadata.json"), b"truncated").unwrap();
|
||||
assert!(read(&root).is_err());
|
||||
discard(&root).unwrap();
|
||||
assert!(read(&root).unwrap().is_none());
|
||||
cleanup_partial(&root).unwrap();
|
||||
assert!(!root.join("partial-abandoned").exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2466,6 +2466,8 @@ pub fn run() {
|
||||
commands::update::check_for_updates,
|
||||
commands::update::fetch_changelog,
|
||||
commands::update::get_system_proxy_url,
|
||||
commands::update::get_downloaded_update,
|
||||
commands::update::discard_downloaded_update,
|
||||
commands::update::download_update,
|
||||
commands::update::cancel_update_download,
|
||||
commands::update::install_downloaded_update,
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
# Changelog
|
||||
|
||||
## \[2.10.1]
|
||||
|
||||
- [`31ab6f8d`](https://github.com/tauri-apps/plugins-workspace/commit/31ab6f8d2466d86c80b1d70510c0400ce2cdcb0a) ([#3285](https://github.com/tauri-apps/plugins-workspace/pull/3285) by [@hrzlgnm](https://github.com/tauri-apps/plugins-workspace/../../hrzlgnm)) fix: preserve file extension of updater package, otherwise users may get confused when presented with a sudo dialog suggesting to install a file with the extension `.rpm` using `dpkg -i`
|
||||
|
||||
## \[2.10.0]
|
||||
|
||||
- [`4375c98b`](https://github.com/tauri-apps/plugins-workspace/commit/4375c98bed1769a1fc1be26eded4db9f7c095d95) ([#3073](https://github.com/tauri-apps/plugins-workspace/pull/3073)) Add no_proxy config to disable system proxy for updater plugin.
|
||||
- [`f122ee98`](https://github.com/tauri-apps/plugins-workspace/commit/f122ee98c6954075891ac3cd530f5e29da117b39) Allow configuring the updater client to accept invalid TLS certificates and hostnames for internal/self-signed update servers. These options are available via the plugin config (`dangerousAcceptInvalidCerts`, `dangerousAcceptInvalidHostnames`) and via the `UpdaterBuilder` (`dangerous_accept_invalid_certs`, `dangerous_accept_invalid_hostnames`).
|
||||
- [`f122ee98`](https://github.com/tauri-apps/plugins-workspace/commit/f122ee98c6954075891ac3cd530f5e29da117b39) Updater plugin now supports all bundle types: Deb, Rpm and AppImage for Linux; NSiS, MSI for Windows. This was added in https://github.com/tauri-apps/plugins-workspace/pull/2624
|
||||
- [`05c5da07`](https://github.com/tauri-apps/plugins-workspace/commit/05c5da072b6e3254c19ee69024f80f4dfe1b779b) ([#3213](https://github.com/tauri-apps/plugins-workspace/pull/3213)) Updated `reqwest` to 0.13, make sure to update your `reqwest` dependency if you're using `UpdaterBuilder::configure_client`
|
||||
|
||||
## \[2.9.0]
|
||||
|
||||
- [`f209b2f2`](https://github.com/tauri-apps/plugins-workspace/commit/f209b2f23cb29133c97ad5961fb46ef794dbe063) ([#2804](https://github.com/tauri-apps/plugins-workspace/pull/2804) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Updated tauri to 2.6
|
||||
|
||||
## \[2.8.1]
|
||||
|
||||
- [`735d209d`](https://github.com/tauri-apps/plugins-workspace/commit/735d209d5d1d92dcac70c6083bcfcd34ec7d84be) ([#2761](https://github.com/tauri-apps/plugins-workspace/pull/2761) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue preventing updates via the NSIS installer from succeeding when the app was launched with command line arguments containing spaces.
|
||||
|
||||
## \[2.8.0]
|
||||
|
||||
- [`87afa23c`](https://github.com/tauri-apps/plugins-workspace/commit/87afa23cad077c09bc1eb743800ae3396b531146) ([#2726](https://github.com/tauri-apps/plugins-workspace/pull/2726)) Add allowDowngrades parameter to check command
|
||||
|
||||
Added a new optional `allowDowngrades` parameter to the JavaScript check command that allows the updater to consider versions that are lower than the current version as valid updates. When enabled, the version comparator will accept any version that is different from the current version, effectively allowing downgrades.
|
||||
- [`73ff15de`](https://github.com/tauri-apps/plugins-workspace/commit/73ff15de5d07d476693e40e8e5d138c16da5211e) ([#2757](https://github.com/tauri-apps/plugins-workspace/pull/2757)) Fix headers option in `Update.download` and `Update.downloadAndInstall` doesn't work with `Record<string, string> | Headers` types
|
||||
|
||||
## \[2.7.1]
|
||||
|
||||
- [`c5b0f51c`](https://github.com/tauri-apps/plugins-workspace/commit/c5b0f51cfd911cca9317b59efc718b570980129b) ([#2621](https://github.com/tauri-apps/plugins-workspace/pull/2621) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Fix `check` and `download` overrides the `accept` header
|
||||
|
||||
## \[2.7.0]
|
||||
|
||||
### bug
|
||||
|
||||
- [`2d731f80`](https://github.com/tauri-apps/plugins-workspace/commit/2d731f80224f74faf1b7170b25e04f5da1da49c8) ([#2573](https://github.com/tauri-apps/plugins-workspace/pull/2573)) Fix JS API `Update.date` not formatted to RFC 3339
|
||||
- [`0bc5d588`](https://github.com/tauri-apps/plugins-workspace/commit/0bc5d5887420ba1eb718254490b7995c771c0447) ([#2572](https://github.com/tauri-apps/plugins-workspace/pull/2572)) Fix `timeout` passed to `check` gets re-used by `download` and `downloadAndinstall`
|
||||
|
||||
## \[2.6.1]
|
||||
|
||||
- [`12c4537b`](https://github.com/tauri-apps/plugins-workspace/commit/12c4537b8e4fed29b415ff817434b664c0596dac) ([#2541](https://github.com/tauri-apps/plugins-workspace/pull/2541) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Add support to the `riscv64` architecture.
|
||||
|
||||
## \[2.6.0]
|
||||
|
||||
- [`faefcc9f`](https://github.com/tauri-apps/plugins-workspace/commit/faefcc9fd8c61f709d491649e255a7fcac82c09a) ([#2430](https://github.com/tauri-apps/plugins-workspace/pull/2430) by [@goenning](https://github.com/tauri-apps/plugins-workspace/../../goenning)) Add `UpdaterBuilder::configure_client` method on Rust side, to configure the `reqwest` client used to check and download the update.
|
||||
- [`ac60d589`](https://github.com/tauri-apps/plugins-workspace/commit/ac60d589eca2bbc4aed040feb18da148e66ec171) ([#2513](https://github.com/tauri-apps/plugins-workspace/pull/2513) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Enhance error logging.
|
||||
|
||||
## \[2.5.1]
|
||||
|
||||
- [`6f881293`](https://github.com/tauri-apps/plugins-workspace/commit/6f881293fcd67838f6f3f8063f536292431dd1f7) ([#2439](https://github.com/tauri-apps/plugins-workspace/pull/2439) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Fixed an issue that caused the plugin to emit a `ReleaseNotFound` error instead of a `Reqwest` error when the http request in `check()` failed.
|
||||
|
||||
## \[2.5.0]
|
||||
|
||||
- [`5369898d`](https://github.com/tauri-apps/plugins-workspace/commit/5369898db7a6098e3e2f43436100ea556d405628) ([#2067](https://github.com/tauri-apps/plugins-workspace/pull/2067) by [@jLynx](https://github.com/tauri-apps/plugins-workspace/../../jLynx)) Fix update installation on macOS when using an user without admin privileges.
|
||||
- [`5369898d`](https://github.com/tauri-apps/plugins-workspace/commit/5369898db7a6098e3e2f43436100ea556d405628) ([#2067](https://github.com/tauri-apps/plugins-workspace/pull/2067) by [@jLynx](https://github.com/tauri-apps/plugins-workspace/../../jLynx)) Remove the `UpdaterBuilder::new` function, use `UpdaterExt::updater_builder` instead.
|
||||
|
||||
## \[2.4.0]
|
||||
|
||||
- [`0afc9b6b`](https://github.com/tauri-apps/plugins-workspace/commit/0afc9b6be07bee1077f05a86285d977e57810ed9) ([#2325](https://github.com/tauri-apps/plugins-workspace/pull/2325) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The `Update` struct/object will now contain a `raw_json`/`rawJson` property to be able to read parts of server's json response that are not handled by the plugin.
|
||||
|
||||
## \[2.3.1]
|
||||
|
||||
- [`57efb47c`](https://github.com/tauri-apps/plugins-workspace/commit/57efb47c116f880477f72f02a8e4239e88007d44) ([#2235](https://github.com/tauri-apps/plugins-workspace/pull/2235) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add `Builder::header` and `Builder::headers` method to configure default headers for updater.
|
||||
|
||||
## \[2.3.0]
|
||||
|
||||
- [`829b6326`](https://github.com/tauri-apps/plugins-workspace/commit/829b63265030bc9c61d1738c4eaca0ffb3178677) ([#1919](https://github.com/tauri-apps/plugins-workspace/pull/1919) by [@n1ght-hunter](https://github.com/tauri-apps/plugins-workspace/../../n1ght-hunter)) Add `tauri_plugin_updater::Builder::default_version_comparator` method to set the default version comparator for the updater.
|
||||
|
||||
## \[2.2.0]
|
||||
|
||||
- [`3a79266b`](https://github.com/tauri-apps/plugins-workspace/commit/3a79266b8cf96a55b1ae6339d725567d45a44b1d) ([#2173](https://github.com/tauri-apps/plugins-workspace/pull/2173) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) Bumped all plugins to `v2.2.0`. From now, the versions for the Rust and JavaScript packages of each plugin will be in sync with each other.
|
||||
|
||||
## \[2.1.0]
|
||||
|
||||
- [`f8f2eefe`](https://github.com/tauri-apps/plugins-workspace/commit/f8f2eefe03ab231beafbd6a88d61b53d77f0400d) ([#1991](https://github.com/tauri-apps/plugins-workspace/pull/1991) by [@jLynx](https://github.com/tauri-apps/plugins-workspace/../../jLynx)) Added support for `.deb` package updates on Linux systems.
|
||||
|
||||
## \[2.0.2]
|
||||
|
||||
- [`a1a82208`](https://github.com/tauri-apps/plugins-workspace/commit/a1a82208ed4ab87f83310be0dc95428aec9ab241) ([#1873](https://github.com/tauri-apps/plugins-workspace/pull/1873) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Downgrade MSRV to 1.77.2 to support Windows 7.
|
||||
|
||||
## \[2.0.1]
|
||||
|
||||
- [`9501cfa5`](https://github.com/tauri-apps/plugins-workspace/commit/9501cfa5f5385b2d7eb43a8378b322ee97cba06f) ([#1868](https://github.com/tauri-apps/plugins-workspace/pull/1868) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fix configuration parser incorrectly warning about the endpoint scheme.
|
||||
|
||||
## \[2.0.0]
|
||||
|
||||
- [`e2c4dfb6`](https://github.com/tauri-apps/plugins-workspace/commit/e2c4dfb6af43e5dd8d9ceba232c315f5febd55c1) Update to tauri v2 stable release.
|
||||
|
||||
## \[2.0.0-rc.4]
|
||||
|
||||
- [`221f50f5`](https://github.com/tauri-apps/plugins-workspace/commit/221f50f53bd7a87dbd404e4cb1aaf502a5047785) ([#1816](https://github.com/tauri-apps/plugins-workspace/pull/1816) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Encode `+` when making updater requests which can be cause incorrectly interpolating the endpoint when using `{{current_version}}` in the endpoint where the current version contains a build number, for example `1.8.0+1`.
|
||||
- [`04a0aea0`](https://github.com/tauri-apps/plugins-workspace/commit/04a0aea0ab9f8750200bc2fe5aff99c1c488082d) ([#1814](https://github.com/tauri-apps/plugins-workspace/pull/1814) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) **Breaking change**, Changed `UpdaterBuilder::endpoints` method to return a `Result`.
|
||||
- [`04a0aea0`](https://github.com/tauri-apps/plugins-workspace/commit/04a0aea0ab9f8750200bc2fe5aff99c1c488082d) ([#1814](https://github.com/tauri-apps/plugins-workspace/pull/1814) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add `dangerousInsecureTransportProtocol` config option to allow using insecure transport protocols, like `http`
|
||||
|
||||
## \[2.0.0-rc.3]
|
||||
|
||||
- [`d00519e3`](https://github.com/tauri-apps/plugins-workspace/commit/d00519e3e3a3234f9eb6c2ba82c92d4199f03e53) ([#1735](https://github.com/tauri-apps/plugins-workspace/pull/1735) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) This releases the changes from 2.0.0-rc.2 to crates.io. Please see the links below for the actual changes.
|
||||
|
||||
## \[2.0.0-rc.2]
|
||||
|
||||
- [`f8255e1d`](https://github.com/tauri-apps/plugins-workspace/commit/f8255e1db5df6cf562b9334fbefe5e62f4a28e0a) ([#1661](https://github.com/tauri-apps/plugins-workspace/pull/1661) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Add a second argument in `Update.download` and `Update.donloadAndInstall` JS APIs to modify headers and timeout when downloading the update.
|
||||
|
||||
## \[2.0.0-rc.1]
|
||||
|
||||
- [`e2e97db5`](https://github.com/tauri-apps/plugins-workspace/commit/e2e97db51983267f5be84d4f6f0278d58834d1f5) ([#1701](https://github.com/tauri-apps/plugins-workspace/pull/1701) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.8
|
||||
|
||||
## \[2.0.0-rc.1]
|
||||
|
||||
- [`77013925`](https://github.com/tauri-apps/plugins-workspace/commit/7701392500f375340045880fce5fb8f867bfe670) ([#1636](https://github.com/tauri-apps/plugins-workspace/pull/1636) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Fixes the updater not preserving AppImage file permissions.
|
||||
- [`5d170a54`](https://github.com/tauri-apps/plugins-workspace/commit/5d170a5444982dcc14135f6f1fc3e5da359f0eb0) ([#1671](https://github.com/tauri-apps/plugins-workspace/pull/1671) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri 2.0.0-rc.3.
|
||||
|
||||
## \[2.0.0-rc.0]
|
||||
|
||||
- [`9887d1`](https://github.com/tauri-apps/plugins-workspace/commit/9887d14bd0e971c4c0f5c1188fc4005d3fc2e29e) Update to tauri RC.
|
||||
|
||||
## \[2.0.0-beta.8]
|
||||
|
||||
- [`99d6ac0f`](https://github.com/tauri-apps/plugins-workspace/commit/99d6ac0f9506a6a4a1aa59c728157190a7441af6) ([#1606](https://github.com/tauri-apps/plugins-workspace/pull/1606) by [@FabianLars](https://github.com/tauri-apps/plugins-workspace/../../FabianLars)) The JS packages now specify the *minimum* `@tauri-apps/api` version instead of a single exact version.
|
||||
- [`6de87966`](https://github.com/tauri-apps/plugins-workspace/commit/6de87966ecc00ad9d91c25be452f1f46bd2b7e1f) ([#1597](https://github.com/tauri-apps/plugins-workspace/pull/1597) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) Update to tauri beta.25.
|
||||
|
||||
## \[2.0.0-beta.11]
|
||||
|
||||
- [`f83b9e98`](https://github.com/tauri-apps/plugins-workspace/commit/f83b9e9813843df19b03b6af1018d848111b2a62) ([#1544](https://github.com/tauri-apps/plugins-workspace/pull/1544) by [@Legend-Master](https://github.com/tauri-apps/plugins-workspace/../../Legend-Master)) On Windows, use a named tempfile with `<app name>-<version>-installer.exe` (or `.msi`) for v2 updater
|
||||
|
||||
**Breaking Change**: `UpdaterBuilder::new` now takes one more argument `app_name: String`
|
||||
|
||||
## \[2.0.0-beta.7]
|
||||
|
||||
- [`22a17980`](https://github.com/tauri-apps/plugins-workspace/commit/22a17980ff4f6f8c40adb1b8f4ffc6dae2fe7e30) ([#1537](https://github.com/tauri-apps/plugins-workspace/pull/1537) by [@lucasfernog](https://github.com/tauri-apps/plugins-workspace/../../lucasfernog)) Update to tauri beta.24.
|
||||
|
||||
## \[2.0.0-beta.6]
|
||||
|
||||
- [`76daee7a`](https://github.com/tauri-apps/plugins-workspace/commit/76daee7aafece34de3092c86e531cf9eb1138989) ([#1512](https://github.com/tauri-apps/plugins-workspace/pull/1512) by [@renovate](https://github.com/tauri-apps/plugins-workspace/../../renovate)) Update to tauri beta.23.
|
||||
|
||||
## \[2.0.0-beta.8]
|
||||
|
||||
- [`bf29a72b`](https://github.com/tauri-apps/plugins-workspace/commit/bf29a72baaff15214a21989df23081eee84e3b8b) ([#1454](https://github.com/tauri-apps/plugins-workspace/pull/1454) by [@amrbashir](https://github.com/tauri-apps/plugins-workspace/../../amrbashir)) Fix regression in updater plugin failing to update using `.msi` installer.
|
||||
|
||||
## \[2.0.0-beta.5]
|
||||
|
||||
- [`9013854f`](https://github.com/tauri-apps/plugins-workspace/commit/9013854f42a49a230b9dbb9d02774765528a923f)([#1382](https://github.com/tauri-apps/plugins-workspace/pull/1382)) Update to tauri beta.22.
|
||||
|
||||
## \[2.0.0-beta.4]
|
||||
|
||||
- [`430bd6f4`](https://github.com/tauri-apps/plugins-workspace/commit/430bd6f4f379bee5d232ae6b098ae131db7f178a)([#1363](https://github.com/tauri-apps/plugins-workspace/pull/1363)) Update to tauri beta.20.
|
||||
- [`43224c5d`](https://github.com/tauri-apps/plugins-workspace/commit/43224c5d5cfe2dd676e79ebafe424027c62c51c3)([#1330](https://github.com/tauri-apps/plugins-workspace/pull/1330)) Add `Update.download` and `Update.install` functions to the JavaScript API
|
||||
|
||||
## \[2.0.0-beta.3]
|
||||
|
||||
- [`bd1ed590`](https://github.com/tauri-apps/plugins-workspace/commit/bd1ed5903ffcce5500310dac1e59e8c67674ef1e)([#1237](https://github.com/tauri-apps/plugins-workspace/pull/1237)) Update to tauri beta.17.
|
||||
|
||||
## \[2.0.0-beta.4]
|
||||
|
||||
- [`293f363`](https://github.com/tauri-apps/plugins-workspace/commit/293f363c0dccc43e8403729fdc8cc2b4311c2d5b)([#1175](https://github.com/tauri-apps/plugins-workspace/pull/1175)) Add a `on_before_exit` hook for cleanup before spawning the updater on Windows, defaults to `app.cleanup_before_exit` when used through `UpdaterExt`
|
||||
- [`293f363`](https://github.com/tauri-apps/plugins-workspace/commit/293f363c0dccc43e8403729fdc8cc2b4311c2d5b)([#1175](https://github.com/tauri-apps/plugins-workspace/pull/1175)) **Breaking change:** The `rustls-tls` feature flag is now enabled by default.
|
||||
- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Internally use the webview scoped resources table instead of the app one, so other webviews can't access other webviews resources.
|
||||
- [`7e2fcc5`](https://github.com/tauri-apps/plugins-workspace/commit/7e2fcc5e74df7c3c718e40f75bfb0eafc7d69d8d)([#1146](https://github.com/tauri-apps/plugins-workspace/pull/1146)) Update dependencies to align with tauri 2.0.0-beta.14.
|
||||
- [`e3d41f4`](https://github.com/tauri-apps/plugins-workspace/commit/e3d41f4011bd3ea3ce281bb38bbe31d3709f8e0f)([#1191](https://github.com/tauri-apps/plugins-workspace/pull/1191)) Update for tauri 2.0.0-beta.15.
|
||||
|
||||
## \[2.0.0-beta.3]
|
||||
|
||||
- [`4e37316`](https://github.com/tauri-apps/plugins-workspace/commit/4e37316af0d6532bf9a9bd0e712b5b14b0598285)([#1051](https://github.com/tauri-apps/plugins-workspace/pull/1051)) Fix deserialization of `windows > installerArgs` config field.
|
||||
- [`4e37316`](https://github.com/tauri-apps/plugins-workspace/commit/4e37316af0d6532bf9a9bd0e712b5b14b0598285)([#1051](https://github.com/tauri-apps/plugins-workspace/pull/1051)) On Windows, fallback to `passive` install mode when not defined in config.
|
||||
- [`a3b5396`](https://github.com/tauri-apps/plugins-workspace/commit/a3b5396113ca93912274f6890d9ef5b1a409587a)([#1054](https://github.com/tauri-apps/plugins-workspace/pull/1054)) Fix Windows powershell window flashing on update
|
||||
- [`a04ea2f`](https://github.com/tauri-apps/plugins-workspace/commit/a04ea2f38294d5a3987578283badc8eec87a7752)([#1071](https://github.com/tauri-apps/plugins-workspace/pull/1071)) The global API script is now only added to the binary when the `withGlobalTauri` config is true.
|
||||
|
||||
## \[2.0.0-beta.2]
|
||||
|
||||
- [`99bea25`](https://github.com/tauri-apps/plugins-workspace/commit/99bea2559c2c0648c2519c50a18cd124dacef57b)([#1005](https://github.com/tauri-apps/plugins-workspace/pull/1005)) Update to tauri beta.8.
|
||||
|
||||
## \[2.0.0-beta.1]
|
||||
|
||||
- [`569defb`](https://github.com/tauri-apps/plugins-workspace/commit/569defbe9492e38938554bb7bdc1be9151456d21) Update to tauri beta.4.
|
||||
|
||||
## \[2.0.0-beta.0]
|
||||
|
||||
- [`d198c01`](https://github.com/tauri-apps/plugins-workspace/commit/d198c014863ee260cb0de88a14b7fc4356ef7474)([#862](https://github.com/tauri-apps/plugins-workspace/pull/862)) Update to tauri beta.
|
||||
- [`0879a87`](https://github.com/tauri-apps/plugins-workspace/commit/0879a87a7ecc83c9e886e6f1412fe253082b8d34)([#899](https://github.com/tauri-apps/plugins-workspace/pull/899)) Fix `Started` event not emitted to JS when downloading update.
|
||||
- [`8505a75`](https://github.com/tauri-apps/plugins-workspace/commit/8505a756b569d88757ec58e452bfe4814d8107bf)([#907](https://github.com/tauri-apps/plugins-workspace/pull/907)) Add support for specifying proxy to use for checking and downloading updates.
|
||||
|
||||
## \[2.0.0-alpha.5]
|
||||
|
||||
- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.13.
|
||||
- [`e5f979f`](https://github.com/tauri-apps/plugins-workspace/commit/e5f979f91abbb1775fa048af3219b30ff30ed691)([#818](https://github.com/tauri-apps/plugins-workspace/pull/818)) Fix NSIS updater failing to launch when using `basicUi` mode.
|
||||
|
||||
## \[2.0.0-alpha.4]
|
||||
|
||||
- [`387c2f9`](https://github.com/tauri-apps/plugins-workspace/commit/387c2f9e0ce4c75c07ffa3fd76391a25b58f5daf)([#802](https://github.com/tauri-apps/plugins-workspace/pull/802)) Update to @tauri-apps/api v2.0.0-alpha.12.
|
||||
|
||||
## \[2.0.0-alpha.3]
|
||||
|
||||
- [`e438e0a`](https://github.com/tauri-apps/plugins-workspace/commit/e438e0a62d4b430a5159f05f13ecd397dd891a0d)([#676](https://github.com/tauri-apps/plugins-workspace/pull/676)) Update to @tauri-apps/api v2.0.0-alpha.11.
|
||||
|
||||
## \[2.0.0-alpha.2]
|
||||
|
||||
- [`5c13736`](https://github.com/tauri-apps/plugins-workspace/commit/5c137365c60790e8d4037d449e8237aa3fffdab0)([#673](https://github.com/tauri-apps/plugins-workspace/pull/673)) Update to @tauri-apps/api v2.0.0-alpha.9.
|
||||
|
||||
## \[2.0.0-alpha.2]
|
||||
|
||||
- [`4e2cef9`](https://github.com/tauri-apps/plugins-workspace/commit/4e2cef9b702bbbb9cf4ee17de50791cb21f1b2a4)([#593](https://github.com/tauri-apps/plugins-workspace/pull/593)) Update to alpha.12.
|
||||
|
||||
## \[2.0.0-alpha.1]
|
||||
|
||||
- [`d74fc0a`](https://github.com/tauri-apps/plugins-workspace/commit/d74fc0a097996e90a37be8f57d50b7d1f6ca616f)([#555](https://github.com/tauri-apps/plugins-workspace/pull/555)) Update to alpha.11.
|
||||
- [`4ab90f0`](https://github.com/tauri-apps/plugins-workspace/commit/4ab90f048eab2918344f97dc8e04413a404e392d)([#431](https://github.com/tauri-apps/plugins-workspace/pull/431)) The updater plugin is recieving a few changes to improve consistency and ergonomics of the Rust and JS APIs
|
||||
|
||||
## \[2.0.0-alpha.0]
|
||||
|
||||
- [`717ae67`](https://github.com/tauri-apps/plugins-workspace/commit/717ae670978feb4492fac1f295998b93f2b9347f)([#371](https://github.com/tauri-apps/plugins-workspace/pull/371)) First v2 alpha release!
|
||||
+4580
File diff suppressed because it is too large
Load Diff
+180
@@ -0,0 +1,180 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
authors = ["Tauri Programme within The Commons Conservancy"]
|
||||
build = "build.rs"
|
||||
links = "tauri-plugin-updater"
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "In-app updates for Tauri applications."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/tauri-apps/plugins-workspace"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
no-default-features = true
|
||||
features = ["zip"]
|
||||
|
||||
[package.metadata.platforms.support.windows]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.linux]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.macos]
|
||||
level = "full"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.android]
|
||||
level = "none"
|
||||
notes = ""
|
||||
|
||||
[package.metadata.platforms.support.ios]
|
||||
level = "none"
|
||||
notes = ""
|
||||
|
||||
[features]
|
||||
default = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
]
|
||||
native-tls = ["reqwest/native-tls"]
|
||||
native-tls-vendored = ["reqwest/native-tls-vendored"]
|
||||
rustls-tls = [
|
||||
"reqwest/rustls-no-provider",
|
||||
"dep:rustls",
|
||||
]
|
||||
zip = [
|
||||
"dep:zip",
|
||||
"dep:tar",
|
||||
"dep:flate2",
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "tauri_plugin_updater"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies.base64]
|
||||
version = "0.22"
|
||||
|
||||
[dependencies.futures-util]
|
||||
version = "0.3"
|
||||
|
||||
[dependencies.http]
|
||||
version = "1"
|
||||
|
||||
[dependencies.infer]
|
||||
version = "0.19"
|
||||
|
||||
[dependencies.log]
|
||||
version = "0.4"
|
||||
|
||||
[dependencies.minisign-verify]
|
||||
version = "0.2"
|
||||
|
||||
[dependencies.percent-encoding]
|
||||
version = "2.3"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.13"
|
||||
features = [
|
||||
"json",
|
||||
"stream",
|
||||
]
|
||||
default-features = false
|
||||
|
||||
[dependencies.rustls]
|
||||
version = "0.23"
|
||||
features = ["ring"]
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.semver]
|
||||
version = "1"
|
||||
features = ["serde"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1"
|
||||
features = ["derive"]
|
||||
|
||||
[dependencies.serde_json]
|
||||
version = "1"
|
||||
|
||||
[dependencies.tauri]
|
||||
version = "2.10"
|
||||
default-features = false
|
||||
|
||||
[dependencies.tempfile]
|
||||
version = "3.20"
|
||||
|
||||
[dependencies.thiserror]
|
||||
version = "2"
|
||||
|
||||
[dependencies.time]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"parsing",
|
||||
"formatting",
|
||||
]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1"
|
||||
|
||||
[dependencies.url]
|
||||
version = "2"
|
||||
|
||||
[build-dependencies.tauri-plugin]
|
||||
version = "2.5"
|
||||
features = ["build"]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.dirs]
|
||||
version = "6"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.flate2]
|
||||
version = "1"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.tar]
|
||||
version = "0.4"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.flate2]
|
||||
version = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.osakit]
|
||||
version = "0.3"
|
||||
features = ["full"]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.tar]
|
||||
version = "0.4"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.windows-sys]
|
||||
version = "0.60.0"
|
||||
features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
]
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.zip]
|
||||
version = "4"
|
||||
optional = true
|
||||
default-features = false
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
[package]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.10.1"
|
||||
description = "In-app updates for Tauri applications."
|
||||
edition = { workspace = true }
|
||||
authors = { workspace = true }
|
||||
license = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
repository = { workspace = true }
|
||||
links = "tauri-plugin-updater"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
no-default-features = true
|
||||
features = ["zip"]
|
||||
|
||||
[package.metadata.platforms.support]
|
||||
windows = { level = "full", notes = "" }
|
||||
linux = { level = "full", notes = "" }
|
||||
macos = { level = "full", notes = "" }
|
||||
android = { level = "none", notes = "" }
|
||||
ios = { level = "none", notes = "" }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { workspace = true, features = ["build"] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
tokio = "1"
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
] }
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"ring",
|
||||
], optional = true }
|
||||
url = { workspace = true }
|
||||
http = "1"
|
||||
minisign-verify = "0.2"
|
||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||
base64 = "0.22"
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
futures-util = "0.3"
|
||||
tempfile = "3.20"
|
||||
infer = "0.19"
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
zip = { version = "4", default-features = false, optional = true }
|
||||
windows-sys = { version = "0.60.0", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[target."cfg(target_os = \"linux\")".dependencies]
|
||||
dirs = "6"
|
||||
tar = { version = "0.4", optional = true }
|
||||
flate2 = { version = "1", optional = true }
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
osakit = { version = "0.3", features = ["full"] }
|
||||
|
||||
[features]
|
||||
default = ["rustls-tls", "zip"]
|
||||
zip = ["dep:zip", "dep:tar", "dep:flate2"]
|
||||
native-tls = ["reqwest/native-tls"]
|
||||
native-tls-vendored = ["reqwest/native-tls-vendored"]
|
||||
rustls-tls = ["reqwest/rustls-no-provider", "dep:rustls"]
|
||||
+1
@@ -0,0 +1 @@
|
||||
Based on tauri-plugin-updater 2.10.1. Keeps upstream licenses. Adds offline restore_update using the same construction as network check, verify using the configured signing key, and checks ShellExecuteW before Windows process exit. Cached metadata never supplies executable paths or runtime configuration.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
SPDXVersion: SPDX-2.1
|
||||
DataLicense: CC0-1.0
|
||||
PackageName: tauri
|
||||
DataFormat: SPDXRef-1
|
||||
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
|
||||
PackageHomePage: https://tauri.app
|
||||
PackageLicenseDeclared: Apache-2.0
|
||||
PackageLicenseDeclared: MIT
|
||||
PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
|
||||
PackageSummary: <text>Tauri is a rust project that enables developers to make secure
|
||||
and small desktop applications using a web frontend.
|
||||
</text>
|
||||
PackageComment: <text>The package includes the following libraries; see
|
||||
Relationship information.
|
||||
</text>
|
||||
Created: 2019-05-20T09:00:00Z
|
||||
PackageDownloadLocation: git://github.com/tauri-apps/tauri
|
||||
PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
|
||||
PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
|
||||
Creator: Person: Daniel Thompson-Yvetot
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||

|
||||
|
||||
In-app updates for Tauri applications.
|
||||
|
||||
| Platform | Supported |
|
||||
| -------- | --------- |
|
||||
| Linux | ✓ |
|
||||
| Windows | ✓ |
|
||||
| macOS | ✓ |
|
||||
| Android | x |
|
||||
| iOS | x |
|
||||
|
||||
## Install
|
||||
|
||||
_This plugin requires a Rust version of at least **1.77.2**_
|
||||
|
||||
There are three general methods of installation that we can recommend.
|
||||
|
||||
1. Use crates.io and npm (easiest, and requires you to trust that our publishing pipeline worked)
|
||||
2. Pull sources directly from Github using git tags / revision hashes (most secure)
|
||||
3. Git submodule install this repo in your tauri project and then use file protocol to ingest the source (most secure, but inconvenient to use)
|
||||
|
||||
Install the Core plugin by adding the following to your `Cargo.toml` file:
|
||||
|
||||
`src-tauri/Cargo.toml`
|
||||
|
||||
```toml
|
||||
# you can add the dependencies on the `[dependencies]` section if you do not target mobile
|
||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||
tauri-plugin-updater = "2.0.0"
|
||||
# alternatively with Git:
|
||||
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
```
|
||||
|
||||
You can install the JavaScript Guest bindings using your preferred JavaScript package manager:
|
||||
|
||||
```sh
|
||||
pnpm add @tauri-apps/plugin-updater
|
||||
# or
|
||||
npm add @tauri-apps/plugin-updater
|
||||
# or
|
||||
yarn add @tauri-apps/plugin-updater
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
First you need to register the core plugin with Tauri:
|
||||
|
||||
`src-tauri/src/lib.rs`
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
#[cfg(desktop)]
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
```
|
||||
|
||||
Afterwards all the plugin's APIs are available through the JavaScript guest bindings:
|
||||
|
||||
```javascript
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
const update = await check()
|
||||
if (update) {
|
||||
await update.downloadAndInstall()
|
||||
await relaunch()
|
||||
}
|
||||
```
|
||||
|
||||
Note that for these APIs to work you have to properly configure the updater first and generate updater artifacts. Please refer to the [guide on our website](https://v2.tauri.app/plugin/updater/) for this.
|
||||
|
||||
## Contributing
|
||||
|
||||
PRs accepted. Please make sure to read the Contributing Guide before making a pull request.
|
||||
|
||||
## Partners
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://crabnebula.dev" target="_blank">
|
||||
<img src="https://github.com/tauri-apps/plugins-workspace/raw/v2/.github/sponsors/crabnebula.svg" alt="CrabNebula" width="283">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
For the complete list of sponsors please visit our [website](https://tauri.app#sponsors) and [Open Collective](https://opencollective.com/tauri).
|
||||
|
||||
## License
|
||||
|
||||
Code: (c) 2015 - Present - The Tauri Programme within The Commons Conservancy.
|
||||
|
||||
MIT or MIT/Apache 2.0 where applicable.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
**Do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
**Please use the [Private Vulnerability Disclosure](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) feature of GitHub.**
|
||||
|
||||
Include as much of the following information:
|
||||
|
||||
- Type of issue (e.g. improper input parsing, privilege escalation, etc.)
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- The distribution affected or used to help us with reproduction of the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Ideally a reproduction repository
|
||||
- Impact of the issue, including how an attacker might exploit the issue
|
||||
|
||||
We prefer to receive reports in English.
|
||||
|
||||
## Contact
|
||||
|
||||
Please disclose a vulnerability or security relevant issue here: [https://github.com/tauri-apps/plugins-workspace/security/advisories/new](https://github.com/tauri-apps/plugins-workspace/security/advisories/new).
|
||||
|
||||
Alternatively, you can also contact us by email via [security@tauri.app](mailto:security@tauri.app).
|
||||
+1
@@ -0,0 +1 @@
|
||||
if("__TAURI__"in window){var __TAURI_PLUGIN_UPDATER__=function(t){"use strict";function e(t,e,s,n){if("function"==typeof e?t!==e||!n:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===s?n:"a"===s?n.call(t):n?n.value:e.get(t)}function s(t,e,s,n,i){if("function"==typeof e||!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");return e.set(t,s),s}var n,i,a,r,o;"function"==typeof SuppressedError&&SuppressedError;const d="__TAURI_TO_IPC_KEY__";class c{constructor(t){n.set(this,void 0),i.set(this,0),a.set(this,[]),r.set(this,void 0),s(this,n,t||(()=>{})),this.id=function(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}(t=>{const o=t.index;if("end"in t)return void(o==e(this,i,"f")?this.cleanupCallback():s(this,r,o));const d=t.message;if(o==e(this,i,"f")){for(e(this,n,"f").call(this,d),s(this,i,e(this,i,"f")+1);e(this,i,"f")in e(this,a,"f");){const t=e(this,a,"f")[e(this,i,"f")];e(this,n,"f").call(this,t),delete e(this,a,"f")[e(this,i,"f")],s(this,i,e(this,i,"f")+1)}e(this,i,"f")===e(this,r,"f")&&this.cleanupCallback()}else e(this,a,"f")[o]=d})}cleanupCallback(){window.__TAURI_INTERNALS__.unregisterCallback(this.id)}set onmessage(t){s(this,n,t)}get onmessage(){return e(this,n,"f")}[(n=new WeakMap,i=new WeakMap,a=new WeakMap,r=new WeakMap,d)](){return`__CHANNEL__:${this.id}`}toJSON(){return this[d]()}}async function l(t,e={},s){return window.__TAURI_INTERNALS__.invoke(t,e,s)}class h{get rid(){return e(this,o,"f")}constructor(t){o.set(this,void 0),s(this,o,t)}async close(){return l("plugin:resources|close",{rid:this.rid})}}o=new WeakMap;class u extends h{constructor(t){super(t.rid),this.available=!0,this.currentVersion=t.currentVersion,this.version=t.version,this.date=t.date,this.body=t.body,this.rawJson=t.rawJson}async download(t,e){_(e);const s=new c;t&&(s.onmessage=t);const n=await l("plugin:updater|download",{onEvent:s,rid:this.rid,...e});this.downloadedBytes=new h(n)}async install(){if(!this.downloadedBytes)throw new Error("Update.install called before Update.download");await l("plugin:updater|install",{updateRid:this.rid,bytesRid:this.downloadedBytes.rid}),this.downloadedBytes=void 0}async downloadAndInstall(t,e){_(e);const s=new c;t&&(s.onmessage=t),await l("plugin:updater|download_and_install",{onEvent:s,rid:this.rid,...e})}async close(){await(this.downloadedBytes?.close()),await super.close()}}function _(t){t?.headers&&(t.headers=Array.from(new Headers(t.headers).entries()))}return t.Update=u,t.check=async function(t){_(t);const e=await l("plugin:updater|check",{...t});return e?new u(e):null},t}({});Object.defineProperty(window.__TAURI__,"updater",{value:__TAURI_PLUGIN_UPDATER__})}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const COMMANDS: &[&str] = &["check", "download", "install", "download_and_install"];
|
||||
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(COMMANDS).global_api_script_path("./api-iife.js").build();
|
||||
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
|
||||
let mobile = target_os == "ios" || target_os == "android";
|
||||
alias("desktop", !mobile);
|
||||
alias("mobile", mobile);
|
||||
}
|
||||
|
||||
// creates a cfg alias if `has_feature` is true.
|
||||
// `alias` must be a snake case string.
|
||||
fn alias(alias: &str, has_feature: bool) {
|
||||
println!("cargo:rustc-check-cfg=cfg({alias})");
|
||||
if has_feature {
|
||||
println!("cargo:rustc-cfg={alias}");
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { invoke, Channel, Resource } from '@tauri-apps/api/core'
|
||||
|
||||
/** Options used when checking for updates */
|
||||
interface CheckOptions {
|
||||
/**
|
||||
* Request headers
|
||||
*/
|
||||
headers?: HeadersInit
|
||||
/**
|
||||
* Timeout in milliseconds
|
||||
*/
|
||||
timeout?: number
|
||||
/**
|
||||
* A proxy url to be used when checking and downloading updates.
|
||||
*/
|
||||
proxy?: string
|
||||
/**
|
||||
* Target identifier for the running application. This is sent to the backend.
|
||||
*/
|
||||
target?: string
|
||||
/**
|
||||
* Allow downgrades to previous versions by not checking if the current version is greater than the available version.
|
||||
*/
|
||||
allowDowngrades?: boolean
|
||||
}
|
||||
|
||||
/** Options used when downloading an update */
|
||||
interface DownloadOptions {
|
||||
/**
|
||||
* Request headers
|
||||
*/
|
||||
headers?: HeadersInit
|
||||
/**
|
||||
* Timeout in milliseconds
|
||||
*/
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
interface UpdateMetadata {
|
||||
rid: number
|
||||
currentVersion: string
|
||||
version: string
|
||||
date?: string
|
||||
body?: string
|
||||
rawJson: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Updater download event */
|
||||
type DownloadEvent =
|
||||
| { event: 'Started'; data: { contentLength?: number } }
|
||||
| { event: 'Progress'; data: { chunkLength: number } }
|
||||
| { event: 'Finished' }
|
||||
|
||||
class Update extends Resource {
|
||||
// TODO: remove this field in v3
|
||||
/** @deprecated This is always true, check if the return value is `null` instead when using {@linkcode check} */
|
||||
available: boolean
|
||||
currentVersion: string
|
||||
version: string
|
||||
date?: string
|
||||
body?: string
|
||||
rawJson: Record<string, unknown>
|
||||
private downloadedBytes?: Resource
|
||||
|
||||
constructor(metadata: UpdateMetadata) {
|
||||
super(metadata.rid)
|
||||
this.available = true
|
||||
this.currentVersion = metadata.currentVersion
|
||||
this.version = metadata.version
|
||||
this.date = metadata.date
|
||||
this.body = metadata.body
|
||||
this.rawJson = metadata.rawJson
|
||||
}
|
||||
|
||||
/** Download the updater package */
|
||||
async download(
|
||||
onEvent?: (progress: DownloadEvent) => void,
|
||||
options?: DownloadOptions
|
||||
): Promise<void> {
|
||||
convertToRustHeaders(options)
|
||||
const channel = new Channel<DownloadEvent>()
|
||||
if (onEvent) {
|
||||
channel.onmessage = onEvent
|
||||
}
|
||||
const downloadedBytesRid = await invoke<number>('plugin:updater|download', {
|
||||
onEvent: channel,
|
||||
rid: this.rid,
|
||||
...options
|
||||
})
|
||||
this.downloadedBytes = new Resource(downloadedBytesRid)
|
||||
}
|
||||
|
||||
/** Install downloaded updater package */
|
||||
async install(): Promise<void> {
|
||||
if (!this.downloadedBytes) {
|
||||
throw new Error('Update.install called before Update.download')
|
||||
}
|
||||
|
||||
await invoke('plugin:updater|install', {
|
||||
updateRid: this.rid,
|
||||
bytesRid: this.downloadedBytes.rid
|
||||
})
|
||||
|
||||
// Don't need to call close, we did it in rust side already
|
||||
this.downloadedBytes = undefined
|
||||
}
|
||||
|
||||
/** Downloads the updater package and installs it */
|
||||
async downloadAndInstall(
|
||||
onEvent?: (progress: DownloadEvent) => void,
|
||||
options?: DownloadOptions
|
||||
): Promise<void> {
|
||||
convertToRustHeaders(options)
|
||||
const channel = new Channel<DownloadEvent>()
|
||||
if (onEvent) {
|
||||
channel.onmessage = onEvent
|
||||
}
|
||||
await invoke('plugin:updater|download_and_install', {
|
||||
onEvent: channel,
|
||||
rid: this.rid,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.downloadedBytes?.close()
|
||||
await super.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Check for updates, resolves to `null` if no updates are available */
|
||||
async function check(options?: CheckOptions): Promise<Update | null> {
|
||||
convertToRustHeaders(options)
|
||||
|
||||
const metadata = await invoke<UpdateMetadata | null>('plugin:updater|check', {
|
||||
...options
|
||||
})
|
||||
return metadata ? new Update(metadata) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the headers in options to be an {@linkcode Array<[string, string]>} which is what the Rust side expects
|
||||
*/
|
||||
function convertToRustHeaders(options?: { headers?: HeadersInit }) {
|
||||
if (options?.headers) {
|
||||
options.headers = Array.from(new Headers(options.headers).entries())
|
||||
}
|
||||
}
|
||||
|
||||
export type { CheckOptions, DownloadOptions, DownloadEvent }
|
||||
export { check, Update }
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@tauri-apps/plugin-updater",
|
||||
"version": "2.10.1",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"authors": [
|
||||
"Tauri Programme within The Commons Conservancy"
|
||||
],
|
||||
"repository": "https://github.com/tauri-apps/plugins-workspace",
|
||||
"type": "module",
|
||||
"types": "./dist-js/index.d.ts",
|
||||
"main": "./dist-js/index.cjs",
|
||||
"module": "./dist-js/index.js",
|
||||
"exports": {
|
||||
"types": "./dist-js/index.d.ts",
|
||||
"import": "./dist-js/index.js",
|
||||
"require": "./dist-js/index.cjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rollup -c"
|
||||
},
|
||||
"files": [
|
||||
"dist-js",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-check"
|
||||
description = "Enables the check command without any pre-configured scope."
|
||||
commands.allow = ["check"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-check"
|
||||
description = "Denies the check command without any pre-configured scope."
|
||||
commands.deny = ["check"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-download"
|
||||
description = "Enables the download command without any pre-configured scope."
|
||||
commands.allow = ["download"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-download"
|
||||
description = "Denies the download command without any pre-configured scope."
|
||||
commands.deny = ["download"]
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-download-and-install"
|
||||
description = "Enables the download_and_install command without any pre-configured scope."
|
||||
commands.allow = ["download_and_install"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-download-and-install"
|
||||
description = "Denies the download_and_install command without any pre-configured scope."
|
||||
commands.deny = ["download_and_install"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-install"
|
||||
description = "Enables the install command without any pre-configured scope."
|
||||
commands.allow = ["install"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-install"
|
||||
description = "Denies the install command without any pre-configured scope."
|
||||
commands.deny = ["install"]
|
||||
@@ -0,0 +1,130 @@
|
||||
## Default Permission
|
||||
|
||||
This permission set configures which kind of
|
||||
updater functions are exposed to the frontend.
|
||||
|
||||
#### Granted Permissions
|
||||
|
||||
The full workflow from checking for updates to installing them
|
||||
is enabled.
|
||||
|
||||
#### This default permission set includes the following:
|
||||
|
||||
- `allow-check`
|
||||
- `allow-download`
|
||||
- `allow-install`
|
||||
- `allow-download-and-install`
|
||||
|
||||
## Permission Table
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-check`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the check command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-check`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the check command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-download`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the download command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-download`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the download command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-download-and-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the download_and_install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-download-and-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the download_and_install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:allow-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`updater:deny-install`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the install command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -0,0 +1,18 @@
|
||||
"$schema" = "schemas/schema.json"
|
||||
[default]
|
||||
description = """
|
||||
This permission set configures which kind of
|
||||
updater functions are exposed to the frontend.
|
||||
|
||||
#### Granted Permissions
|
||||
|
||||
The full workflow from checking for updates to installing them
|
||||
is enabled.
|
||||
|
||||
"""
|
||||
permissions = [
|
||||
"allow-check",
|
||||
"allow-download",
|
||||
"allow-install",
|
||||
"allow-download-and-install",
|
||||
]
|
||||
@@ -0,0 +1,354 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PermissionFile",
|
||||
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default": {
|
||||
"description": "The default permission set for the plugin",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DefaultPermission"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"set": {
|
||||
"description": "A list of permissions sets defined",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionSet"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"description": "A list of inlined permissions",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Permission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"DefaultPermission": {
|
||||
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"description": "A set of direct permissions grouped together under a new name.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"description",
|
||||
"identifier",
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does.",
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionKind"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Permission": {
|
||||
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"description": "Allowed or denied commands when using this permission.",
|
||||
"default": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Commands"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"description": "Allowed or denied scoped when using this permission.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Scopes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platforms": {
|
||||
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Target"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Commands": {
|
||||
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Allowed command.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Denied command, which takes priority.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scopes": {
|
||||
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Data that defines what is allowed by the scope.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Value": {
|
||||
"description": "All supported ACL values.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents a null JSON value.",
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`bool`].",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "Represents a valid ACL [`Number`].",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`String`].",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Represents a list of other [`Value`]s.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Number": {
|
||||
"description": "A valid ACL number.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents an [`i64`].",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`f64`].",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Target": {
|
||||
"description": "Platform target.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "MacOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"macOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Windows.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Linux.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Android.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "iOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iOS"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionKind": {
|
||||
"type": "string",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Enables the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-check",
|
||||
"markdownDescription": "Enables the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the check command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-check",
|
||||
"markdownDescription": "Denies the check command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-download",
|
||||
"markdownDescription": "Enables the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-download",
|
||||
"markdownDescription": "Denies the download command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-download-and-install",
|
||||
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the download_and_install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-download-and-install",
|
||||
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-install",
|
||||
"markdownDescription": "Enables the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the install command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-install",
|
||||
"markdownDescription": "Denies the install command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
|
||||
"type": "string",
|
||||
"const": "default",
|
||||
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { createConfig } from '../../shared/rollup.config.js'
|
||||
|
||||
export default createConfig()
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::{Result, Update, UpdaterExt};
|
||||
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::Serialize;
|
||||
use tauri::{ipc::Channel, Manager, Resource, ResourceId, Runtime, Webview};
|
||||
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum DownloadEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
content_length: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
chunk_length: usize,
|
||||
},
|
||||
Finished,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Metadata {
|
||||
rid: ResourceId,
|
||||
current_version: String,
|
||||
version: String,
|
||||
date: Option<String>,
|
||||
body: Option<String>,
|
||||
raw_json: serde_json::Value,
|
||||
}
|
||||
|
||||
struct DownloadedBytes(pub Vec<u8>);
|
||||
impl Resource for DownloadedBytes {}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn check<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
timeout: Option<u64>,
|
||||
proxy: Option<String>,
|
||||
target: Option<String>,
|
||||
allow_downgrades: Option<bool>,
|
||||
) -> Result<Option<Metadata>> {
|
||||
let mut builder = webview.updater_builder();
|
||||
if let Some(headers) = headers {
|
||||
for (k, v) in headers {
|
||||
builder = builder.header(k, v)?;
|
||||
}
|
||||
}
|
||||
if let Some(timeout) = timeout {
|
||||
builder = builder.timeout(Duration::from_millis(timeout));
|
||||
}
|
||||
if let Some(ref proxy) = proxy {
|
||||
let url = Url::parse(proxy.as_str())?;
|
||||
builder = builder.proxy(url);
|
||||
}
|
||||
if let Some(target) = target {
|
||||
builder = builder.target(target);
|
||||
}
|
||||
if allow_downgrades.unwrap_or(false) {
|
||||
builder = builder.version_comparator(|current, update| update.version != current);
|
||||
}
|
||||
|
||||
let updater = builder.build()?;
|
||||
let update = updater.check().await?;
|
||||
|
||||
if let Some(update) = update {
|
||||
let formatted_date = if let Some(date) = update.date {
|
||||
let formatted_date =
|
||||
date.format(&time::format_description::well_known::Rfc3339).map_err(|_| crate::Error::FormatDate)?;
|
||||
Some(formatted_date)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let metadata = Metadata {
|
||||
current_version: update.current_version.clone(),
|
||||
version: update.version.clone(),
|
||||
date: formatted_date,
|
||||
body: update.body.clone(),
|
||||
raw_json: update.raw_json.clone(),
|
||||
rid: webview.resources_table().add(update),
|
||||
};
|
||||
Ok(Some(metadata))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn download<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
rid: ResourceId,
|
||||
on_event: Channel<DownloadEvent>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
timeout: Option<u64>,
|
||||
) -> Result<ResourceId> {
|
||||
let update = webview.resources_table().get::<Update>(rid)?;
|
||||
|
||||
let mut update = (*update).clone();
|
||||
|
||||
if let Some(headers) = headers {
|
||||
let mut map = HeaderMap::new();
|
||||
for (k, v) in headers {
|
||||
map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?);
|
||||
}
|
||||
update.headers = map;
|
||||
}
|
||||
|
||||
if let Some(timeout) = timeout {
|
||||
update.timeout = Some(Duration::from_millis(timeout));
|
||||
}
|
||||
|
||||
let mut first_chunk = true;
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk_length, content_length| {
|
||||
if first_chunk {
|
||||
first_chunk = !first_chunk;
|
||||
let _ = on_event.send(DownloadEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(DownloadEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
let _ = on_event.send(DownloadEvent::Finished);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(webview.resources_table().add(DownloadedBytes(bytes)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
update_rid: ResourceId,
|
||||
bytes_rid: ResourceId,
|
||||
) -> Result<()> {
|
||||
let update = webview.resources_table().get::<Update>(update_rid)?;
|
||||
let bytes = webview.resources_table().get::<DownloadedBytes>(bytes_rid)?;
|
||||
update.install(&bytes.0)?;
|
||||
let _ = webview.resources_table().close(bytes_rid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn download_and_install<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
rid: ResourceId,
|
||||
on_event: Channel<DownloadEvent>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
timeout: Option<u64>,
|
||||
) -> Result<()> {
|
||||
let update = webview.resources_table().get::<Update>(rid)?;
|
||||
|
||||
let mut update = (*update).clone();
|
||||
|
||||
if let Some(headers) = headers {
|
||||
let mut map = HeaderMap::new();
|
||||
for (k, v) in headers {
|
||||
map.append(HeaderName::from_str(&k)?, HeaderValue::from_str(&v)?);
|
||||
}
|
||||
update.headers = map;
|
||||
}
|
||||
|
||||
if let Some(timeout) = timeout {
|
||||
update.timeout = Some(Duration::from_millis(timeout));
|
||||
}
|
||||
|
||||
let mut first_chunk = true;
|
||||
|
||||
update
|
||||
.download_and_install(
|
||||
|chunk_length, content_length| {
|
||||
if first_chunk {
|
||||
first_chunk = !first_chunk;
|
||||
let _ = on_event.send(DownloadEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(DownloadEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
let _ = on_event.send(DownloadEvent::Finished);
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use std::{ffi::OsString, fmt::Display};
|
||||
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use url::Url;
|
||||
|
||||
/// Install modes for the Windows update.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[derive(Default)]
|
||||
pub enum WindowsUpdateInstallMode {
|
||||
/// Specifies there's a basic UI during the installation process, including a final dialog box at the end.
|
||||
BasicUi,
|
||||
/// The quiet mode means there's no user interaction required.
|
||||
/// Requires admin privileges if the installer does.
|
||||
Quiet,
|
||||
/// Specifies unattended mode, which means the installation only shows a progress bar.
|
||||
#[default]
|
||||
Passive,
|
||||
}
|
||||
|
||||
impl WindowsUpdateInstallMode {
|
||||
/// Returns the associated `msiexec.exe` arguments.
|
||||
pub fn msiexec_args(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::BasicUi => &["/qb+"],
|
||||
Self::Quiet => &["/quiet"],
|
||||
Self::Passive => &["/passive"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the associated nsis arguments.
|
||||
pub fn nsis_args(&self) -> &'static [&'static str] {
|
||||
// `/P`: Passive
|
||||
// `/S`: Silent
|
||||
// `/R`: Restart
|
||||
match self {
|
||||
Self::Passive => &["/P", "/R"],
|
||||
Self::Quiet => &["/S", "/R"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for WindowsUpdateInstallMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Self::BasicUi => "basicUi",
|
||||
Self::Quiet => "quiet",
|
||||
Self::Passive => "passive",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowsConfig {
|
||||
/// Additional arguments given to the NSIS or WiX installer.
|
||||
#[serde(default, alias = "installer-args", deserialize_with = "deserialize_os_string")]
|
||||
pub installer_args: Vec<OsString>,
|
||||
/// Updating mode, defaults to `passive` mode.
|
||||
///
|
||||
/// See [`WindowsUpdateInstallMode`] for more info.
|
||||
#[serde(default, alias = "install-mode")]
|
||||
pub install_mode: WindowsUpdateInstallMode,
|
||||
}
|
||||
|
||||
fn deserialize_os_string<'de, D>(deserializer: D) -> Result<Vec<OsString>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(Vec::<String>::deserialize(deserializer)?.into_iter().map(OsString::from).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Updater configuration.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
/// Dangerously allow using insecure transport protocols for update endpoints.
|
||||
pub dangerous_insecure_transport_protocol: bool,
|
||||
/// Dangerously accept invalid TLS certificates for update requests.
|
||||
pub dangerous_accept_invalid_certs: bool,
|
||||
/// Dangerously accept invalid hostnames for TLS certificates for update requests.
|
||||
pub dangerous_accept_invalid_hostnames: bool,
|
||||
/// Updater endpoints.
|
||||
pub endpoints: Vec<Url>,
|
||||
/// Signature public key.
|
||||
pub pubkey: String,
|
||||
/// The Windows configuration for the updater.
|
||||
pub windows: Option<WindowsConfig>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Config {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
#[serde(default, alias = "dangerous-insecure-transport-protocol")]
|
||||
pub dangerous_insecure_transport_protocol: bool,
|
||||
#[serde(default, alias = "dangerous-accept-invalid-certs")]
|
||||
pub dangerous_accept_invalid_certs: bool,
|
||||
#[serde(default, alias = "dangerous-accept-invalid-hostnames")]
|
||||
pub dangerous_accept_invalid_hostnames: bool,
|
||||
#[serde(default)]
|
||||
pub endpoints: Vec<Url>,
|
||||
pub pubkey: String,
|
||||
pub windows: Option<WindowsConfig>,
|
||||
}
|
||||
|
||||
let config = Config::deserialize(deserializer)?;
|
||||
|
||||
validate_endpoints(&config.endpoints, config.dangerous_insecure_transport_protocol)
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
|
||||
Ok(Self {
|
||||
dangerous_insecure_transport_protocol: config.dangerous_insecure_transport_protocol,
|
||||
dangerous_accept_invalid_certs: config.dangerous_accept_invalid_certs,
|
||||
dangerous_accept_invalid_hostnames: config.dangerous_accept_invalid_hostnames,
|
||||
endpoints: config.endpoints,
|
||||
pubkey: config.pubkey,
|
||||
windows: config.windows,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_endpoints(endpoints: &[Url], dangerous_insecure_transport_protocol: bool) -> crate::Result<()> {
|
||||
if !dangerous_insecure_transport_protocol {
|
||||
for url in endpoints {
|
||||
if url.scheme() != "https" {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
eprintln!("[\x1b[33mWARNING\x1b[0m] The updater endpoint \"{url}\" doesn't use `https` protocol. This is allowed in development but will fail in release builds.");
|
||||
eprintln!("[\x1b[33mWARNING\x1b[0m] if this is a desired behavior, you can enable `dangerousInsecureTransportProtocol` in the plugin configuration");
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
return Err(crate::Error::InsecureTransportProtocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
use thiserror::Error;
|
||||
|
||||
/// All errors that can occur while running the updater.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// Endpoints are not sent.
|
||||
#[error("Updater does not have any endpoints set.")]
|
||||
EmptyEndpoints,
|
||||
/// IO errors.
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
/// Semver errors.
|
||||
#[error(transparent)]
|
||||
Semver(#[from] semver::Error),
|
||||
/// Serialization errors.
|
||||
#[error(transparent)]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
/// Could not fetch a valid response from the server.
|
||||
#[error("Could not fetch a valid release JSON from the remote")]
|
||||
ReleaseNotFound,
|
||||
/// Unsupported app architecture.
|
||||
#[error("Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`.")]
|
||||
UnsupportedArch,
|
||||
/// Operating system is not supported.
|
||||
#[error("Unsupported OS, expected one of `linux`, `darwin` or `windows`.")]
|
||||
UnsupportedOs,
|
||||
/// Failed to determine updater package extract path
|
||||
#[error("Failed to determine updater package extract path.")]
|
||||
FailedToDetermineExtractPath,
|
||||
/// Url parsing errors.
|
||||
#[error(transparent)]
|
||||
UrlParse(#[from] url::ParseError),
|
||||
/// `reqwest` crate errors.
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
/// The platform was not found in the updater JSON response.
|
||||
#[error("the platform `{0}` was not found in the response `platforms` object")]
|
||||
TargetNotFound(String),
|
||||
/// Neither the platform nor the fallback platform was found in the updater JSON response.
|
||||
#[error("None of the fallback platforms `{0:?}` were found in the response `platforms` object")]
|
||||
TargetsNotFound(Vec<String>),
|
||||
/// Download failed
|
||||
#[error("`{0}`")]
|
||||
Network(String),
|
||||
/// `minisign_verify` errors.
|
||||
#[error(transparent)]
|
||||
Minisign(#[from] minisign_verify::Error),
|
||||
/// `base64` errors.
|
||||
#[error(transparent)]
|
||||
Base64(#[from] base64::DecodeError),
|
||||
/// UTF8 Errors in signature.
|
||||
#[error("The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string.")]
|
||||
SignatureUtf8(String),
|
||||
#[cfg(all(target_os = "windows", feature = "zip"))]
|
||||
/// `zip` errors.
|
||||
#[error(transparent)]
|
||||
Extract(#[from] zip::result::ZipError),
|
||||
/// Temp dir is not on same mount mount. This prevents our updater to rename the AppImage to a temp file.
|
||||
#[error("temp directory is not on the same mount point as the AppImage")]
|
||||
TempDirNotOnSameMountPoint,
|
||||
#[error("binary for the current target not found in the archive")]
|
||||
BinaryNotFoundInArchive,
|
||||
#[error("failed to create temporary directory")]
|
||||
TempDirNotFound,
|
||||
#[error("Authentication failed or was cancelled")]
|
||||
AuthenticationFailed,
|
||||
#[error("Failed to install .deb package")]
|
||||
DebInstallFailed,
|
||||
#[error("Failed to install package")]
|
||||
PackageInstallFailed,
|
||||
#[error("invalid updater binary format")]
|
||||
InvalidUpdaterFormat,
|
||||
#[error(transparent)]
|
||||
Http(#[from] http::Error),
|
||||
#[error(transparent)]
|
||||
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
|
||||
#[error(transparent)]
|
||||
InvalidHeaderName(#[from] http::header::InvalidHeaderName),
|
||||
#[error("Failed to format date")]
|
||||
FormatDate,
|
||||
/// The configured updater endpoint must use a secure protocol like `https`
|
||||
#[error("The configured updater endpoint must use a secure protocol like `https`.")]
|
||||
InsecureTransportProtocol,
|
||||
#[error(transparent)]
|
||||
Tauri(#[from] tauri::Error),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! In-app updates for Tauri applications.
|
||||
//!
|
||||
//! - Supported platforms: Windows, Linux and macOS.crypted database and secure runtime.
|
||||
|
||||
#![doc(
|
||||
html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
|
||||
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
|
||||
)]
|
||||
|
||||
use std::{ffi::OsString, sync::Arc};
|
||||
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use semver::Version;
|
||||
use tauri::{
|
||||
plugin::{Builder as PluginBuilder, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
};
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod error;
|
||||
mod updater;
|
||||
|
||||
pub use config::Config;
|
||||
pub use error::{Error, Result};
|
||||
pub use updater::*;
|
||||
|
||||
/// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the updater APIs.
|
||||
pub trait UpdaterExt<R: Runtime> {
|
||||
/// Gets the updater builder to build and updater
|
||||
/// that can manually check if an update is available.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_updater::UpdaterExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// let handle = app.handle().clone();
|
||||
/// tauri::async_runtime::spawn(async move {
|
||||
/// let response = handle.updater_builder().build().unwrap().check().await;
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
fn updater_builder(&self) -> UpdaterBuilder;
|
||||
|
||||
/// Gets the updater to manually check if an update is available.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tauri_plugin_updater::UpdaterExt;
|
||||
/// tauri::Builder::default()
|
||||
/// .setup(|app| {
|
||||
/// let handle = app.handle().clone();
|
||||
/// tauri::async_runtime::spawn(async move {
|
||||
/// let response = handle.updater().unwrap().check().await;
|
||||
/// });
|
||||
/// Ok(())
|
||||
/// });
|
||||
/// ```
|
||||
fn updater(&self) -> Result<Updater>;
|
||||
}
|
||||
|
||||
impl<R: Runtime, T: Manager<R>> UpdaterExt<R> for T {
|
||||
fn updater_builder(&self) -> UpdaterBuilder {
|
||||
let app = self.app_handle();
|
||||
let UpdaterState { config, target, version_comparator, headers } = self.state::<UpdaterState>().inner();
|
||||
|
||||
let mut builder = UpdaterBuilder::new(app, config.clone()).headers(headers.clone());
|
||||
|
||||
if let Some(target) = target {
|
||||
builder = builder.target(target);
|
||||
}
|
||||
|
||||
let args = self.env().args_os;
|
||||
if !args.is_empty() {
|
||||
builder = builder.current_exe_args(args);
|
||||
}
|
||||
|
||||
builder.version_comparator = version_comparator.clone();
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "linux",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd"
|
||||
))]
|
||||
{
|
||||
let env = app.env();
|
||||
if let Some(appimage) = env.appimage {
|
||||
builder = builder.executable_path(appimage);
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle = app.app_handle().clone();
|
||||
builder = builder.on_before_exit(move || {
|
||||
app_handle.cleanup_before_exit();
|
||||
});
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
fn updater(&self) -> Result<Updater> {
|
||||
self.updater_builder().build()
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdaterState {
|
||||
target: Option<String>,
|
||||
config: Config,
|
||||
version_comparator: Option<VersionComparator>,
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Builder {
|
||||
target: Option<String>,
|
||||
pubkey: Option<String>,
|
||||
installer_args: Vec<OsString>,
|
||||
headers: HeaderMap,
|
||||
default_version_comparator: Option<VersionComparator>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn target(mut self, target: impl Into<String>) -> Self {
|
||||
self.target.replace(target.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pubkey<S: Into<String>>(mut self, pubkey: S) -> Self {
|
||||
self.pubkey.replace(pubkey.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an additional argument to pass to the Windows installer.
|
||||
pub fn installer_args<I, S>(mut self, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
self.installer_args.extend(args.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds multiple additional arguments to pass to the Windows installer.
|
||||
pub fn installer_arg<S>(mut self, arg: S) -> Self
|
||||
where
|
||||
S: Into<OsString>,
|
||||
{
|
||||
self.installer_args.push(arg.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Removes all the additional arguments to pass to the Windows installer.
|
||||
///
|
||||
/// Note: this only removes the additional arguments added through [`Self::installer_args`],
|
||||
/// not the ones managed by us (e.g. `/UPDATER` flag passed to the NSIS installer)
|
||||
pub fn clear_installer_args(mut self) -> Self {
|
||||
self.installer_args.clear();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
|
||||
where
|
||||
HeaderName: TryFrom<K>,
|
||||
<HeaderName as TryFrom<K>>::Error: Into<http::Error>,
|
||||
HeaderValue: TryFrom<V>,
|
||||
<HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
|
||||
{
|
||||
let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
|
||||
let value: std::result::Result<HeaderValue, http::Error> = value.try_into().map_err(Into::into);
|
||||
self.headers.insert(key?, value?);
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn headers(mut self, headers: HeaderMap) -> Self {
|
||||
self.headers = headers;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn default_version_comparator<F: Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static>(
|
||||
mut self,
|
||||
f: F,
|
||||
) -> Self {
|
||||
self.default_version_comparator.replace(Arc::new(f));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build<R: Runtime>(self) -> TauriPlugin<R, Config> {
|
||||
let pubkey = self.pubkey;
|
||||
let target = self.target;
|
||||
let version_comparator = self.default_version_comparator;
|
||||
let installer_args = self.installer_args;
|
||||
let headers = self.headers;
|
||||
PluginBuilder::<R, Config>::new("updater")
|
||||
.setup(move |app, api| {
|
||||
let mut config = api.config().clone();
|
||||
if let Some(pubkey) = pubkey {
|
||||
config.pubkey = pubkey;
|
||||
}
|
||||
if let Some(windows) = &mut config.windows {
|
||||
windows.installer_args.extend(installer_args);
|
||||
}
|
||||
app.manage(UpdaterState { target, config, version_comparator, headers });
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::check,
|
||||
commands::download,
|
||||
commands::install,
|
||||
commands::download_and_install,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
}
|
||||
+1605
File diff suppressed because it is too large
Load Diff
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["guest-js/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user