import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts"; import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Schema from "effect/Schema"; import { APP_VERSION } from "./branding"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; export interface VersionMismatch { readonly clientVersion: string; readonly serverVersion: string; readonly hint: string; } const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; // Runtime failures retain their identity until the next attempt. Dismiss only // that attempt, across chat remounts, without clearing the error in Settings. const dismissedServerUpdateFailures = new WeakSet(); export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean { return state.status === "failed" && dismissedServerUpdateFailures.has(state); } export function dismissServerUpdateFailure(state: ServerUpdateState): void { if (state.status === "failed") dismissedServerUpdateFailures.add(state); } const VersionMismatchDismissalsSchema = Schema.Struct({ keys: Schema.Array(Schema.String), }); type VersionMismatchDismissals = typeof VersionMismatchDismissalsSchema.Type; function normalizeVersion(version: string | null | undefined): string | null { const trimmed = version?.trim(); return trimmed && trimmed.length > 0 ? trimmed : null; } /** Core `major.minor.patch`, dropping any prerelease or build suffix. */ function versionCore(version: string): string { return version.replace(/[-+].*$/, ""); } /** * The skew a user can act on: the connected server runs an older T3 Code than * this client, so the server is the side that needs updating. * * Two nightly builds compare their full versions, including the date and run. * Other combinations compare their core `major.minor.patch` only, so a stable * build and a nightly build with the same core do not cause an update warning. * A server ahead of the client does not need an update. Versions that do not * parse as semver fall back to plain string inequality. */ export function resolveVersionMismatch( serverVersion: string | null | undefined, ): VersionMismatch | null { const normalizedClientVersion = normalizeVersion(APP_VERSION); const normalizedServerVersion = normalizeVersion(serverVersion); if (!normalizedClientVersion || !normalizedServerVersion) { return null; } const clientCore = versionCore(normalizedClientVersion); const serverCore = versionCore(normalizedServerVersion); const compareNightlyBuilds = parseSemver(normalizedClientVersion)?.prerelease[0] === "nightly" && parseSemver(normalizedServerVersion)?.prerelease[0] === "nightly"; const serverIsBehind = parseSemver(clientCore) && parseSemver(serverCore) ? compareSemverVersions( compareNightlyBuilds ? normalizedServerVersion : serverCore, compareNightlyBuilds ? normalizedClientVersion : clientCore, ) < 0 : normalizedServerVersion !== normalizedClientVersion; if (!serverIsBehind) { return null; } return { clientVersion: normalizedClientVersion, serverVersion: normalizedServerVersion, hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.", }; } export function resolveServerConfigVersionMismatch( serverConfig: Pick | null | undefined, ): VersionMismatch | null { return resolveVersionMismatch(serverConfig?.environment.serverVersion); } /** The update path the connected server offers, or null when it only supports a manual relaunch (older servers, dev checkouts, Windows). */ export function resolveServerSelfUpdateCapability( serverConfig: Pick | null | undefined, ): ServerSelfUpdateCapability | null { return serverConfig?.environment.capabilities.serverSelfUpdate ?? null; } /** True when the desktop app supervising this server can be told to update itself over RPC. Older desktop servers only get the manual instruction. */ export function supportsDesktopAppUpdate( serverConfig: Pick | null | undefined, ): boolean { return serverConfig?.environment.capabilities.desktopAppUpdate === true; } /** True when the connected server can recover opted-in running turns after its self-update restart. */ export function supportsServerUpdateThreadContinuation( serverConfig: Pick | null | undefined, ): boolean { return serverConfig?.environment.capabilities.serverUpdateThreadContinuation === true; } /** The command to hand users whose server cannot update itself. */ export function manualServerUpdateCommand(targetVersion: string): string { return `npx t3@${targetVersion}`; } export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { return capability === "desktop-managed" ? "Update the desktop app" : "Update to stay in sync"; } export function buildVersionMismatchDismissalKey( environmentId: EnvironmentId, mismatch: Pick, ): string { return `${environmentId}:${mismatch.clientVersion}:${mismatch.serverVersion}`; } function readVersionMismatchDismissals(): VersionMismatchDismissals { try { return ( getLocalStorageItem( VERSION_MISMATCH_DISMISSALS_STORAGE_KEY, VersionMismatchDismissalsSchema, ) ?? { keys: [] } ); } catch (error) { console.error("Could not read version-mismatch dismissals.", error); return { keys: [] }; } } function writeVersionMismatchDismissals(document: VersionMismatchDismissals): void { try { setLocalStorageItem( VERSION_MISMATCH_DISMISSALS_STORAGE_KEY, document, VersionMismatchDismissalsSchema, ); } catch (error) { console.error("Could not persist version-mismatch dismissals.", error); } } export function isVersionMismatchDismissed(dismissalKey: string | null | undefined): boolean { if (!dismissalKey) { return false; } return readVersionMismatchDismissals().keys.includes(dismissalKey); } export function dismissVersionMismatch(dismissalKey: string | null | undefined): void { if (!dismissalKey) { return; } const document = readVersionMismatchDismissals(); if (document.keys.includes(dismissalKey)) { return; } writeVersionMismatchDismissals({ keys: [...document.keys, dismissalKey], }); }