fix(desktop): allow auto-restart even when the instance is behind

Stop holding downloaded desktop updates until the connected instance
catches up. When the client is newer, system info shows a small hint
that some features may not work yet and that the instance can update
automatically each day or be updated manually.
This commit is contained in:
tianma-if committed 2026-09-11 10:36:36 +08:00
1 parent e154d73455
commit e8ed6b1019
16 files changed
+172 -159

No files matched your search

@@ -78,13 +78,13 @@ describe("desktop update flow", () => {
expect(notebookPaneSource).toContain('className="flex items-center gap-1"');
});
test("holds in-app restart installation while the GitHub release is newer than the instance", () => {
expect(mainSource).toContain("shouldHoldAutoRestartUpdate(downloadedUpdateVersion, instanceVersion)");
expect(mainSource).toContain("holdAutoRestartUpdate(downloadedUpdateVersion)");
expect(mainSource).toContain('writeDiagnostic("update.held-for-instance"');
expect(mainSource).toContain("autoUpdater.autoInstallOnAppQuit = false");
expect(mainSource).toContain("releaseHeldAutoRestartUpdate()");
expect(mainSource).toContain("${configuredApiBaseUrl}/api/release");
expect(mainSource).toContain("heldUpdateVersion ||");
test("does not hold auto-restart while the GitHub release is newer than the instance", () => {
expect(mainSource).not.toContain("shouldHoldAutoRestartUpdate");
expect(mainSource).not.toContain("holdAutoRestartUpdate");
expect(mainSource).not.toContain("heldUpdateVersion");
expect(mainSource).not.toContain("update.held-for-instance");
expect(mainSource).toContain("promptForDownloadedUpdate(downloadedUpdateVersion)");
expect(systemInfoSource).toContain('t("systemInfo.clientAheadOfInstance")');
expect(systemInfoSource).toContain("isClientAheadOfInstance");
});
});
+9 -68
View File
@@ -39,7 +39,6 @@ import {
fetchTrustedWindowsUpdate,
verifyDownloadedWindowsUpdate,
} from "./windows-update-trust.mjs";
import { instanceReleaseVersionFromPayload, shouldHoldAutoRestartUpdate } from "./instance-update-gate.mjs";
import electronUpdater from "electron-updater";
import { createPluginPublicNetworkRuntime } from "./plugin-public-network.mjs";
import { shouldQuitAfterAllWindowsClosed } from "./window-lifecycle.mjs";
@@ -116,7 +115,6 @@ let updateDownloadInFlight = null;
let updateCheckTimer = null;
let lastUpdateCheckAt = 0;
let downloadedUpdateVersion = null;
let heldUpdateVersion = null;
let promptedUpdateVersion = null;
let trustedWindowsUpdate = null;
let windowsDownloadedUpdateVerified = false;
@@ -808,52 +806,9 @@ const publishDesktopUpdateStatus = () => {
mainWindow.webContents.send("desktop:update-status-changed", desktopUpdateStatus());
};
const readInstanceReleaseVersion = async () => {
if (!configuredApiBaseUrl) return null;
try {
const response = await net.fetch(`${configuredApiBaseUrl}/api/release`);
if (!response.ok) return null;
return instanceReleaseVersionFromPayload(await response.json());
} catch {
return null;
}
};
const holdAutoRestartUpdate = async (version) => {
heldUpdateVersion = version || "unknown";
autoUpdater.autoInstallOnAppQuit = false;
updateState = "available";
downloadedUpdateVersion = version || downloadedUpdateVersion;
refreshTrayMenu();
publishDesktopUpdateStatus();
await writeDiagnostic("update.held-for-instance", { version: heldUpdateVersion });
};
const releaseHeldAutoRestartUpdate = async () => {
if (!heldUpdateVersion || linuxUpdateTestMode) return false;
const instanceVersion = await readInstanceReleaseVersion();
if (shouldHoldAutoRestartUpdate(heldUpdateVersion, instanceVersion)) return false;
const version = heldUpdateVersion === "unknown" ? downloadedUpdateVersion : heldUpdateVersion;
heldUpdateVersion = null;
if (process.platform !== "win32" || windowsDownloadedUpdateVerified) {
autoUpdater.autoInstallOnAppQuit = true;
}
updateState = "downloaded";
downloadedUpdateVersion = version || downloadedUpdateVersion;
refreshTrayMenu();
publishDesktopUpdateStatus();
await writeDiagnostic("update.released-for-instance", { version: downloadedUpdateVersion });
await promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => {
promptedUpdateVersion = null;
void writeDiagnostic("update.prompt-failed", { message: error.message });
});
return true;
};
const installDownloadedUpdate = () => {
if (
updateState !== "downloaded" ||
heldUpdateVersion ||
(process.platform === "win32" && !windowsDownloadedUpdateVerified)
) return { started: false };
// The normal window close handler hides the app. Mark this as a real quit
@@ -926,11 +881,8 @@ const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } =
if (!force && now - lastUpdateCheckAt < updateCheckFocusThrottleMs) return Promise.resolve(null);
lastUpdateCheckAt = now;
void writeDiagnostic("update.check-started", { reason });
updateCheckInFlight = Promise.resolve()
.then(() => releaseHeldAutoRestartUpdate())
.then(async (released) => {
if (released || updateState === "downloaded") return null;
const result = await autoUpdater.checkForUpdates();
updateCheckInFlight = autoUpdater.checkForUpdates()
.then(async (result) => {
if (process.platform === "win32" && result?.isUpdateAvailable) {
trustedWindowsUpdate = await fetchTrustedWindowsUpdate({
version: result.updateInfo.version,
@@ -950,14 +902,12 @@ const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } =
return result;
})
.catch(async (error) => {
if (!heldUpdateVersion) {
updateState = "idle";
downloadedUpdateVersion = null;
trustedWindowsUpdate = null;
windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
publishDesktopUpdateStatus();
}
updateState = "idle";
downloadedUpdateVersion = null;
trustedWindowsUpdate = null;
windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
publishDesktopUpdateStatus();
await writeDiagnostic("update.check-failed", { reason, message: error.message });
throw error;
})
@@ -995,7 +945,6 @@ const configureAutoUpdater = () => {
autoUpdater.on("update-not-available", () => {
updateState = "idle";
downloadedUpdateVersion = null;
heldUpdateVersion = null;
trustedWindowsUpdate = null;
windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
@@ -1019,16 +968,8 @@ const configureAutoUpdater = () => {
windowsDownloadedUpdateVerified = true;
autoUpdater.autoInstallOnAppQuit = true;
}
downloadedUpdateVersion = info?.version || downloadedUpdateVersion;
if (!linuxUpdateTestMode) {
const instanceVersion = await readInstanceReleaseVersion();
if (shouldHoldAutoRestartUpdate(downloadedUpdateVersion, instanceVersion)) {
await holdAutoRestartUpdate(downloadedUpdateVersion);
return;
}
}
updateState = "downloaded";
heldUpdateVersion = null;
downloadedUpdateVersion = info?.version || downloadedUpdateVersion;
refreshTrayMenu();
publishDesktopUpdateStatus();
await writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion });
@@ -1,25 +0,0 @@
import { describe, expect, test } from "bun:test";
import { instanceReleaseVersionFromPayload, shouldHoldAutoRestartUpdate } from "./instance-update-gate.mjs";
describe("desktop auto-restart update gate", () => {
test("holds only when the GitHub release is newer than the connected instance", () => {
expect(shouldHoldAutoRestartUpdate("1.64.0", "1.63.2")).toBe(true);
expect(shouldHoldAutoRestartUpdate("v1.64.0", "v1.64.0")).toBe(false);
expect(shouldHoldAutoRestartUpdate("1.63.2", "1.64.0")).toBe(false);
expect(shouldHoldAutoRestartUpdate("1.64.0+12", "1.64.0")).toBe(false);
});
test("does not hold when the instance version is unknown", () => {
expect(shouldHoldAutoRestartUpdate("1.64.0", null)).toBe(false);
expect(shouldHoldAutoRestartUpdate("1.64.0", "")).toBe(false);
expect(shouldHoldAutoRestartUpdate("1.64.0", "local")).toBe(false);
expect(shouldHoldAutoRestartUpdate("", "1.63.2")).toBe(false);
});
test("reads the public instance release payload", () => {
expect(instanceReleaseVersionFromPayload({ version: "1.63.2+4" })).toBe("1.63.2+4");
expect(instanceReleaseVersionFromPayload({ version: " v1.64.0 " })).toBe("v1.64.0");
expect(instanceReleaseVersionFromPayload({})).toBeNull();
expect(instanceReleaseVersionFromPayload(null)).toBeNull();
});
});
@@ -462,7 +462,8 @@ struct SettingsView: View {
title: env.preferences.t("云端实例", en: "Cloud instance"),
description: env.preferences.t("当前连接实例的版本与部署环境。", en: "Version and deployment environment for the connected instance."),
icon: "cloud",
items: cloudSystemInfoItems
items: cloudSystemInfoItems,
notice: clientAheadOfInstanceNotice
)
systemInfoGroup(
@@ -516,6 +517,36 @@ struct SettingsView: View {
}
}
private var clientAheadOfInstanceNotice: String? {
let clientVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
guard let clientVersion, let instanceVersion, Self.isClient(clientVersion, aheadOfInstance: instanceVersion) else {
return nil
}
return env.preferences.t(
"当前客户端版本高于云端实例,部分功能可能暂不可用。可等待每天自动更新,也可手动更新实例。",
en: "This client is newer than the connected cloud instance, so some features may not work yet. You can wait for the daily automatic instance update, or update the instance manually."
)
}
private static func isClient(_ clientVersion: String, aheadOfInstance instanceVersion: String) -> Bool {
func core(_ value: String) -> [Int]? {
let pattern = #"^v?(\d+)\.(\d+)\.(\d+)"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return nil }
let range = NSRange(value.startIndex..., in: value)
guard let match = regex.firstMatch(in: value, range: range) else { return nil }
let numbers = (1...3).compactMap { index -> Int? in
guard let part = Range(match.range(at: index), in: value) else { return nil }
return Int(value[part])
}
return numbers.count == 3 ? numbers : nil
}
guard let client = core(clientVersion), let instance = core(instanceVersion) else { return false }
for index in 0..<3 where client[index] != instance[index] {
return client[index] > instance[index]
}
return false
}
private var clientSystemInfoItems: [(label: String, value: String)] {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "—"
@@ -720,7 +751,8 @@ struct SettingsView: View {
title: String,
description: String,
icon: String,
items: [(label: String, value: String)]
items: [(label: String, value: String)],
notice: String? = nil
) -> some View {
settingsGroup(title: title, icon: icon) {
Text(description)
@@ -731,6 +763,23 @@ struct SettingsView: View {
.padding(.horizontal, 16)
.padding(.bottom, 12)
if let notice {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 12, weight: .semibold))
Text(notice)
.font(.system(size: 12))
.fixedSize(horizontal: false, vertical: true)
}
.foregroundStyle(AppTheme.warningText)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.warningSurface, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
infoRow(item.label, item.value, showBorder: true)
}
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState, type ComponentRef, type ReactNode } from "
import * as Clipboard from "expo-clipboard";
import Constants from "expo-constants";
import type { InstanceHealth } from "@edgeever/client";
import { buildGitHubFeedbackUrl, type AuthUser } from "@edgeever/shared";
import { buildGitHubFeedbackUrl, isClientAheadOfInstance, type AuthUser } from "@edgeever/shared";
import { useQuery } from "@tanstack/react-query";
import { BackHandler, Linking, Modal, Platform, ScrollView, Switch, View } from "react-native";
import { Activity, ActivityIndicator, Check, ChevronDown, ChevronLeft, ChevronRight, Cloud, Copy, ExternalLink, Image as ImageIcon, Info, LogOut, MessageSquare, MonitorSmartphone, Moon, RefreshCw, ShieldCheck, SlidersHorizontal, Sun, UserRound } from "../components/icons";
import { Activity, ActivityIndicator, AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, Cloud, Copy, ExternalLink, Image as ImageIcon, Info, LogOut, MessageSquare, MonitorSmartphone, Moon, RefreshCw, ShieldCheck, SlidersHorizontal, Sun, UserRound } from "../components/icons";
import { Pressable, Text } from "../components/LocalizedText";
import { useMobileLocale } from "../lib/mobile-locale";
import { useMobileTheme } from "../lib/mobile-theme";
@@ -53,6 +53,7 @@ type MobileSystemInfoGroup = {
description: string;
id: "cloud" | "client" | "connection";
items: Array<{ label: string; value: string }>;
notice?: string;
title: string;
};
@@ -448,6 +449,12 @@ const MobileSystemInfoSection = ({ group }: { group: MobileSystemInfoGroup }) =>
<Text style={styles.systemInfoSectionDescription}>{group.description}</Text>
</View>
</View>
{group.notice ? (
<View accessibilityLiveRegion="polite" style={styles.systemInfoNotice}>
<AlertTriangle color="#d97706" size={14} />
<Text style={styles.systemInfoNoticeText}>{group.notice}</Text>
</View>
) : null}
<View style={styles.systemInfoRows}>
{Array.from({ length: Math.ceil(group.items.length / 3) }, (_, rowIndex) => {
const rowItems = group.items.slice(rowIndex * 3, rowIndex * 3 + 3);
@@ -479,6 +486,7 @@ const getMobileSystemInfoText = (localePreference: MobileLocaleMode) =>
? {
build: "Build",
client: "Client",
clientAheadOfInstance: "This client is newer than the connected cloud instance, so some features may not work yet. You can wait for the daily automatic instance update, or update the instance manually.",
clientDescription: "The EdgeEver app and runtime environment on this device.",
clientSection: "Current client",
cloudDescription: "Version and deployment environment for the connected instance.",
@@ -519,6 +527,7 @@ const getMobileSystemInfoText = (localePreference: MobileLocaleMode) =>
: {
build: "构建",
client: "客户端",
clientAheadOfInstance: "当前客户端版本高于云端实例,部分功能可能暂不可用。可等待每天自动更新,也可手动更新实例。",
clientDescription: "这台设备上的 EdgeEver 应用与运行环境。",
clientSection: "当前客户端",
cloudDescription: "当前连接实例的版本与部署环境。",
@@ -628,6 +637,7 @@ const getMobileSystemInfoGroups = (
{
description: copy.cloudDescription,
id: "cloud",
notice: isClientAheadOfInstance(MOBILE_APP_VERSION, instance?.version) ? copy.clientAheadOfInstance : undefined,
items: [
{ label: copy.instanceVersion, value: instance?.version ? `v${instance.version.replace(/^v/, "")}` : copy.unknown },
{ label: copy.instanceBuild, value: instance?.health.build || copy.unknown },
@@ -1077,6 +1077,23 @@ const baseWorkspaceStyles = StyleSheet.create({
fontSize: 11,
lineHeight: 16,
},
systemInfoNotice: {
alignItems: "flex-start",
backgroundColor: "#fff7ed",
borderColor: "#fed7aa",
borderRadius: 8,
borderWidth: 1,
flexDirection: "row",
gap: 8,
paddingHorizontal: 10,
paddingVertical: 8,
},
systemInfoNoticeText: {
color: "#9a3412",
flex: 1,
fontSize: 11,
lineHeight: 16,
},
systemInfoRow: {
borderBottomColor: "#e2e8f0",
borderBottomWidth: 1,
@@ -0,0 +1,14 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
const source = readFileSync(new URL("./SystemInfoPanel.tsx", import.meta.url), "utf8");
describe("system information client/instance version hint", () => {
test("warns in system info when the client is ahead of the connected instance", () => {
expect(source).toContain("isClientAheadOfInstance");
expect(source).toContain("clientRuntimeQuery.data?.appVersion ?? __EDGEEVER_APP_VERSION__");
expect(source).toContain("release?.version");
expect(source).toContain('t("systemInfo.clientAheadOfInstance")');
expect(source).toContain("clientAheadOfInstance");
});
});
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DeploymentMetadata } from "@edgeever/shared/deployment-metadata";
import { Activity, CircleCheck, Cloud, Copy, ExternalLink, LoaderCircle, MonitorSmartphone, RefreshCw, RotateCcw } from "lucide-react";
import { Activity, CircleCheck, Cloud, Copy, ExternalLink, LoaderCircle, MonitorSmartphone, RefreshCw, RotateCcw, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useDeployedUpdateNotice } from "@/hooks/useDeployedUpdateNotice";
@@ -16,7 +16,7 @@ import {
type ClientSyncDiagnostics,
} from "@/lib/system-diagnostics";
import { cn } from "@/lib/utils";
import { getReleaseTagForVersion } from "@/lib/version-check";
import { getReleaseTagForVersion, isClientAheadOfInstance } from "@/lib/version-check";
import { copyTextToClipboard } from "./settings-utils";
export type SystemInfoItem = {
@@ -337,6 +337,10 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => {
window.setTimeout(() => setCopied(false), 1600);
};
const clientAheadOfInstance = isClientAheadOfInstance(
clientRuntimeQuery.data?.appVersion ?? __EDGEEVER_APP_VERSION__,
release?.version,
);
const desktopUpdateState = desktopUpdateStatusQuery.data?.state ?? "idle";
const desktopAutoUpdateSupported = clientRuntimeQuery.data?.autoUpdateSupported !== false;
const desktopUpdateBusy = desktopUpdateCheckMutation.isPending || desktopUpdateInstallMutation.isPending;
@@ -430,7 +434,12 @@ export const SystemInfoPanel = ({ active = true }: { active?: boolean }) => {
</Button>
) : null}
</div>
{isCloud && active && release ? (
{isCloud && active && clientAheadOfInstance ? (
<p className="flex items-start gap-2 rounded-lg border border-amber-200/80 bg-amber-50/70 px-3 py-1.5 text-xs leading-5 text-amber-900" role="status">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600" />
<span>{t("systemInfo.clientAheadOfInstance")}</span>
</p>
) : isCloud && active && release ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-emerald-200/80 bg-emerald-50/70 px-3 py-1.5 text-slate-800" role="status">
<CircleCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600" />
<div className="min-w-0 flex-1 text-xs font-medium text-emerald-950">
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { findDesktopReleaseVersion, getReleaseTagForVersion, isVersionOutdated, resolveLocalizedReleaseChanges } from "./version-check";
import { findDesktopReleaseVersion, getReleaseTagForVersion, isClientAheadOfInstance, isVersionOutdated, resolveLocalizedReleaseChanges } from "./version-check";
describe("platform release version checks", () => {
test("derives the installed desktop version from the DMG asset", () => {
@@ -31,6 +31,13 @@ describe("platform release version checks", () => {
expect(isVersionOutdated("1.6.50", "1.6.50-beta.10")).toBe(false);
});
test("treats the client as ahead only when it is newer than a known instance", () => {
expect(isClientAheadOfInstance("1.66.0", "1.65.1")).toBe(true);
expect(isClientAheadOfInstance("1.65.1+4", "1.65.1")).toBe(false);
expect(isClientAheadOfInstance("1.66.0", null)).toBe(false);
expect(isClientAheadOfInstance("1.66.0", "local")).toBe(false);
});
test("maps deployed builds back to their formal release tag", () => {
expect(getReleaseTagForVersion("1.34.1+18")).toBe("v1.34.1");
expect(getReleaseTagForVersion("v1.35.0-beta.2+4")).toBe("v1.35.0-beta.2");
+1 -34
View File
@@ -16,13 +16,7 @@ export const findDesktopReleaseVersion = (assetNames: string[]) => {
: null;
};
const parseVersion = (value: string) => {
const match = value.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/i);
return match ? {
core: match.slice(1, 4).map(Number),
prerelease: match[4]?.split(".") ?? null,
} : null;
};
export { isClientAheadOfInstance, isVersionOutdated } from "@edgeever/shared";
export const getReleaseTagForVersion = (version: string) => {
const match = version.match(/^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+[0-9A-Za-z.-]+)?$/i);
@@ -49,30 +43,3 @@ export const resolveLocalizedReleaseChanges = (
const fallbackLocale = locales.find((locale) => normalizeLocale(locale) === normalizedFallback);
return fallbackLocale ? changes[fallbackLocale] : [];
};
export const isVersionOutdated = (currentVersion: string, latestVersion: string) => {
const current = parseVersion(currentVersion);
const latest = parseVersion(latestVersion);
if (!current || !latest) return false;
for (let index = 0; index < 3; index += 1) {
if (current.core[index] !== latest.core[index]) return current.core[index] < latest.core[index];
}
if (!current.prerelease) return false;
if (!latest.prerelease) return true;
const length = Math.max(current.prerelease.length, latest.prerelease.length);
for (let index = 0; index < length; index += 1) {
const currentPart = current.prerelease[index];
const latestPart = latest.prerelease[index];
if (currentPart === undefined) return true;
if (latestPart === undefined) return false;
if (currentPart === latestPart) continue;
const currentNumber = /^\d+$/.test(currentPart) ? Number(currentPart) : null;
const latestNumber = /^\d+$/.test(latestPart) ? Number(latestPart) : null;
if (currentNumber !== null && latestNumber !== null) return currentNumber < latestNumber;
if (currentNumber !== null) return true;
if (latestNumber !== null) return false;
return currentPart.localeCompare(latestPart) < 0;
}
return false;
};
+1 -1
View File
@@ -63,7 +63,7 @@
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/instance-update-gate.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/app-protocol.test.mjs apps/desktop/src/main/child-process-start.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/desktop-menu.test.mjs apps/desktop/src/main/desktop-update-flow.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/plugin-public-network.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/renderer-startup-guard.test.mjs apps/desktop/src/main/renderer-storage-migration.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/scheduled-task-scheduler.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/desktop/src/main/windows-update-trust.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-renderer-ready.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration",
"verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs",
+1
View File
@@ -1137,6 +1137,7 @@ export const enUS = {
browserMode: "Browser",
unknown: "Unknown",
copy: "Copy info",
clientAheadOfInstance: "This client is newer than the connected cloud instance, so some features may not work yet. You can wait for the daily automatic instance update, or update the instance manually.",
deployedUpdateTitle: "Your cloud instance was automatically updated to v{{version}}",
viewReleaseNotes: "Release notes",
desktopCheckForUpdates: "Check for updates",
+1
View File
@@ -1137,6 +1137,7 @@ export const zhCN = {
browserMode: "浏览器",
unknown: "未知",
copy: "复制信息",
clientAheadOfInstance: "当前客户端版本高于云端实例,部分功能可能暂不可用。可等待每天自动更新,也可手动更新实例。",
deployedUpdateTitle: "您的云端实例已自动更新至 v{{version}}",
viewReleaseNotes: "版本说明",
desktopCheckForUpdates: "检查更新",
+1
View File
@@ -39,6 +39,7 @@ export * from "./file-attachment";
export * from "./note-image-card";
export * from "./pdf-attachment";
export * from "./types";
export * from "./version";
export * from './plugin-capabilities';
export {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { isClientAheadOfInstance, isVersionOutdated } from "./version";
describe("release version comparison", () => {
test("compares semantic release components and ignores build metadata", () => {
expect(isVersionOutdated("1.6.49+12", "1.6.50")).toBe(true);
expect(isVersionOutdated("1.6.50+3", "1.6.50")).toBe(false);
expect(isVersionOutdated("1.6.50-beta.2", "1.6.50-beta.10")).toBe(true);
expect(isVersionOutdated("1.6.50-beta.10", "1.6.50")).toBe(true);
expect(isVersionOutdated("1.6.50", "1.6.50-beta.10")).toBe(false);
});
test("treats the client as ahead only when it is newer than a known instance", () => {
expect(isClientAheadOfInstance("1.66.0", "1.65.1")).toBe(true);
expect(isClientAheadOfInstance("v1.66.0", "v1.65.1")).toBe(true);
expect(isClientAheadOfInstance("1.65.1+4", "1.65.1")).toBe(false);
expect(isClientAheadOfInstance("1.65.1", "1.65.1")).toBe(false);
expect(isClientAheadOfInstance("1.64.0", "1.65.1")).toBe(false);
expect(isClientAheadOfInstance("1.66.0", null)).toBe(false);
expect(isClientAheadOfInstance("1.66.0", "")).toBe(false);
expect(isClientAheadOfInstance("1.66.0", "local")).toBe(false);
expect(isClientAheadOfInstance(null, "1.65.1")).toBe(false);
});
});
@@ -1,14 +1,16 @@
const parseVersion = (value) => {
const match = String(value ?? "").match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/i);
return match
? { core: match.slice(1, 4).map(Number), prerelease: match[4]?.split(".") ?? null }
: null;
const parseVersion = (value: string) => {
const match = value.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/i);
return match ? {
core: match.slice(1, 4).map(Number),
prerelease: match[4]?.split(".") ?? null,
} : null;
};
const isVersionOutdated = (currentVersion, latestVersion) => {
export const isVersionOutdated = (currentVersion: string, latestVersion: string) => {
const current = parseVersion(currentVersion);
const latest = parseVersion(latestVersion);
if (!current || !latest) return false;
for (let index = 0; index < 3; index += 1) {
if (current.core[index] !== latest.core[index]) return current.core[index] < latest.core[index];
}
@@ -31,12 +33,7 @@ const isVersionOutdated = (currentVersion, latestVersion) => {
return false;
};
export const instanceReleaseVersionFromPayload = (payload) => (
payload && typeof payload.version === "string" && payload.version.trim()
? payload.version.trim()
: null
);
export const shouldHoldAutoRestartUpdate = (updateVersion, instanceVersion) => (
Boolean(updateVersion && instanceVersion && isVersionOutdated(instanceVersion, updateVersion))
);
export const isClientAheadOfInstance = (
clientVersion: string | null | undefined,
instanceVersion: string | null | undefined,
) => Boolean(clientVersion && instanceVersion && isVersionOutdated(instanceVersion, clientVersion));