Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 89 additions & 14 deletions apps/app/src/app/lib/version-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean> {
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<string | null> {
try {
const settings = readDenSettings();
const token = settings.authToken?.trim() ?? "";
Expand All @@ -123,13 +167,44 @@ export async function isUpdateSupportedByDen(updateVersion: string): Promise<boo
...(token ? { token } : {}),
});
const metadata = await client.getAppVersionMetadata();
const comparison = compareVersions(updateVersion, metadata.latestAppVersion);
return comparison !== null && comparison <= 0;
return metadata.latestAppVersion;
} catch {
return false;
return null;
}
}

/**
* 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<boolean> {
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<boolean> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -212,17 +217,19 @@ export function useElectronUpdaterState(options: UseElectronUpdaterStateOptions)
} finally {
unsubProgress?.();
}
}, [desktopConfig, setError]);
}, [desktopConfig, releaseChannel, setError]);

const checkForUpdates = useCallback(async () => {
if (isTauriRuntime()) {
setUpdateStatus({ state: "checking" });
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() });
Expand Down Expand Up @@ -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<SettingsUpdateStatus, null> = availableAllowed
? {
Expand Down
Loading