| 1 | import type { |
| 2 | EnvironmentId, |
| 3 | ServerConfig, |
| 4 | ServerInstallation, |
| 5 | ServerSelfUpdateCapability, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 8 | import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | |
| 11 | import { APP_VERSION } from "./branding"; |
| 12 | import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; |
| 13 | |
| 14 | export interface VersionMismatch { |
| 15 | readonly clientVersion: string; |
| 16 | readonly serverVersion: string; |
| 17 | readonly hint: string; |
| 18 | } |
| 19 | |
| 20 | const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; |
| 21 | |
| 22 | // Runtime failures retain their identity until the next attempt. Dismiss only |
| 23 | // that attempt, across chat remounts, without clearing the error in Settings. |
| 24 | const dismissedServerUpdateFailures = new WeakSet<ServerUpdateState>(); |
| 25 | |
| 26 | export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean { |
| 27 | return state.status === "failed" && dismissedServerUpdateFailures.has(state); |
| 28 | } |
| 29 | |
| 30 | export function dismissServerUpdateFailure(state: ServerUpdateState): void { |
| 31 | if (state.status === "failed") dismissedServerUpdateFailures.add(state); |
| 32 | } |
| 33 | |
| 34 | const VersionMismatchDismissalsSchema = Schema.Struct({ |
| 35 | keys: Schema.Array(Schema.String), |
| 36 | }); |
| 37 | |
| 38 | type VersionMismatchDismissals = typeof VersionMismatchDismissalsSchema.Type; |
| 39 | |
| 40 | function normalizeVersion(version: string | null | undefined): string | null { |
| 41 | const trimmed = version?.trim(); |
| 42 | return trimmed && trimmed.length > 0 ? trimmed : null; |
| 43 | } |
| 44 | |
| 45 | /** Core `major.minor.patch`, dropping any prerelease or build suffix. */ |
| 46 | function versionCore(version: string): string { |
| 47 | return version.replace(/[-+].*$/, ""); |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * The skew a user can act on: the connected server runs an older T3 Code than |
| 52 | * this client (or than `clientVersion`, the version this client is about to |
| 53 | * update to), so the server is the side that needs updating. |
| 54 | * |
| 55 | * Two nightly builds compare their full versions, including the date and run. |
| 56 | * Other combinations compare their core `major.minor.patch` only, so a stable |
| 57 | * build and a nightly build with the same core do not cause an update warning. |
| 58 | * A server ahead of the client does not need an update. Versions that do not |
| 59 | * parse as semver fall back to plain string inequality. |
| 60 | */ |
| 61 | export function resolveVersionMismatch( |
| 62 | serverVersion: string | null | undefined, |
| 63 | clientVersion: string = APP_VERSION, |
| 64 | ): VersionMismatch | null { |
| 65 | const normalizedClientVersion = normalizeVersion(clientVersion); |
| 66 | const normalizedServerVersion = normalizeVersion(serverVersion); |
| 67 | if (!normalizedClientVersion || !normalizedServerVersion) { |
| 68 | return null; |
| 69 | } |
| 70 | |
| 71 | const clientCore = versionCore(normalizedClientVersion); |
| 72 | const serverCore = versionCore(normalizedServerVersion); |
| 73 | const compareNightlyBuilds = |
| 74 | parseSemver(normalizedClientVersion)?.prerelease[0] === "nightly" && |
| 75 | parseSemver(normalizedServerVersion)?.prerelease[0] === "nightly"; |
| 76 | const serverIsBehind = |
| 77 | parseSemver(clientCore) && parseSemver(serverCore) |
| 78 | ? compareSemverVersions( |
| 79 | compareNightlyBuilds ? normalizedServerVersion : serverCore, |
| 80 | compareNightlyBuilds ? normalizedClientVersion : clientCore, |
| 81 | ) < 0 |
| 82 | : normalizedServerVersion !== normalizedClientVersion; |
| 83 | if (!serverIsBehind) { |
| 84 | return null; |
| 85 | } |
| 86 | |
| 87 | return { |
| 88 | clientVersion: normalizedClientVersion, |
| 89 | serverVersion: normalizedServerVersion, |
| 90 | hint: "Version mismatch. Try syncing the client and server to the same version of The Knife.", |
| 91 | }; |
| 92 | } |
| 93 | |
| 94 | export function resolveServerConfigVersionMismatch( |
| 95 | serverConfig: Pick<ServerConfig, "environment"> | null | undefined, |
| 96 | ): VersionMismatch | null { |
| 97 | return resolveVersionMismatch(serverConfig?.environment.serverVersion); |
| 98 | } |
| 99 | |
| 100 | /** The update path the connected server offers, or null when it only |
| 101 | supports a manual relaunch (older servers, dev checkouts, Windows). */ |
| 102 | export function resolveServerSelfUpdateCapability( |
| 103 | serverConfig: Pick<ServerConfig, "environment"> | null | undefined, |
| 104 | ): ServerSelfUpdateCapability | null { |
| 105 | return serverConfig?.environment.capabilities.serverSelfUpdate ?? null; |
| 106 | } |
| 107 | |
| 108 | /** True when the desktop app supervising this server can be told to update |
| 109 | itself over RPC. Older desktop servers only get the manual instruction. */ |
| 110 | export function supportsDesktopAppUpdate( |
| 111 | serverConfig: Pick<ServerConfig, "environment"> | null | undefined, |
| 112 | ): boolean { |
| 113 | return serverConfig?.environment.capabilities.desktopAppUpdate === true; |
| 114 | } |
| 115 | |
| 116 | /** True when the connected server can recover opted-in running turns after |
| 117 | its self-update restart. */ |
| 118 | export function supportsServerUpdateThreadContinuation( |
| 119 | serverConfig: Pick<ServerConfig, "environment"> | null | undefined, |
| 120 | ): boolean { |
| 121 | return serverConfig?.environment.capabilities.serverUpdateThreadContinuation === true; |
| 122 | } |
| 123 | |
| 124 | /** The command to hand users whose server cannot update itself. */ |
| 125 | export function manualServerUpdateCommand( |
| 126 | targetVersion: string, |
| 127 | installation?: ServerInstallation, |
| 128 | ): string { |
| 129 | if (installation?.kind === "npm-global") { |
| 130 | const prefix = `'${installation.prefix.replaceAll("'", "'\\''")}'`; |
| 131 | return `npm install --global --prefix ${prefix} t3@${targetVersion}`; |
| 132 | } |
| 133 | const runner = |
| 134 | installation?.kind === "pnpm-dlx" ? "pnpm dlx" : installation?.kind === "bunx" ? "bunx" : "npx"; |
| 135 | return `${runner} t3@${targetVersion}`; |
| 136 | } |
| 137 | |
| 138 | export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { |
| 139 | return capability === "desktop-managed" ? "Update the desktop app" : "Update to stay in sync"; |
| 140 | } |
| 141 | |
| 142 | export function buildVersionMismatchDismissalKey( |
| 143 | environmentId: EnvironmentId, |
| 144 | mismatch: Pick<VersionMismatch, "clientVersion" | "serverVersion">, |
| 145 | ): string { |
| 146 | return `${environmentId}:${mismatch.clientVersion}:${mismatch.serverVersion}`; |
| 147 | } |
| 148 | |
| 149 | function readVersionMismatchDismissals(): VersionMismatchDismissals { |
| 150 | try { |
| 151 | return ( |
| 152 | getLocalStorageItem( |
| 153 | VERSION_MISMATCH_DISMISSALS_STORAGE_KEY, |
| 154 | VersionMismatchDismissalsSchema, |
| 155 | ) ?? { keys: [] } |
| 156 | ); |
| 157 | } catch (error) { |
| 158 | console.error("Could not read version-mismatch dismissals.", error); |
| 159 | return { keys: [] }; |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | function writeVersionMismatchDismissals(document: VersionMismatchDismissals): void { |
| 164 | try { |
| 165 | setLocalStorageItem( |
| 166 | VERSION_MISMATCH_DISMISSALS_STORAGE_KEY, |
| 167 | document, |
| 168 | VersionMismatchDismissalsSchema, |
| 169 | ); |
| 170 | } catch (error) { |
| 171 | console.error("Could not persist version-mismatch dismissals.", error); |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | export function isVersionMismatchDismissed(dismissalKey: string | null | undefined): boolean { |
| 176 | if (!dismissalKey) { |
| 177 | return false; |
| 178 | } |
| 179 | return readVersionMismatchDismissals().keys.includes(dismissalKey); |
| 180 | } |
| 181 | |
| 182 | export function dismissVersionMismatch(dismissalKey: string | null | undefined): void { |
| 183 | if (!dismissalKey) { |
| 184 | return; |
| 185 | } |
| 186 | const document = readVersionMismatchDismissals(); |
| 187 | if (document.keys.includes(dismissalKey)) { |
| 188 | return; |
| 189 | } |
| 190 | writeVersionMismatchDismissals({ |
| 191 | keys: [...document.keys, dismissalKey], |
| 192 | }); |
| 193 | } |
| 194 | |
| 195 | export function appendVersionMismatchHint( |
| 196 | message: string | null | undefined, |
| 197 | mismatch: VersionMismatch | null | undefined, |
| 198 | ): string | null { |
| 199 | const normalizedMessage = normalizeVersion(message); |
| 200 | if (!normalizedMessage) { |
| 201 | return mismatch?.hint ?? null; |
| 202 | } |
| 203 | if (!mismatch) { |
| 204 | return normalizedMessage; |
| 205 | } |
| 206 | return `${normalizedMessage} Hint: ${mismatch.hint}`; |
| 207 | } |