1import type {
2 EnvironmentId,
3 ServerConfig,
4 ServerInstallation,
5 ServerSelfUpdateCapability,
6} from "@t3tools/contracts";
7import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
8import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
9import * as Schema from "effect/Schema";
10
11import { APP_VERSION } from "./branding";
12import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
13
14export interface VersionMismatch {
15 readonly clientVersion: string;
16 readonly serverVersion: string;
17 readonly hint: string;
18}
19
20const 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.
24const dismissedServerUpdateFailures = new WeakSet<ServerUpdateState>();
25
26export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean {
27 return state.status === "failed" && dismissedServerUpdateFailures.has(state);
28}
29
30export function dismissServerUpdateFailure(state: ServerUpdateState): void {
31 if (state.status === "failed") dismissedServerUpdateFailures.add(state);
32}
33
34const VersionMismatchDismissalsSchema = Schema.Struct({
35 keys: Schema.Array(Schema.String),
36});
37
38type VersionMismatchDismissals = typeof VersionMismatchDismissalsSchema.Type;
39
40function 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. */
46function 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 */
61export 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
94export 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). */
102export 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. */
110export 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. */
118export 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. */
125export 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
138export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string {
139 return capability === "desktop-managed" ? "Update the desktop app" : "Update to stay in sync";
140}
141
142export function buildVersionMismatchDismissalKey(
143 environmentId: EnvironmentId,
144 mismatch: Pick<VersionMismatch, "clientVersion" | "serverVersion">,
145): string {
146 return `${environmentId}:${mismatch.clientVersion}:${mismatch.serverVersion}`;
147}
148
149function 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
163function 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
175export function isVersionMismatchDismissed(dismissalKey: string | null | undefined): boolean {
176 if (!dismissalKey) {
177 return false;
178 }
179 return readVersionMismatchDismissals().keys.includes(dismissalKey);
180}
181
182export 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
195export 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}