Open History to see the last commit
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
22const dismissedServerUpdateFailures = new WeakSet<ServerUpdateState>();
23
24export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean {
25 return state.status === "failed" && dismissedServerUpdateFailures.has(state);
26}
27
28export function dismissServerUpdateFailure(state: ServerUpdateState): void {
29 if (state.status === "failed") dismissedServerUpdateFailures.add(state);
30}
31
32const VersionMismatchDismissalsSchema = Schema.Struct({
33 keys: Schema.Array(Schema.String),
34});
35
36type VersionMismatchDismissals = typeof VersionMismatchDismissalsSchema.Type;
37
38function normalizeVersion(version: string | null | undefined): string | null {
39 const trimmed = version?.trim();
40 return trimmed && trimmed.length > 0 ? trimmed : null;
41}
42
43function versionCore(version: string): string {
44 return version.replace(/[-+].*$/, "");
45}
46
47export function resolveVersionMismatch(
48 serverVersion: string | null | undefined,
49 clientVersion: string = APP_VERSION,
50): VersionMismatch | null {
51 const normalizedClientVersion = normalizeVersion(clientVersion);
52 const normalizedServerVersion = normalizeVersion(serverVersion);
53 if (!normalizedClientVersion || !normalizedServerVersion) {
54 return null;
55 }
56
57 const clientCore = versionCore(normalizedClientVersion);
58 const serverCore = versionCore(normalizedServerVersion);
59 const compareNightlyBuilds =
60 parseSemver(normalizedClientVersion)?.prerelease[0] === "nightly" &&
61 parseSemver(normalizedServerVersion)?.prerelease[0] === "nightly";
62 const serverIsBehind =
63 parseSemver(clientCore) && parseSemver(serverCore)
64 ? compareSemverVersions(
65 compareNightlyBuilds ? normalizedServerVersion : serverCore,
66 compareNightlyBuilds ? normalizedClientVersion : clientCore,
67 ) < 0
68 : normalizedServerVersion !== normalizedClientVersion;
69 if (!serverIsBehind) {
70 return null;
71 }
72
73 return {
74 clientVersion: normalizedClientVersion,
75 serverVersion: normalizedServerVersion,
76 hint: "Version mismatch. Try syncing the client and server to the same T3 Code version.",
77 };
78}
79
80export function resolveServerConfigVersionMismatch(
81 serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
82): VersionMismatch | null {
83 return resolveVersionMismatch(serverConfig?.environment.serverVersion);
84}
85
86export function resolveServerSelfUpdateCapability(
87 serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
88): ServerSelfUpdateCapability | null {
89 return serverConfig?.environment.capabilities.serverSelfUpdate ?? null;
90}
91
92export function supportsDesktopAppUpdate(
93 serverConfig: Pick<ServerConfig, "environment"> | null | undefined,
94): boolean {
95 return serverConfig?.environment.capabilities.desktopAppUpdate === true;
96}
97
98export function manualServerUpdateCommand(
99 targetVersion: string,
100 installation?: ServerInstallation,
101): string {
102 const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
103 if (installation?.kind === "npm-global") {
104 return `npm install --global --prefix ${quote(installation.prefix)} t3@${targetVersion}`;
105 }
106 if (installation?.kind === "t3-launcher") {
107 // An inherited T3CODE_HOME would otherwise send the update to a home the launcher is not in.
108 return `${quote(installation.launcher)} update --base-dir ${quote(installation.baseDir)} ${targetVersion}`;
109 }
110 const runner =
111 installation?.kind === "pnpm-dlx" ? "pnpm dlx" : installation?.kind === "bunx" ? "bunx" : "npx";
112 return `${runner} t3@${targetVersion}`;
113}
114
115export function buildVersionMismatchDismissalKey(
116 environmentId: EnvironmentId,
117 mismatch: Pick<VersionMismatch, "clientVersion" | "serverVersion">,
118): string {
119 return `${environmentId}:${mismatch.clientVersion}:${mismatch.serverVersion}`;
120}
121
122function readVersionMismatchDismissals(): VersionMismatchDismissals {
123 try {
124 return (
125 getLocalStorageItem(
126 VERSION_MISMATCH_DISMISSALS_STORAGE_KEY,
127 VersionMismatchDismissalsSchema,
128 ) ?? { keys: [] }
129 );
130 } catch (error) {
131 console.error("Could not read version-mismatch dismissals.", error);
132 return { keys: [] };
133 }
134}
135
136function writeVersionMismatchDismissals(document: VersionMismatchDismissals): void {
137 try {
138 setLocalStorageItem(
139 VERSION_MISMATCH_DISMISSALS_STORAGE_KEY,
140 document,
141 VersionMismatchDismissalsSchema,
142 );
143 } catch (error) {
144 console.error("Could not persist version-mismatch dismissals.", error);
145 }
146}
147
148export function isVersionMismatchDismissed(dismissalKey: string | null | undefined): boolean {
149 if (!dismissalKey) {
150 return false;
151 }
152 return readVersionMismatchDismissals().keys.includes(dismissalKey);
153}
154
155export function dismissVersionMismatch(dismissalKey: string | null | undefined): void {
156 if (!dismissalKey) {
157 return;
158 }
159 const document = readVersionMismatchDismissals();
160 if (document.keys.includes(dismissalKey)) {
161 return;
162 }
163 writeVersionMismatchDismissals({
164 keys: [...document.keys, dismissalKey],
165 });
166}