385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
import { computed, ref } 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;
|
|
}
|
|
|
|
export function shouldOpenUpdateDialog(options: { silent?: boolean }) {
|
|
return options.silent !== true;
|
|
}
|
|
|
|
export function canDownloadAndInstallUpdate(info: api.UpdateInfo | null, isDesktop: boolean) {
|
|
return isDesktop && info?.update_available === true && info.manual_update_only !== true;
|
|
}
|
|
|
|
interface ParsedUpdateVersion {
|
|
core: [string, string, string];
|
|
prerelease: string[];
|
|
}
|
|
|
|
function normalizeUpdateVersion(version: string): string {
|
|
return version.trim().replace(/^[vV]/, "");
|
|
}
|
|
|
|
function parseUpdateVersion(version: string): ParsedUpdateVersion | null {
|
|
const match = normalizeUpdateVersion(version).match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
|
|
if (!match) return null;
|
|
return {
|
|
core: [match[1], match[2], match[3]],
|
|
prerelease: match[4]?.split(".") ?? [],
|
|
};
|
|
}
|
|
|
|
function compareNumericVersionIdentifier(left: string, right: string): number {
|
|
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
|
|
return left === right ? 0 : left < right ? -1 : 1;
|
|
}
|
|
|
|
function compareParsedUpdateVersions(left: ParsedUpdateVersion, right: ParsedUpdateVersion): number {
|
|
for (let index = 0; index < left.core.length; index += 1) {
|
|
const comparison = compareNumericVersionIdentifier(left.core[index], right.core[index]);
|
|
if (comparison !== 0) return comparison;
|
|
}
|
|
if (!left.prerelease.length || !right.prerelease.length) {
|
|
if (left.prerelease.length === right.prerelease.length) return 0;
|
|
return left.prerelease.length ? -1 : 1;
|
|
}
|
|
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
for (let index = 0; index < length; index += 1) {
|
|
const leftIdentifier = left.prerelease[index];
|
|
const rightIdentifier = right.prerelease[index];
|
|
if (leftIdentifier === undefined || rightIdentifier === undefined) return leftIdentifier === undefined ? -1 : 1;
|
|
if (leftIdentifier === rightIdentifier) continue;
|
|
const leftNumeric = /^\d+$/.test(leftIdentifier);
|
|
const rightNumeric = /^\d+$/.test(rightIdentifier);
|
|
if (leftNumeric && rightNumeric) return compareNumericVersionIdentifier(leftIdentifier, rightIdentifier);
|
|
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
return leftIdentifier < rightIdentifier ? -1 : 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export function isUpdateIgnored(info: api.UpdateInfo | null, ignoredVersion: string | undefined): boolean {
|
|
const latest = info?.latest_version;
|
|
if (!latest || !ignoredVersion) return false;
|
|
const parsedLatest = parseUpdateVersion(latest);
|
|
const parsedIgnored = parseUpdateVersion(ignoredVersion);
|
|
if (!parsedLatest || !parsedIgnored) return normalizeUpdateVersion(latest) === normalizeUpdateVersion(ignoredVersion);
|
|
return compareParsedUpdateVersions(parsedLatest, parsedIgnored) <= 0;
|
|
}
|
|
|
|
export function normalizeUpdateDownloadSource(value: unknown): SettingsUpdateDownloadSource {
|
|
// Old persisted AtomGit preferences should retain their mainland mirror behavior.
|
|
if (value === "atomgit") return "cnb";
|
|
return value === "cnb" ? "cnb" : "official";
|
|
}
|
|
|
|
export function tagVersion(version: string): string {
|
|
const trimmed = version.trim();
|
|
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
|
}
|
|
|
|
export function resolveUpdateReleaseUrl(info: api.UpdateInfo | null, source: unknown, fallbackUrl: string): string {
|
|
const normalizedSource = normalizeUpdateDownloadSource(source);
|
|
if (normalizedSource === "cnb" && info?.latest_version) {
|
|
return `https://cnb.cool/dbxio.com/dbx/-/releases/tag/${tagVersion(info.latest_version)}`;
|
|
}
|
|
if (normalizedSource === "cnb") return "https://cnb.cool/dbxio.com/dbx/-/releases";
|
|
return info?.release_url || fallbackUrl;
|
|
}
|
|
|
|
export async function resolveUpdaterProxy(): Promise<string | undefined> {
|
|
if (!isTauriRuntime()) return undefined;
|
|
try {
|
|
const proxy = await api.getSystemProxyUrl();
|
|
return proxy || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
|
const { t } = useI18n();
|
|
const { toast } = useToast();
|
|
const settingsStore = useSettingsStore();
|
|
|
|
const checkingUpdates = ref(false);
|
|
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 isIgnoringUpdate = ref(false);
|
|
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 latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
|
|
let activeDownloadAttempt = 0;
|
|
let pendingCancellation: Promise<void> | undefined;
|
|
|
|
function openUrl(url: string) {
|
|
if (isTauriRuntime()) {
|
|
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
|
|
} else {
|
|
window.open(url, "_blank");
|
|
}
|
|
}
|
|
|
|
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 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");
|
|
}
|
|
return t("updates.downloadFailed", { error: message });
|
|
}
|
|
|
|
function openLatestRelease() {
|
|
const url = resolveUpdateReleaseUrl(updateInfo.value, settingsStore.editorSettings.updateDownloadSource, latestReleaseUrl);
|
|
openUrl(url);
|
|
}
|
|
|
|
async function changeUpdateDownloadSource(source: SettingsUpdateDownloadSource) {
|
|
await settingsStore.updateEditorSettingsAndPersist({ updateDownloadSource: source });
|
|
await checkUpdates();
|
|
}
|
|
|
|
async function ignoreCurrentVersion() {
|
|
const latest = updateInfo.value?.latest_version;
|
|
if (!latest || isIgnoringUpdate.value) return;
|
|
isIgnoringUpdate.value = true;
|
|
try {
|
|
await settingsStore.updateEditorSettingsAndPersist({ ignoredUpdateVersion: latest });
|
|
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);
|
|
} 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);
|
|
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();
|
|
}
|
|
} 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;
|
|
}
|
|
toast(formatUpdateError(rawMsg), 5000);
|
|
} finally {
|
|
unlisten?.();
|
|
if (attempt === activeDownloadAttempt) {
|
|
isDownloadingUpdate.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
try {
|
|
await cancellation;
|
|
} finally {
|
|
if (pendingCancellation === cancellation) {
|
|
pendingCancellation = undefined;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
checkingUpdates,
|
|
updateInfo,
|
|
updateCheckMessage,
|
|
updateCheckFailed,
|
|
showUpdateDialog,
|
|
isDownloadingUpdate,
|
|
downloadProgress,
|
|
updateDownloaded,
|
|
isInstallingUpdate,
|
|
updateReady,
|
|
isIgnoringUpdate,
|
|
activeTaskCount,
|
|
hasUpdateAvailable,
|
|
latestReleaseUrl,
|
|
openUrl,
|
|
checkUpdates,
|
|
formatUpdateError,
|
|
openLatestRelease,
|
|
changeUpdateDownloadSource,
|
|
ignoreCurrentVersion,
|
|
downloadUpdateInBackground,
|
|
cancelDownload,
|
|
installDownloadedUpdate,
|
|
restartApp,
|
|
};
|
|
}
|