diff --git a/apps/app/src/app/lib/version-gate.ts b/apps/app/src/app/lib/version-gate.ts index bf6cc53c5..64d8b92d9 100644 --- a/apps/app/src/app/lib/version-gate.ts +++ b/apps/app/src/app/lib/version-gate.ts @@ -63,6 +63,10 @@ function comparePrereleaseIdentifiers(left: string[], right: string[]): number { return 0; } +function releasePart(value: string): number[] | null { + return parseComparableVersion(value)?.release ?? null; +} + /** * Compare two version strings. Returns -1 / 0 / 1 as usual, or null if * either side fails to parse. Accepts an optional leading `v` and handles @@ -103,17 +107,57 @@ export function isUpdateAllowedByDesktopConfig( ); } -/** - * Ask Den for the currently-supported latest app version (dev #1476) and - * return true only when the candidate update version is the latest - * version or older. If Den is unreachable or returns an invalid payload, - * this returns `false` — the caller must treat that as "do not surface - * the update". - * - * No-op safe: callers can invoke this without any Den auth; the client - * will omit the token when none is persisted. - */ -export async function isUpdateSupportedByDen(updateVersion: string): Promise { +function maxAllowedDesktopVersion(desktopConfig: DenDesktopConfig | null | undefined): string | null { + if (!Array.isArray(desktopConfig?.allowedDesktopVersions)) { + return null; + } + + let maxVersion: string | null = null; + for (const version of desktopConfig.allowedDesktopVersions) { + if (parseComparableVersion(version) === null) continue; + if (maxVersion === null) { + maxVersion = version; + continue; + } + const comparison = compareVersions(version, maxVersion); + if (comparison !== null && comparison > 0) { + maxVersion = version; + } + } + return maxVersion; +} + +function effectiveMaxDesktopVersion( + denLatestAppVersion: string, + desktopConfig: DenDesktopConfig | null | undefined, +): string { + const orgMaxVersion = maxAllowedDesktopVersion(desktopConfig); + if (!orgMaxVersion) return denLatestAppVersion; + const comparison = compareVersions(orgMaxVersion, denLatestAppVersion); + return comparison !== null && comparison < 0 ? orgMaxVersion : denLatestAppVersion; +} + +function isWithinOnePatchAhead(updateVersion: string, maxVersion: string): boolean { + const directComparison = compareVersions(updateVersion, maxVersion); + if (directComparison !== null && directComparison <= 0) { + return true; + } + + const updateRelease = releasePart(updateVersion); + const maxRelease = releasePart(maxVersion); + if (!updateRelease || !maxRelease) return false; + + const updateMajor = updateRelease[0] ?? 0; + const updateMinor = updateRelease[1] ?? 0; + const updatePatch = updateRelease[2] ?? 0; + const maxMajor = maxRelease[0] ?? 0; + const maxMinor = maxRelease[1] ?? 0; + const maxPatch = maxRelease[2] ?? 0; + + return updateMajor === maxMajor && updateMinor === maxMinor && updatePatch <= maxPatch + 1; +} + +async function readDenLatestAppVersion(): Promise { try { const settings = readDenSettings(); const token = settings.authToken?.trim() ?? ""; @@ -123,13 +167,44 @@ export async function isUpdateSupportedByDen(updateVersion: string): Promise { + const latestAppVersion = await readDenLatestAppVersion(); + if (!latestAppVersion) return false; + const comparison = compareVersions(updateVersion, latestAppVersion); + return comparison !== null && comparison <= 0; +} + +/** + * Alpha channel builds may run one patch ahead of the current Den/org maximum + * (e.g. Den allows 0.13.3, alpha 0.13.4-alpha.N is allowed). Larger jumps are + * still blocked so alpha cannot bypass staged rollout ceilings entirely. + */ +export async function isAlphaUpdateAllowed( + updateVersion: string, + desktopConfig: DenDesktopConfig | null | undefined, +): Promise { + const latestAppVersion = await readDenLatestAppVersion(); + if (!latestAppVersion) return false; + const effectiveMaxVersion = effectiveMaxDesktopVersion(latestAppVersion, desktopConfig); + return isWithinOnePatchAhead(updateVersion, effectiveMaxVersion); +} + /** * Combined gate: the update must be supported by Den (version metadata * endpoint) AND allowed by the active org's `allowedDesktopVersions` if diff --git a/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts b/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts index 8fa0e7564..fd34e55c1 100644 --- a/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts +++ b/apps/app/src/react-app/domains/settings/state/electron-updater-state.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { DenDesktopConfig } from "../../../../app/lib/den"; -import { isUpdateAllowed } from "../../../../app/lib/version-gate"; +import { isAlphaUpdateAllowed, isUpdateAllowed } from "../../../../app/lib/version-gate"; import type { ReleaseChannel } from "../../../../app/types"; import { isElectronRuntime, isTauriRuntime, safeStringify } from "../../../../app/utils"; @@ -138,7 +138,12 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) setUpdateStatus({ state: "idle", lastCheckedAt: Date.now() }); return; } - if (update.version && !(await isUpdateAllowed(update.version, desktopConfig))) { + const allowed = update.version + ? releaseChannel === "alpha" + ? await isAlphaUpdateAllowed(update.version, desktopConfig) + : await isUpdateAllowed(update.version, desktopConfig) + : true; + if (!allowed) { tauriUpdateRef.current = null; setUpdateStatus({ state: "idle", lastCheckedAt: Date.now() }); return; @@ -212,7 +217,7 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) } finally { unsubProgress?.(); } - }, [desktopConfig, setError]); + }, [desktopConfig, releaseChannel, setError]); const checkForUpdates = useCallback(async () => { if (isTauriRuntime()) { @@ -220,9 +225,11 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) try { const { check } = await import("@tauri-apps/plugin-updater"); const update = (await check()) as TauriUpdate | null; - const allowed = update?.version - ? await isUpdateAllowed(update.version, desktopConfig) - : true; + const allowed = !update?.version + ? true + : releaseChannel === "alpha" + ? await isAlphaUpdateAllowed(update.version, desktopConfig) + : await isUpdateAllowed(update.version, desktopConfig); if (!allowed) { tauriUpdateRef.current = null; setUpdateStatus({ state: "idle", lastCheckedAt: Date.now() }); @@ -276,7 +283,9 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions) } const availableAllowed = result.available && result.latestVersion - ? await isUpdateAllowed(result.latestVersion, desktopConfig) + ? releaseChannel === "alpha" + ? await isAlphaUpdateAllowed(result.latestVersion, desktopConfig) + : await isUpdateAllowed(result.latestVersion, desktopConfig) : result.available; const nextStatus: Exclude = availableAllowed ? {