From e8ed6b1019d34de9b91c7d0d7bc3e86508fac51d Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 11 Sep 2026 10:36:36 +0800 Subject: [PATCH] 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. --- .../src/main/desktop-update-flow.test.mjs | 16 ++-- apps/desktop/src/main/index.mjs | 77 +++---------------- .../src/main/instance-update-gate.test.mjs | 25 ------ .../Features/Workspace/SettingsView.swift | 53 ++++++++++++- .../src/screens/WorkspaceSettingsView.tsx | 14 +++- apps/mobile/src/screens/workspace-styles.ts | 17 ++++ .../settings/SystemInfoPanel.test.mjs | 14 ++++ .../components/settings/SystemInfoPanel.tsx | 15 +++- apps/web/src/lib/version-check.test.mjs | 9 ++- apps/web/src/lib/version-check.ts | 35 +-------- package.json | 2 +- packages/shared/src/i18n/en-US.ts | 1 + packages/shared/src/i18n/zh-CN.ts | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/version.test.mjs | 24 ++++++ .../shared/src/version.ts | 27 +++---- 16 files changed, 172 insertions(+), 159 deletions(-) delete mode 100644 apps/desktop/src/main/instance-update-gate.test.mjs create mode 100644 apps/web/src/components/settings/SystemInfoPanel.test.mjs create mode 100644 packages/shared/src/version.test.mjs rename apps/desktop/src/main/instance-update-gate.mjs => packages/shared/src/version.ts (62%) diff --git a/apps/desktop/src/main/desktop-update-flow.test.mjs b/apps/desktop/src/main/desktop-update-flow.test.mjs index 2bd26f65..220e0305 100644 --- a/apps/desktop/src/main/desktop-update-flow.test.mjs +++ b/apps/desktop/src/main/desktop-update-flow.test.mjs @@ -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"); }); }); diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 4f0782d9..4856d074 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -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 }); diff --git a/apps/desktop/src/main/instance-update-gate.test.mjs b/apps/desktop/src/main/instance-update-gate.test.mjs deleted file mode 100644 index b73b270e..00000000 --- a/apps/desktop/src/main/instance-update-gate.test.mjs +++ /dev/null @@ -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(); - }); -}); diff --git a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift index c49e38d0..4a8f7bd9 100644 --- a/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/SettingsView.swift @@ -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) } diff --git a/apps/mobile/src/screens/WorkspaceSettingsView.tsx b/apps/mobile/src/screens/WorkspaceSettingsView.tsx index 5556de31..e2c2f375 100644 --- a/apps/mobile/src/screens/WorkspaceSettingsView.tsx +++ b/apps/mobile/src/screens/WorkspaceSettingsView.tsx @@ -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 }) => {group.description} + {group.notice ? ( + + + {group.notice} + + ) : null} {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 }, diff --git a/apps/mobile/src/screens/workspace-styles.ts b/apps/mobile/src/screens/workspace-styles.ts index 334b24aa..f38e678d 100644 --- a/apps/mobile/src/screens/workspace-styles.ts +++ b/apps/mobile/src/screens/workspace-styles.ts @@ -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, diff --git a/apps/web/src/components/settings/SystemInfoPanel.test.mjs b/apps/web/src/components/settings/SystemInfoPanel.test.mjs new file mode 100644 index 00000000..835979ba --- /dev/null +++ b/apps/web/src/components/settings/SystemInfoPanel.test.mjs @@ -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"); + }); +}); diff --git a/apps/web/src/components/settings/SystemInfoPanel.tsx b/apps/web/src/components/settings/SystemInfoPanel.tsx index 2ce1595f..f0c310e1 100644 --- a/apps/web/src/components/settings/SystemInfoPanel.tsx +++ b/apps/web/src/components/settings/SystemInfoPanel.tsx @@ -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 }) => { ) : null} - {isCloud && active && release ? ( + {isCloud && active && clientAheadOfInstance ? ( +

+ + {t("systemInfo.clientAheadOfInstance")} +

+ ) : isCloud && active && release ? (
diff --git a/apps/web/src/lib/version-check.test.mjs b/apps/web/src/lib/version-check.test.mjs index ccf13ade..86e41b4a 100644 --- a/apps/web/src/lib/version-check.test.mjs +++ b/apps/web/src/lib/version-check.test.mjs @@ -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"); diff --git a/apps/web/src/lib/version-check.ts b/apps/web/src/lib/version-check.ts index f3b66a1c..6f045fb0 100644 --- a/apps/web/src/lib/version-check.ts +++ b/apps/web/src/lib/version-check.ts @@ -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; -}; diff --git a/package.json b/package.json index 630478d1..8e894db6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 5cb865fa..8bea401b 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 07c28404..1da92358 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1137,6 +1137,7 @@ export const zhCN = { browserMode: "浏览器", unknown: "未知", copy: "复制信息", + clientAheadOfInstance: "当前客户端版本高于云端实例,部分功能可能暂不可用。可等待每天自动更新,也可手动更新实例。", deployedUpdateTitle: "您的云端实例已自动更新至 v{{version}}", viewReleaseNotes: "版本说明", desktopCheckForUpdates: "检查更新", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0b7748d9..bfe34993 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 { diff --git a/packages/shared/src/version.test.mjs b/packages/shared/src/version.test.mjs new file mode 100644 index 00000000..802cb47e --- /dev/null +++ b/packages/shared/src/version.test.mjs @@ -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); + }); +}); diff --git a/apps/desktop/src/main/instance-update-gate.mjs b/packages/shared/src/version.ts similarity index 62% rename from apps/desktop/src/main/instance-update-gate.mjs rename to packages/shared/src/version.ts index 1413ad86..8800cee2 100644 --- a/apps/desktop/src/main/instance-update-gate.mjs +++ b/packages/shared/src/version.ts @@ -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));