authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 20:18:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 20:18:41-07:00
loge32077cee78c6867cc79b2964c82e4c12d3e213f
tree00e543975f692fe10f64e50bf1c38393d11cfd51
parent5d9eb183123466fdf4b972fe33a4823d089370d3
signature Signed by PGP key B5690EEEBB952194

Persist client settings and saved environment secrets (#1868)


25 files changed, 1667 insertions(+), 277 deletions(-)

apps/desktop/src/clientPersistence.test.ts created+234
......@@ -0,0 +1,234 @@
1import * as fs from "node:fs";
2import * as os from "node:os";
3import * as path from "node:path";
4
5import {
6 EnvironmentId,
7 type ClientSettings,
8 type PersistedSavedEnvironmentRecord,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";
11
12import {
13 readClientSettings,
14 readSavedEnvironmentRegistry,
15 readSavedEnvironmentSecret,
16 removeSavedEnvironmentSecret,
17 writeClientSettings,
18 writeSavedEnvironmentRegistry,
19 writeSavedEnvironmentSecret,
20 type DesktopSecretStorage,
21} from "./clientPersistence";
22
23const tempDirectories: string[] = [];
24
25afterEach(() => {
26 for (const directory of tempDirectories.splice(0)) {
27 fs.rmSync(directory, { recursive: true, force: true });
28 }
29});
30
31function makeTempPath(fileName: string): string {
32 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-"));
33 tempDirectories.push(directory);
34 return path.join(directory, fileName);
35}
36
37function makeSecretStorage(available: boolean): DesktopSecretStorage {
38 return {
39 isEncryptionAvailable: () => available,
40 encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"),
41 decryptString: (value) => {
42 const decoded = value.toString("utf8");
43 if (!decoded.startsWith("enc:")) {
44 throw new Error("invalid secret");
45 }
46 return decoded.slice("enc:".length);
47 },
48 };
49}
50
51const clientSettings: ClientSettings = {
52 confirmThreadArchive: true,
53 confirmThreadDelete: false,
54 diffWordWrap: true,
55 sidebarProjectSortOrder: "manual",
56 sidebarThreadSortOrder: "created_at",
57 timestampFormat: "24-hour",
58};
59
60const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
61 environmentId: EnvironmentId.makeUnsafe("environment-1"),
62 label: "Remote environment",
63 httpBaseUrl: "https://remote.example.com/",
64 wsBaseUrl: "wss://remote.example.com/",
65 createdAt: "2026-04-09T00:00:00.000Z",
66 lastConnectedAt: "2026-04-09T01:00:00.000Z",
67};
68
69describe("clientPersistence", () => {
70 it("persists and reloads client settings", () => {
71 const settingsPath = makeTempPath("client-settings.json");
72
73 writeClientSettings(settingsPath, clientSettings);
74
75 expect(readClientSettings(settingsPath)).toEqual(clientSettings);
76 });
77
78 it("persists and reloads saved environment metadata", () => {
79 const registryPath = makeTempPath("saved-environments.json");
80
81 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
82
83 expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
84 });
85
86 it("persists encrypted saved environment secrets when encryption is available", () => {
87 const registryPath = makeTempPath("saved-environments.json");
88 const secretStorage = makeSecretStorage(true);
89
90 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
91
92 expect(
93 writeSavedEnvironmentSecret({
94 registryPath,
95 environmentId: savedRegistryRecord.environmentId,
96 secret: "bearer-token",
97 secretStorage,
98 }),
99 ).toBe(true);
100
101 expect(
102 readSavedEnvironmentSecret({
103 registryPath,
104 environmentId: savedRegistryRecord.environmentId,
105 secretStorage,
106 }),
107 ).toBe("bearer-token");
108
109 expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({
110 records: [
111 {
112 ...savedRegistryRecord,
113 encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"),
114 },
115 ],
116 });
117 });
118
119 it("preserves existing secrets when encryption is unavailable", () => {
120 const registryPath = makeTempPath("saved-environments.json");
121 const availableSecretStorage = makeSecretStorage(true);
122
123 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
124
125 writeSavedEnvironmentSecret({
126 registryPath,
127 environmentId: savedRegistryRecord.environmentId,
128 secret: "bearer-token",
129 secretStorage: availableSecretStorage,
130 });
131
132 expect(
133 writeSavedEnvironmentSecret({
134 registryPath,
135 environmentId: savedRegistryRecord.environmentId,
136 secret: "next-token",
137 secretStorage: makeSecretStorage(false),
138 }),
139 ).toBe(false);
140
141 expect(
142 readSavedEnvironmentSecret({
143 registryPath,
144 environmentId: savedRegistryRecord.environmentId,
145 secretStorage: availableSecretStorage,
146 }),
147 ).toBe("bearer-token");
148 });
149
150 it("removes saved environment secrets", () => {
151 const registryPath = makeTempPath("saved-environments.json");
152 const secretStorage = makeSecretStorage(true);
153
154 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
155
156 writeSavedEnvironmentSecret({
157 registryPath,
158 environmentId: savedRegistryRecord.environmentId,
159 secret: "bearer-token",
160 secretStorage,
161 });
162
163 removeSavedEnvironmentSecret({
164 registryPath,
165 environmentId: savedRegistryRecord.environmentId,
166 });
167
168 expect(
169 readSavedEnvironmentSecret({
170 registryPath,
171 environmentId: savedRegistryRecord.environmentId,
172 secretStorage,
173 }),
174 ).toBeNull();
175 });
176
177 it("treats malformed secrets documents as empty", () => {
178 const registryPath = makeTempPath("saved-environments.json");
179 fs.writeFileSync(registryPath, "{}\n", "utf8");
180
181 expect(
182 readSavedEnvironmentSecret({
183 registryPath,
184 environmentId: savedRegistryRecord.environmentId,
185 secretStorage: makeSecretStorage(true),
186 }),
187 ).toBeNull();
188
189 expect(() =>
190 removeSavedEnvironmentSecret({
191 registryPath,
192 environmentId: savedRegistryRecord.environmentId,
193 }),
194 ).not.toThrow();
195 });
196
197 it("returns false when writing a secret without metadata", () => {
198 const registryPath = makeTempPath("saved-environments.json");
199
200 expect(
201 writeSavedEnvironmentSecret({
202 registryPath,
203 environmentId: savedRegistryRecord.environmentId,
204 secret: "bearer-token",
205 secretStorage: makeSecretStorage(true),
206 }),
207 ).toBe(false);
208 });
209
210 it("preserves encrypted secrets when metadata is rewritten", () => {
211 const registryPath = makeTempPath("saved-environments.json");
212 const secretStorage = makeSecretStorage(true);
213
214 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
215
216 writeSavedEnvironmentSecret({
217 registryPath,
218 environmentId: savedRegistryRecord.environmentId,
219 secret: "bearer-token",
220 secretStorage,
221 });
222
223 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
224
225 expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
226 expect(
227 readSavedEnvironmentSecret({
228 registryPath,
229 environmentId: savedRegistryRecord.environmentId,
230 secretStorage,
231 }),
232 ).toBe("bearer-token");
233 });
234});
apps/desktop/src/clientPersistence.ts created+216
......@@ -0,0 +1,216 @@
1import * as FS from "node:fs";
2import * as Path from "node:path";
3
4import type { ClientSettings, PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
5import { Predicate } from "effect";
6
7interface ClientSettingsDocument {
8 readonly settings: ClientSettings;
9}
10
11interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord {
12 readonly encryptedBearerToken?: string;
13}
14
15interface SavedEnvironmentRegistryDocument {
16 readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
17}
18
19export interface DesktopSecretStorage {
20 readonly isEncryptionAvailable: () => boolean;
21 readonly encryptString: (value: string) => Buffer;
22 readonly decryptString: (value: Buffer) => string;
23}
24
25function readJsonFile<T>(filePath: string): T | null {
26 try {
27 if (!FS.existsSync(filePath)) {
28 return null;
29 }
30 return JSON.parse(FS.readFileSync(filePath, "utf8")) as T;
31 } catch {
32 return null;
33 }
34}
35
36function writeJsonFile(filePath: string, value: unknown): void {
37 const directory = Path.dirname(filePath);
38 const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
39 FS.mkdirSync(directory, { recursive: true });
40 FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
41 FS.renameSync(tempPath, filePath);
42}
43
44function isPersistedSavedEnvironmentStorageRecord(
45 value: unknown,
46): value is PersistedSavedEnvironmentStorageRecord {
47 return (
48 Predicate.isObject(value) &&
49 typeof value.environmentId === "string" &&
50 typeof value.label === "string" &&
51 typeof value.httpBaseUrl === "string" &&
52 typeof value.wsBaseUrl === "string" &&
53 typeof value.createdAt === "string" &&
54 (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&
55 (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
56 );
57}
58
59function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument {
60 const parsed = readJsonFile<SavedEnvironmentRegistryDocument>(filePath);
61 if (!Predicate.isObject(parsed)) {
62 return { records: [] };
63 }
64
65 return {
66 records: Array.isArray(parsed.records)
67 ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord)
68 : [],
69 };
70}
71
72function toPersistedSavedEnvironmentRecord(
73 record: PersistedSavedEnvironmentStorageRecord,
74): PersistedSavedEnvironmentRecord {
75 return {
76 environmentId: record.environmentId,
77 label: record.label,
78 httpBaseUrl: record.httpBaseUrl,
79 wsBaseUrl: record.wsBaseUrl,
80 createdAt: record.createdAt,
81 lastConnectedAt: record.lastConnectedAt,
82 };
83}
84
85export function readClientSettings(settingsPath: string): ClientSettings | null {
86 return readJsonFile<ClientSettingsDocument>(settingsPath)?.settings ?? null;
87}
88
89export function writeClientSettings(settingsPath: string, settings: ClientSettings): void {
90 writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument);
91}
92
93export function readSavedEnvironmentRegistry(
94 registryPath: string,
95): readonly PersistedSavedEnvironmentRecord[] {
96 return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) =>
97 toPersistedSavedEnvironmentRecord(record),
98 );
99}
100
101export function writeSavedEnvironmentRegistry(
102 registryPath: string,
103 records: readonly PersistedSavedEnvironmentRecord[],
104): void {
105 const currentDocument = readSavedEnvironmentRegistryDocument(registryPath);
106 const encryptedBearerTokenById = new Map(
107 currentDocument.records.flatMap((record) =>
108 record.encryptedBearerToken
109 ? [[record.environmentId, record.encryptedBearerToken] as const]
110 : [],
111 ),
112 );
113 writeJsonFile(registryPath, {
114 records: records.map((record) => {
115 const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
116 return encryptedBearerToken
117 ? {
118 environmentId: record.environmentId,
119 label: record.label,
120 httpBaseUrl: record.httpBaseUrl,
121 wsBaseUrl: record.wsBaseUrl,
122 createdAt: record.createdAt,
123 lastConnectedAt: record.lastConnectedAt,
124 encryptedBearerToken,
125 }
126 : record;
127 }),
128 } satisfies SavedEnvironmentRegistryDocument);
129}
130
131export function readSavedEnvironmentSecret(input: {
132 readonly registryPath: string;
133 readonly environmentId: string;
134 readonly secretStorage: DesktopSecretStorage;
135}): string | null {
136 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
137 const encoded = document.records.find(
138 (record) => record.environmentId === input.environmentId,
139 )?.encryptedBearerToken;
140 if (!encoded) {
141 return null;
142 }
143
144 if (!input.secretStorage.isEncryptionAvailable()) {
145 return null;
146 }
147
148 try {
149 return input.secretStorage.decryptString(Buffer.from(encoded, "base64"));
150 } catch {
151 return null;
152 }
153}
154
155export function writeSavedEnvironmentSecret(input: {
156 readonly registryPath: string;
157 readonly environmentId: string;
158 readonly secret: string;
159 readonly secretStorage: DesktopSecretStorage;
160}): boolean {
161 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
162
163 if (!input.secretStorage.isEncryptionAvailable()) {
164 return false;
165 }
166
167 let found = false;
168
169 writeJsonFile(input.registryPath, {
170 records: document.records.map((record) => {
171 if (record.environmentId !== input.environmentId) {
172 return record;
173 }
174
175 found = true;
176 const encryptedBearerToken = input.secretStorage
177 .encryptString(input.secret)
178 .toString("base64");
179 return {
180 environmentId: record.environmentId,
181 label: record.label,
182 httpBaseUrl: record.httpBaseUrl,
183 wsBaseUrl: record.wsBaseUrl,
184 createdAt: record.createdAt,
185 lastConnectedAt: record.lastConnectedAt,
186 encryptedBearerToken,
187 } satisfies PersistedSavedEnvironmentStorageRecord;
188 }),
189 } satisfies SavedEnvironmentRegistryDocument);
190 return found;
191}
192
193export function removeSavedEnvironmentSecret(input: {
194 readonly registryPath: string;
195 readonly environmentId: string;
196}): void {
197 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
198 if (
199 !document.records.some(
200 (record) =>
201 record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined,
202 )
203 ) {
204 return;
205 }
206
207 writeJsonFile(input.registryPath, {
208 records: document.records.map((record) => {
209 if (record.environmentId !== input.environmentId) {
210 return record;
211 }
212
213 return toPersistedSavedEnvironmentRecord(record);
214 }),
215 } satisfies SavedEnvironmentRegistryDocument);
216}
apps/desktop/src/main.ts+109
......@@ -14,14 +14,17 @@ import {
1414 nativeImage,
1515 nativeTheme,
1616 protocol,
17 safeStorage,
1718 shell,
1819} from "electron";
1920import type { MenuItemConstructorOptions } from "electron";
2021import * as Effect from "effect/Effect";
2122import type {
23 ClientSettings,
2224 DesktopTheme,
2325 DesktopServerExposureMode,
2426 DesktopServerExposureState,
27 PersistedSavedEnvironmentRecord,
2528 DesktopUpdateActionResult,
2629 DesktopUpdateCheckResult,
2730 DesktopUpdateState,
......@@ -38,6 +41,15 @@ import {
3841 setDesktopServerExposurePreference,
3942 writeDesktopSettings,
4043} from "./desktopSettings";
44import {
45 readClientSettings,
46 readSavedEnvironmentRegistry,
47 readSavedEnvironmentSecret,
48 removeSavedEnvironmentSecret,
49 writeClientSettings,
50 writeSavedEnvironmentRegistry,
51 writeSavedEnvironmentSecret,
52} from "./clientPersistence";
4153import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness";
4254import { showDesktopConfirmDialog } from "./confirmDialog";
4355import { resolveDesktopServerExposure } from "./serverExposure";
......@@ -71,11 +83,20 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
7183const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
7284const UPDATE_CHECK_CHANNEL = "desktop:update-check";
7385const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
86const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
87const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
88const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
89const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
90const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
91const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
92const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
7493const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
7594const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
7695const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
7796const STATE_DIR = Path.join(BASE_DIR, "userdata");
7897const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
98const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");
99const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");
79100const DESKTOP_SCHEME = "t3";
80101const ROOT_DIR = Path.resolve(__dirname, "../../..");
81102const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
......@@ -204,6 +225,14 @@ function getDesktopServerExposureState(): DesktopServerExposureState {
204225 };
205226}
206227
228function getDesktopSecretStorage() {
229 return {
230 isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
231 encryptString: (value: string) => safeStorage.encryptString(value),
232 decryptString: (value: Buffer) => safeStorage.decryptString(value),
233 } as const;
234}
235
207236function resolveAdvertisedHostOverride(): string | undefined {
208237 const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim();
209238 return override && override.length > 0 ? override : undefined;
......@@ -1349,6 +1378,86 @@ function registerIpcHandlers(): void {
13491378 } as const;
13501379 });
13511380
1381 ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL);
1382 ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH));
1383
1384 ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL);
1385 ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => {
1386 if (typeof rawSettings !== "object" || rawSettings === null) {
1387 throw new Error("Invalid client settings payload.");
1388 }
1389
1390 writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings);
1391 });
1392
1393 ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1394 ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () =>
1395 readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH),
1396 );
1397
1398 ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1399 ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => {
1400 if (!Array.isArray(rawRecords)) {
1401 throw new Error("Invalid saved environment registry payload.");
1402 }
1403
1404 writeSavedEnvironmentRegistry(
1405 SAVED_ENVIRONMENT_REGISTRY_PATH,
1406 rawRecords as readonly PersistedSavedEnvironmentRecord[],
1407 );
1408 });
1409
1410 ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1411 ipcMain.handle(
1412 GET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1413 async (_event, rawEnvironmentId: unknown) => {
1414 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1415 return null;
1416 }
1417
1418 return readSavedEnvironmentSecret({
1419 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1420 environmentId: rawEnvironmentId,
1421 secretStorage: getDesktopSecretStorage(),
1422 });
1423 },
1424 );
1425
1426 ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1427 ipcMain.handle(
1428 SET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1429 async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => {
1430 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1431 throw new Error("Invalid saved environment id.");
1432 }
1433 if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) {
1434 throw new Error("Invalid saved environment secret.");
1435 }
1436
1437 return writeSavedEnvironmentSecret({
1438 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1439 environmentId: rawEnvironmentId,
1440 secret: rawSecret,
1441 secretStorage: getDesktopSecretStorage(),
1442 });
1443 },
1444 );
1445
1446 ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1447 ipcMain.handle(
1448 REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1449 async (_event, rawEnvironmentId: unknown) => {
1450 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1451 return;
1452 }
1453
1454 removeSavedEnvironmentSecret({
1455 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1456 environmentId: rawEnvironmentId,
1457 });
1458 },
1459 );
1460
13521461 ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
13531462 ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());
13541463
apps/desktop/src/preload.ts+18
......@@ -13,6 +13,13 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1313const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1414const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1515const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
16const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
17const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
18const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
19const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
20const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
21const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
22const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
1623const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
1724const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
1825
......@@ -24,6 +31,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
2431 }
2532 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
2633 },
34 getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL),
35 setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings),
36 getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL),
37 setSavedEnvironmentRegistry: (records) =>
38 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records),
39 getSavedEnvironmentSecret: (environmentId) =>
40 ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
41 setSavedEnvironmentSecret: (environmentId, secret) =>
42 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),
43 removeSavedEnvironmentSecret: (environmentId) =>
44 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
2745 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
2846 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
2947 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
apps/web/src/clientPersistenceStorage.test.ts created+80
......@@ -0,0 +1,80 @@
1import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";
3
4const testEnvironmentId = EnvironmentId.makeUnsafe("environment-1");
5
6const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
7 environmentId: testEnvironmentId,
8 label: "Remote environment",
9 httpBaseUrl: "https://remote.example.com/",
10 wsBaseUrl: "wss://remote.example.com/",
11 createdAt: "2026-04-09T00:00:00.000Z",
12 lastConnectedAt: null,
13};
14
15function createLocalStorageStub(): Storage {
16 const store = new Map<string, string>();
17 return {
18 getItem: (key) => store.get(key) ?? null,
19 setItem: (key, value) => {
20 store.set(key, value);
21 },
22 removeItem: (key) => {
23 store.delete(key);
24 },
25 clear: () => {
26 store.clear();
27 },
28 key: (index) => [...store.keys()][index] ?? null,
29 get length() {
30 return store.size;
31 },
32 };
33}
34
35function getTestWindow(): Window & typeof globalThis {
36 const localStorage = createLocalStorageStub();
37 const testWindow = {
38 localStorage,
39 } as Window & typeof globalThis;
40 vi.stubGlobal("window", testWindow);
41 vi.stubGlobal("localStorage", localStorage);
42 return testWindow;
43}
44
45afterEach(() => {
46 vi.resetModules();
47 vi.unstubAllGlobals();
48 vi.restoreAllMocks();
49});
50
51describe("clientPersistenceStorage", () => {
52 it("stores browser secrets inline with the saved environment record", async () => {
53 const testWindow = getTestWindow();
54 const {
55 SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY,
56 readBrowserSavedEnvironmentRegistry,
57 readBrowserSavedEnvironmentSecret,
58 writeBrowserSavedEnvironmentRegistry,
59 writeBrowserSavedEnvironmentSecret,
60 } = await import("./clientPersistenceStorage");
61
62 writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]);
63 expect(writeBrowserSavedEnvironmentSecret(testEnvironmentId, "bearer-token")).toBe(true);
64 writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]);
65
66 expect(readBrowserSavedEnvironmentRegistry()).toEqual([savedRegistryRecord]);
67 expect(readBrowserSavedEnvironmentSecret(testEnvironmentId)).toBe("bearer-token");
68 expect(
69 JSON.parse(testWindow.localStorage.getItem(SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY)!),
70 ).toEqual({
71 version: 1,
72 records: [
73 {
74 ...savedRegistryRecord,
75 bearerToken: "bearer-token",
76 },
77 ],
78 });
79 });
80});
apps/web/src/clientPersistenceStorage.ts created+194
......@@ -0,0 +1,194 @@
1import {
2 ClientSettingsSchema,
3 EnvironmentId,
4 type ClientSettings,
5 type EnvironmentId as EnvironmentIdValue,
6 type PersistedSavedEnvironmentRecord,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9
10import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage";
11
12export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
13export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";
14
15const BrowserSavedEnvironmentRecordSchema = Schema.Struct({
16 environmentId: EnvironmentId,
17 label: Schema.String,
18 httpBaseUrl: Schema.String,
19 wsBaseUrl: Schema.String,
20 createdAt: Schema.String,
21 lastConnectedAt: Schema.NullOr(Schema.String),
22 bearerToken: Schema.optionalKey(Schema.String),
23});
24type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;
25
26const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({
27 version: Schema.optionalKey(Schema.Number),
28 records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)),
29});
30type BrowserSavedEnvironmentRegistryDocument =
31 typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type;
32
33function hasWindow(): boolean {
34 return typeof window !== "undefined";
35}
36
37function toPersistedSavedEnvironmentRecord(
38 record: PersistedSavedEnvironmentRecord,
39): PersistedSavedEnvironmentRecord {
40 return {
41 environmentId: record.environmentId,
42 label: record.label,
43 httpBaseUrl: record.httpBaseUrl,
44 wsBaseUrl: record.wsBaseUrl,
45 createdAt: record.createdAt,
46 lastConnectedAt: record.lastConnectedAt,
47 };
48}
49
50export function readBrowserClientSettings(): ClientSettings | null {
51 if (!hasWindow()) {
52 return null;
53 }
54
55 try {
56 return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema);
57 } catch {
58 return null;
59 }
60}
61
62export function writeBrowserClientSettings(settings: ClientSettings): void {
63 if (!hasWindow()) {
64 return;
65 }
66
67 setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema);
68}
69
70function readBrowserSavedEnvironmentRegistryDocument(): BrowserSavedEnvironmentRegistryDocument {
71 if (!hasWindow()) {
72 return {};
73 }
74
75 try {
76 const parsed = getLocalStorageItem(
77 SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY,
78 BrowserSavedEnvironmentRegistryDocumentSchema,
79 );
80 return parsed ?? {};
81 } catch {
82 return {};
83 }
84}
85
86function writeBrowserSavedEnvironmentRegistryDocument(
87 document: BrowserSavedEnvironmentRegistryDocument,
88): void {
89 if (!hasWindow()) {
90 return;
91 }
92
93 setLocalStorageItem(
94 SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY,
95 document,
96 BrowserSavedEnvironmentRegistryDocumentSchema,
97 );
98}
99
100function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray<BrowserSavedEnvironmentRecord> {
101 return readBrowserSavedEnvironmentRegistryDocument().records ?? [];
102}
103
104function writeBrowserSavedEnvironmentRecords(
105 records: ReadonlyArray<BrowserSavedEnvironmentRecord>,
106): void {
107 writeBrowserSavedEnvironmentRegistryDocument({
108 version: 1,
109 records,
110 });
111}
112
113export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray<PersistedSavedEnvironmentRecord> {
114 return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) =>
115 toPersistedSavedEnvironmentRecord(record),
116 );
117}
118
119export function writeBrowserSavedEnvironmentRegistry(
120 records: ReadonlyArray<PersistedSavedEnvironmentRecord>,
121): void {
122 const existing = new Map(
123 readBrowserSavedEnvironmentRecordsWithSecrets().map(
124 (record) => [record.environmentId, record] as const,
125 ),
126 );
127 writeBrowserSavedEnvironmentRecords(
128 records.map((record) => {
129 const bearerToken = existing.get(record.environmentId)?.bearerToken;
130 return bearerToken
131 ? {
132 environmentId: record.environmentId,
133 label: record.label,
134 httpBaseUrl: record.httpBaseUrl,
135 wsBaseUrl: record.wsBaseUrl,
136 createdAt: record.createdAt,
137 lastConnectedAt: record.lastConnectedAt,
138 bearerToken,
139 }
140 : toPersistedSavedEnvironmentRecord(record);
141 }),
142 );
143}
144
145export function readBrowserSavedEnvironmentSecret(
146 environmentId: EnvironmentIdValue,
147): string | null {
148 return (
149 readBrowserSavedEnvironmentRecordsWithSecrets().find(
150 (record) => record.environmentId === environmentId,
151 )?.bearerToken ?? null
152 );
153}
154
155export function writeBrowserSavedEnvironmentSecret(
156 environmentId: EnvironmentIdValue,
157 secret: string,
158): boolean {
159 const document = readBrowserSavedEnvironmentRegistryDocument();
160 const records = document.records ?? [];
161 let found = false;
162 writeBrowserSavedEnvironmentRegistryDocument({
163 version: document.version ?? 1,
164 records: records.map((record) => {
165 if (record.environmentId !== environmentId) {
166 return record;
167 }
168 found = true;
169 return {
170 environmentId: record.environmentId,
171 label: record.label,
172 httpBaseUrl: record.httpBaseUrl,
173 wsBaseUrl: record.wsBaseUrl,
174 createdAt: record.createdAt,
175 lastConnectedAt: record.lastConnectedAt,
176 bearerToken: secret,
177 } satisfies BrowserSavedEnvironmentRecord;
178 }),
179 });
180 return found;
181}
182
183export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentIdValue): void {
184 const document = readBrowserSavedEnvironmentRegistryDocument();
185 writeBrowserSavedEnvironmentRegistryDocument({
186 version: document.version ?? 1,
187 records: (document.records ?? []).map((record) => {
188 if (record.environmentId !== environmentId) {
189 return record;
190 }
191 return toPersistedSavedEnvironmentRecord(record);
192 }),
193 });
194}
apps/web/src/components/ChatMarkdown.browser.tsx+3
......@@ -17,6 +17,9 @@ vi.mock("../editorPreferences", () => ({
1717}));
1818
1919vi.mock("../localApi", () => ({
20 ensureLocalApi: vi.fn(() => {
21 throw new Error("ensureLocalApi not implemented in browser test");
22 }),
2023 readLocalApi: readLocalApiMock,
2124}));
2225
apps/web/src/components/GitActionsControl.browser.tsx+3
......@@ -126,6 +126,9 @@ vi.mock("~/lib/gitStatusState", () => ({
126126}));
127127
128128vi.mock("~/localApi", () => ({
129 ensureLocalApi: vi.fn(() => {
130 throw new Error("ensureLocalApi not implemented in browser test");
131 }),
129132 readLocalApi: vi.fn(() => null),
130133}));
131134
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+3
......@@ -113,6 +113,9 @@ vi.mock("~/environmentApi", () => ({
113113}));
114114
115115vi.mock("~/localApi", () => ({
116 ensureLocalApi: vi.fn(() => {
117 throw new Error("ensureLocalApi not implemented in browser test");
118 }),
116119 readLocalApi: readLocalApiMock,
117120}));
118121
apps/web/src/components/settings/SettingsPanels.browser.tsx+7
......@@ -282,6 +282,13 @@ const createDesktopBridgeStub = (overrides?: {
282282 wsBaseUrl: "ws://127.0.0.1:3773",
283283 bootstrapToken: "desktop-bootstrap-token",
284284 }),
285 getClientSettings: vi.fn().mockResolvedValue(null),
286 setClientSettings: vi.fn().mockResolvedValue(undefined),
287 getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]),
288 setSavedEnvironmentRegistry: vi.fn().mockResolvedValue(undefined),
289 getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null),
290 setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true),
291 removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
285292 getServerExposureState: vi.fn().mockResolvedValue(
286293 overrides?.serverExposureState ?? {
287294 mode: "local-only",
apps/web/src/environments/primary/bootstrap.test.ts+26
......@@ -110,6 +110,32 @@ describe("environmentBootstrap", () => {
110110 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
111111 });
112112
113 it("derives the websocket url when only VITE_HTTP_URL is configured", async () => {
114 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
115 vi.stubGlobal("fetch", fetchMock);
116 vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
117
118 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
119 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
120 expect(getPrimaryKnownEnvironment()?.target).toEqual({
121 httpBaseUrl: "https://remote.example.com/",
122 wsBaseUrl: "wss://remote.example.com/",
123 });
124 });
125
126 it("derives the http url when only VITE_WS_URL is configured", async () => {
127 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
128 vi.stubGlobal("fetch", fetchMock);
129 vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
130
131 await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
132 expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
133 expect(getPrimaryKnownEnvironment()?.target).toEqual({
134 httpBaseUrl: "https://remote.example.com/",
135 wsBaseUrl: "wss://remote.example.com/",
136 });
137 });
138
113139 it("uses the current origin as the descriptor base for local dev environments", async () => {
114140 const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
115141 vi.stubGlobal("fetch", fetchMock);
apps/web/src/environments/primary/target.ts+23-7
......@@ -16,6 +16,15 @@ function normalizeBaseUrl(rawValue: string): string {
1616 return new URL(rawValue, window.location.origin).toString();
1717}
1818
19function swapBaseUrlProtocol(
20 rawValue: string,
21 nextProtocol: "http:" | "https:" | "ws:" | "wss:",
22): string {
23 const url = new URL(normalizeBaseUrl(rawValue));
24 url.protocol = nextProtocol;
25 return url.toString();
26}
27
1928function normalizeHostname(hostname: string): string {
2029 return hostname
2130 .trim()
......@@ -54,22 +63,29 @@ function resolveHttpRequestBaseUrl(httpBaseUrl: string): string {
5463}
5564
5665function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null {
57 const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim();
58 const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim();
66 const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim() || undefined;
67 const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim() || undefined;
5968
6069 if (!configuredHttpBaseUrl && !configuredWsBaseUrl) {
6170 return null;
6271 }
6372
64 if (!configuredHttpBaseUrl || !configuredWsBaseUrl) {
65 throw new Error("Configured primary environments require both VITE_HTTP_URL and VITE_WS_URL.");
66 }
73 const resolvedHttpBaseUrl =
74 configuredHttpBaseUrl ??
75 (configuredWsBaseUrl?.startsWith("wss:")
76 ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:")
77 : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:"));
78 const resolvedWsBaseUrl =
79 configuredWsBaseUrl ??
80 (configuredHttpBaseUrl?.startsWith("https:")
81 ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:")
82 : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:"));
6783
6884 return {
6985 source: "configured",
7086 target: {
71 httpBaseUrl: normalizeBaseUrl(configuredHttpBaseUrl),
72 wsBaseUrl: normalizeBaseUrl(configuredWsBaseUrl),
87 httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl),
88 wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl),
7389 },
7490 };
7591}
apps/web/src/environments/runtime/catalog.test.ts+94-4
......@@ -1,17 +1,43 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { afterEach, describe, expect, it } from "vitest";
1import {
2 EnvironmentId,
3 type LocalApi,
4 type PersistedSavedEnvironmentRecord,
5} from "@t3tools/contracts";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
37
48import {
59 resetSavedEnvironmentRegistryStoreForTests,
610 resetSavedEnvironmentRuntimeStoreForTests,
711 useSavedEnvironmentRegistryStore,
812 useSavedEnvironmentRuntimeStore,
13 waitForSavedEnvironmentRegistryHydration,
914} from "./catalog";
1015
1116describe("environment runtime catalog stores", () => {
12 afterEach(() => {
17 beforeEach(async () => {
18 vi.stubGlobal("window", {
19 nativeApi: {
20 persistence: {
21 getClientSettings: async () => null,
22 setClientSettings: async () => undefined,
23 getSavedEnvironmentRegistry: async () => [],
24 setSavedEnvironmentRegistry: async () => undefined,
25 getSavedEnvironmentSecret: async () => null,
26 setSavedEnvironmentSecret: async () => true,
27 removeSavedEnvironmentSecret: async () => undefined,
28 },
29 } satisfies Pick<LocalApi, "persistence">,
30 });
31 const { __resetLocalApiForTests } = await import("../../localApi");
32 await __resetLocalApiForTests();
33 });
34
35 afterEach(async () => {
1336 resetSavedEnvironmentRegistryStoreForTests();
1437 resetSavedEnvironmentRuntimeStoreForTests();
38 const { __resetLocalApiForTests } = await import("../../localApi");
39 await __resetLocalApiForTests();
40 vi.unstubAllGlobals();
1541 });
1642
1743 it("resets the saved environment registry store state", () => {
......@@ -22,7 +48,6 @@ describe("environment runtime catalog stores", () => {
2248 label: "Remote environment",
2349 httpBaseUrl: "https://remote.example.com/",
2450 wsBaseUrl: "wss://remote.example.com/",
25 bearerToken: "token",
2651 createdAt: "2026-04-09T00:00:00.000Z",
2752 lastConnectedAt: null,
2853 });
......@@ -48,4 +73,69 @@ describe("environment runtime catalog stores", () => {
4873
4974 expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({});
5075 });
76
77 it("does not throw when local api lookup fails during registry persistence", async () => {
78 vi.unstubAllGlobals();
79 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
80 const { __resetLocalApiForTests } = await import("../../localApi");
81 await __resetLocalApiForTests();
82
83 expect(() =>
84 useSavedEnvironmentRegistryStore.getState().upsert({
85 environmentId: EnvironmentId.makeUnsafe("environment-1"),
86 label: "Remote environment",
87 httpBaseUrl: "https://remote.example.com/",
88 wsBaseUrl: "wss://remote.example.com/",
89 createdAt: "2026-04-09T00:00:00.000Z",
90 lastConnectedAt: null,
91 }),
92 ).not.toThrow();
93
94 expect(errorSpy).toHaveBeenCalledWith("[SAVED_ENVIRONMENTS] persist failed", expect.any(Error));
95 });
96
97 it("does not let stale hydration overwrite records added while hydration is in flight", async () => {
98 let resolveRegistryRead: () => void = () => {
99 throw new Error("Registry read resolver was not initialized.");
100 };
101
102 vi.stubGlobal("window", {
103 nativeApi: {
104 persistence: {
105 getClientSettings: async () => null,
106 setClientSettings: async () => undefined,
107 getSavedEnvironmentRegistry: () =>
108 new Promise<readonly PersistedSavedEnvironmentRecord[]>((resolve) => {
109 resolveRegistryRead = () => resolve([]);
110 }),
111 setSavedEnvironmentRegistry: async () => undefined,
112 getSavedEnvironmentSecret: async () => null,
113 setSavedEnvironmentSecret: async () => true,
114 removeSavedEnvironmentSecret: async () => undefined,
115 },
116 } satisfies Pick<LocalApi, "persistence">,
117 });
118
119 const { __resetLocalApiForTests } = await import("../../localApi");
120 await __resetLocalApiForTests();
121
122 const hydrationPromise = waitForSavedEnvironmentRegistryHydration();
123
124 const environmentId = EnvironmentId.makeUnsafe("environment-1");
125 const record = {
126 environmentId,
127 label: "Remote environment",
128 httpBaseUrl: "https://remote.example.com/",
129 wsBaseUrl: "wss://remote.example.com/",
130 createdAt: "2026-04-09T00:00:00.000Z",
131 lastConnectedAt: null,
132 } as const;
133
134 useSavedEnvironmentRegistryStore.getState().upsert(record);
135
136 resolveRegistryRead();
137 await hydrationPromise;
138
139 expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record);
140 });
51141});
apps/web/src/environments/runtime/catalog.ts+158-68
......@@ -3,22 +3,19 @@ import type {
33 AuthSessionRole,
44 EnvironmentId,
55 ExecutionEnvironmentDescriptor,
6 PersistedSavedEnvironmentRecord,
67 ServerConfig,
78} from "@t3tools/contracts";
89import { create } from "zustand";
9import { createJSONStorage, persist } from "zustand/middleware";
1010
11import { resolveStorage } from "../../lib/storage";
11import { ensureLocalApi } from "../../localApi";
1212import { getPrimaryKnownEnvironment } from "../primary";
1313
14const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1";
15
1614export interface SavedEnvironmentRecord {
1715 readonly environmentId: EnvironmentId;
1816 readonly label: string;
1917 readonly wsBaseUrl: string;
2018 readonly httpBaseUrl: string;
21 readonly bearerToken: string;
2219 readonly createdAt: string;
2320 readonly lastConnectedAt: string | null;
2421}
......@@ -34,62 +31,133 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState {
3431 readonly reset: () => void;
3532}
3633
37function createSavedEnvironmentRegistryStorage() {
38 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
34let savedEnvironmentRegistryHydrated = false;
35let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null;
36
37function toPersistedSavedEnvironmentRecord(
38 record: SavedEnvironmentRecord,
39): PersistedSavedEnvironmentRecord {
40 return {
41 environmentId: record.environmentId,
42 label: record.label,
43 httpBaseUrl: record.httpBaseUrl,
44 wsBaseUrl: record.wsBaseUrl,
45 createdAt: record.createdAt,
46 lastConnectedAt: record.lastConnectedAt,
47 };
3948}
4049
41export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryStore>()(
42 persist(
43 (set) => ({
44 byId: {},
45 upsert: (record) =>
46 set((state) => ({
47 byId: {
48 ...state.byId,
49 [record.environmentId]: record,
50 },
51 })),
52 remove: (environmentId) =>
53 set((state) => {
54 const { [environmentId]: _removed, ...remaining } = state.byId;
55 return {
56 byId: remaining,
57 };
58 }),
59 markConnected: (environmentId, connectedAt) =>
60 set((state) => {
61 const existing = state.byId[environmentId];
62 if (!existing) {
63 return state;
64 }
65 return {
66 byId: {
67 ...state.byId,
68 [environmentId]: {
69 ...existing,
70 lastConnectedAt: connectedAt,
71 },
72 },
73 };
74 }),
75 reset: () =>
76 set({
77 byId: {},
78 }),
79 }),
80 {
81 name: SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY,
82 version: 1,
83 storage: createJSONStorage(createSavedEnvironmentRegistryStorage),
84 partialize: (state) => ({
85 byId: state.byId,
86 }),
50function valuesOfSavedEnvironmentRegistry(
51 byId: Record<EnvironmentId, SavedEnvironmentRecord>,
52): ReadonlyArray<SavedEnvironmentRecord> {
53 return Object.values(byId) as ReadonlyArray<SavedEnvironmentRecord>;
54}
55
56function persistSavedEnvironmentRegistryState(
57 byId: Record<EnvironmentId, SavedEnvironmentRecord>,
58): void {
59 try {
60 void ensureLocalApi()
61 .persistence.setSavedEnvironmentRegistry(
62 valuesOfSavedEnvironmentRegistry(byId).map((record) =>
63 toPersistedSavedEnvironmentRecord(record),
64 ),
65 )
66 .catch((error) => {
67 console.error("[SAVED_ENVIRONMENTS] persist failed", error);
68 });
69 } catch (error) {
70 console.error("[SAVED_ENVIRONMENTS] persist failed", error);
71 }
72}
73
74function replaceSavedEnvironmentRegistryState(
75 records: ReadonlyArray<SavedEnvironmentRecord>,
76): void {
77 const currentById = useSavedEnvironmentRegistryStore.getState().byId;
78 const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record]));
79 useSavedEnvironmentRegistryStore.setState({
80 byId: {
81 ...hydratedById,
82 ...currentById,
8783 },
88 ),
89);
84 });
85}
86
87async function hydrateSavedEnvironmentRegistry(): Promise<void> {
88 if (savedEnvironmentRegistryHydrated) {
89 return;
90 }
91 if (savedEnvironmentRegistryHydrationPromise) {
92 return savedEnvironmentRegistryHydrationPromise;
93 }
94
95 const nextHydration = (async () => {
96 try {
97 const persistedRecords = await ensureLocalApi().persistence.getSavedEnvironmentRegistry();
98 replaceSavedEnvironmentRegistryState(persistedRecords);
99 } catch (error) {
100 console.error("[SAVED_ENVIRONMENTS] hydrate failed", error);
101 } finally {
102 savedEnvironmentRegistryHydrated = true;
103 }
104 })();
105
106 const hydrationPromise = nextHydration.finally(() => {
107 if (savedEnvironmentRegistryHydrationPromise === hydrationPromise) {
108 savedEnvironmentRegistryHydrationPromise = null;
109 }
110 });
111 savedEnvironmentRegistryHydrationPromise = hydrationPromise;
112
113 return savedEnvironmentRegistryHydrationPromise;
114}
115
116export const useSavedEnvironmentRegistryStore = create<SavedEnvironmentRegistryStore>()((set) => ({
117 byId: {},
118 upsert: (record) =>
119 set((state) => {
120 const byId = {
121 ...state.byId,
122 [record.environmentId]: record,
123 };
124 persistSavedEnvironmentRegistryState(byId);
125 return { byId };
126 }),
127 remove: (environmentId) =>
128 set((state) => {
129 const { [environmentId]: _removed, ...remaining } = state.byId;
130 persistSavedEnvironmentRegistryState(remaining);
131 return {
132 byId: remaining,
133 };
134 }),
135 markConnected: (environmentId, connectedAt) =>
136 set((state) => {
137 const existing = state.byId[environmentId];
138 if (!existing) {
139 return state;
140 }
141 const byId = {
142 ...state.byId,
143 [environmentId]: {
144 ...existing,
145 lastConnectedAt: connectedAt,
146 },
147 };
148 persistSavedEnvironmentRegistryState(byId);
149 return { byId };
150 }),
151 reset: () => {
152 persistSavedEnvironmentRegistryState({});
153 set({
154 byId: {},
155 });
156 },
157}));
90158
91159export function hasSavedEnvironmentRegistryHydrated(): boolean {
92 return useSavedEnvironmentRegistryStore.persist.hasHydrated();
160 return savedEnvironmentRegistryHydrated;
93161}
94162
95163export function waitForSavedEnvironmentRegistryHydration(): Promise<void> {
......@@ -97,17 +165,7 @@ export function waitForSavedEnvironmentRegistryHydration(): Promise<void> {
97165 return Promise.resolve();
98166 }
99167
100 return new Promise((resolve) => {
101 const unsubscribe = useSavedEnvironmentRegistryStore.persist.onFinishHydration(() => {
102 unsubscribe();
103 resolve();
104 });
105
106 if (hasSavedEnvironmentRegistryHydrated()) {
107 unsubscribe();
108 resolve();
109 }
110 });
168 return hydrateSavedEnvironmentRegistry();
111169}
112170
113171export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> {
......@@ -150,7 +208,39 @@ export function resolveEnvironmentHttpUrl(input: {
150208}
151209
152210export function resetSavedEnvironmentRegistryStoreForTests() {
153 useSavedEnvironmentRegistryStore.getState().reset();
211 savedEnvironmentRegistryHydrated = false;
212 savedEnvironmentRegistryHydrationPromise = null;
213 useSavedEnvironmentRegistryStore.setState({ byId: {} });
214}
215
216export async function persistSavedEnvironmentRecord(record: SavedEnvironmentRecord): Promise<void> {
217 const byId = {
218 ...useSavedEnvironmentRegistryStore.getState().byId,
219 [record.environmentId]: record,
220 };
221
222 await ensureLocalApi().persistence.setSavedEnvironmentRegistry(
223 valuesOfSavedEnvironmentRegistry(byId).map((entry) => toPersistedSavedEnvironmentRecord(entry)),
224 );
225}
226
227export async function readSavedEnvironmentBearerToken(
228 environmentId: EnvironmentId,
229): Promise<string | null> {
230 return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId);
231}
232
233export async function writeSavedEnvironmentBearerToken(
234 environmentId: EnvironmentId,
235 bearerToken: string,
236): Promise<boolean> {
237 return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken);
238}
239
240export async function removeSavedEnvironmentBearerToken(
241 environmentId: EnvironmentId,
242): Promise<void> {
243 await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId);
154244}
155245
156246export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error";
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts created+105
......@@ -0,0 +1,105 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vitest";
3
4const mockResolveRemotePairingTarget = vi.fn();
5const mockFetchRemoteEnvironmentDescriptor = vi.fn();
6const mockBootstrapRemoteBearerSession = vi.fn();
7const mockPersistSavedEnvironmentRecord = vi.fn();
8const mockWriteSavedEnvironmentBearerToken = vi.fn();
9const mockSetSavedEnvironmentRegistry = vi.fn();
10const mockUpsert = vi.fn();
11const mockListSavedEnvironmentRecords = vi.fn();
12
13vi.mock("../remote/target", () => ({
14 resolveRemotePairingTarget: mockResolveRemotePairingTarget,
15}));
16
17vi.mock("../remote/api", () => ({
18 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,
19 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
20 fetchRemoteSessionState: vi.fn(),
21 resolveRemoteWebSocketConnectionUrl: vi.fn(),
22}));
23
24vi.mock("~/localApi", () => ({
25 ensureLocalApi: () => ({
26 persistence: {
27 setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry,
28 },
29 }),
30}));
31
32vi.mock("./catalog", () => ({
33 getSavedEnvironmentRecord: vi.fn(),
34 hasSavedEnvironmentRegistryHydrated: vi.fn(),
35 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
36 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,
37 readSavedEnvironmentBearerToken: vi.fn(),
38 removeSavedEnvironmentBearerToken: vi.fn(),
39 useSavedEnvironmentRegistryStore: {
40 getState: () => ({
41 upsert: mockUpsert,
42 remove: vi.fn(),
43 markConnected: vi.fn(),
44 }),
45 },
46 useSavedEnvironmentRuntimeStore: {
47 getState: () => ({
48 ensure: vi.fn(),
49 patch: vi.fn(),
50 clear: vi.fn(),
51 }),
52 },
53 waitForSavedEnvironmentRegistryHydration: vi.fn(),
54 writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken,
55}));
56
57vi.mock("./connection", () => ({
58 createEnvironmentConnection: vi.fn(),
59}));
60
61describe("addSavedEnvironment", () => {
62 beforeEach(() => {
63 vi.resetModules();
64 vi.clearAllMocks();
65 mockResolveRemotePairingTarget.mockReturnValue({
66 httpBaseUrl: "https://remote.example.com/",
67 wsBaseUrl: "wss://remote.example.com/",
68 credential: "pairing-code",
69 });
70 mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({
71 environmentId: EnvironmentId.makeUnsafe("environment-1"),
72 label: "Remote environment",
73 });
74 mockBootstrapRemoteBearerSession.mockResolvedValue({
75 sessionToken: "bearer-token",
76 role: "owner",
77 });
78 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
79 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
80 mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined);
81 mockListSavedEnvironmentRecords.mockReturnValue([]);
82 });
83
84 it("rolls back persisted metadata when bearer token persistence fails", async () => {
85 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
86
87 await expect(
88 addSavedEnvironment({
89 label: "Remote environment",
90 host: "remote.example.com",
91 pairingCode: "123456",
92 }),
93 ).rejects.toThrow("Unable to persist saved environment credentials.");
94
95 expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledTimes(1);
96 expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith(
97 EnvironmentId.makeUnsafe("environment-1"),
98 "bearer-token",
99 );
100 expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]);
101 expect(mockUpsert).not.toHaveBeenCalled();
102
103 await resetEnvironmentServiceForTests();
104 });
105});
apps/web/src/environments/runtime/service.ts+49-6
......@@ -23,6 +23,7 @@ import {
2323 markPromotedDraftThreadsByRef,
2424 useComposerDraftStore,
2525} from "~/composerDraftStore";
26import { ensureLocalApi } from "~/localApi";
2627import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup";
2728import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
2829import { projectQueryKeys } from "~/lib/projectReactQuery";
......@@ -39,10 +40,14 @@ import {
3940 getSavedEnvironmentRecord,
4041 hasSavedEnvironmentRegistryHydrated,
4142 listSavedEnvironmentRecords,
43 persistSavedEnvironmentRecord,
44 readSavedEnvironmentBearerToken,
45 removeSavedEnvironmentBearerToken,
4246 type SavedEnvironmentRecord,
4347 useSavedEnvironmentRegistryStore,
4448 useSavedEnvironmentRuntimeStore,
4549 waitForSavedEnvironmentRegistryHydration,
50 writeSavedEnvironmentBearerToken,
4651} from "./catalog";
4752import { createEnvironmentConnection, type EnvironmentConnection } from "./connection";
4853import {
......@@ -306,7 +311,10 @@ function createPrimaryEnvironmentClient(
306311 return createWsRpcClient(new WsTransport(wsBaseUrl));
307312}
308313
309function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClient {
314function createSavedEnvironmentClient(
315 record: SavedEnvironmentRecord,
316 bearerToken: string,
317): WsRpcClient {
310318 useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId);
311319
312320 return createWsRpcClient(
......@@ -315,7 +323,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie
315323 resolveRemoteWebSocketConnectionUrl({
316324 wsBaseUrl: record.wsBaseUrl,
317325 httpBaseUrl: record.httpBaseUrl,
318 bearerToken: record.bearerToken,
326 bearerToken,
319327 }),
320328 {
321329 onAttempt: () => {
......@@ -341,6 +349,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie
341349
342350async function refreshSavedEnvironmentMetadata(
343351 record: SavedEnvironmentRecord,
352 bearerToken: string,
344353 client: WsRpcClient,
345354 roleHint?: AuthSessionRole | null,
346355 configHint?: ServerConfig | null,
......@@ -349,7 +358,7 @@ async function refreshSavedEnvironmentMetadata(
349358 configHint ? Promise.resolve(configHint) : client.server.getConfig(),
350359 fetchRemoteSessionState({
351360 httpBaseUrl: record.httpBaseUrl,
352 bearerToken: record.bearerToken,
361 bearerToken,
353362 }),
354363 ]);
355364
......@@ -408,6 +417,7 @@ async function ensureSavedEnvironmentConnection(
408417 record: SavedEnvironmentRecord,
409418 options?: {
410419 readonly client?: WsRpcClient;
420 readonly bearerToken?: string;
411421 readonly role?: AuthSessionRole | null;
412422 readonly serverConfig?: ServerConfig | null;
413423 },
......@@ -417,7 +427,20 @@ async function ensureSavedEnvironmentConnection(
417427 return existing;
418428 }
419429
420 const client = options?.client ?? createSavedEnvironmentClient(record);
430 const bearerToken =
431 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
432 if (!bearerToken) {
433 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
434 authState: "requires-auth",
435 role: null,
436 connectionState: "disconnected",
437 lastError: "Saved environment is missing its saved credential. Pair it again.",
438 lastErrorAt: isoNow(),
439 });
440 throw new Error("Saved environment is missing its saved credential.");
441 }
442
443 const client = options?.client ?? createSavedEnvironmentClient(record, bearerToken);
421444 const knownEnvironment = createKnownEnvironment({
422445 id: record.environmentId,
423446 label: record.label,
......@@ -435,7 +458,7 @@ async function ensureSavedEnvironmentConnection(
435458 },
436459 client,
437460 refreshMetadata: async () => {
438 await refreshSavedEnvironmentMetadata(record, client);
461 await refreshSavedEnvironmentMetadata(record, bearerToken, client);
439462 },
440463 onConfigSnapshot: (config) => {
441464 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
......@@ -456,6 +479,7 @@ async function ensureSavedEnvironmentConnection(
456479 try {
457480 await refreshSavedEnvironmentMetadata(
458481 record,
482 bearerToken,
459483 client,
460484 options?.role ?? null,
461485 options?.serverConfig ?? null,
......@@ -552,6 +576,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P
552576
553577export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> {
554578 useSavedEnvironmentRegistryStore.getState().remove(environmentId);
579 await removeSavedEnvironmentBearerToken(environmentId);
555580 await disconnectSavedEnvironment(environmentId);
556581}
557582
......@@ -585,12 +610,30 @@ export async function addSavedEnvironment(input: {
585610 label: input.label.trim() || descriptor.label,
586611 wsBaseUrl: resolvedTarget.wsBaseUrl,
587612 httpBaseUrl: resolvedTarget.httpBaseUrl,
588 bearerToken: bearerSession.sessionToken,
589613 createdAt: isoNow(),
590614 lastConnectedAt: isoNow(),
591615 };
592616
617 await persistSavedEnvironmentRecord(record);
618 const didPersistBearerToken = await writeSavedEnvironmentBearerToken(
619 environmentId,
620 bearerSession.sessionToken,
621 );
622 if (!didPersistBearerToken) {
623 await ensureLocalApi().persistence.setSavedEnvironmentRegistry(
624 listSavedEnvironmentRecords().map((entry) => ({
625 environmentId: entry.environmentId,
626 label: entry.label,
627 httpBaseUrl: entry.httpBaseUrl,
628 wsBaseUrl: entry.wsBaseUrl,
629 createdAt: entry.createdAt,
630 lastConnectedAt: entry.lastConnectedAt,
631 })),
632 );
633 throw new Error("Unable to persist saved environment credentials.");
634 }
593635 await ensureSavedEnvironmentConnection(record, {
636 bearerToken: bearerSession.sessionToken,
594637 role: bearerSession.role,
595638 });
596639 useSavedEnvironmentRegistryStore.getState().upsert(record);
apps/web/src/hooks/useSettings.test.ts deleted-16
......@@ -1,16 +0,0 @@
1import { describe, expect, it } from "vitest";
2import { buildLegacyClientSettingsMigrationPatch } from "./useSettings";
3
4describe("buildLegacyClientSettingsMigrationPatch", () => {
5 it("migrates archive confirmation from legacy local settings", () => {
6 expect(
7 buildLegacyClientSettingsMigrationPatch({
8 confirmThreadArchive: true,
9 confirmThreadDelete: false,
10 }),
11 ).toEqual({
12 confirmThreadArchive: true,
13 confirmThreadDelete: false,
14 });
15 });
16});
apps/web/src/hooks/useSettings.ts+98-172
......@@ -9,33 +9,88 @@
99 * write. The hook transparently routes reads/writes to the correct backing
1010 * store.
1111 */
12import { useCallback, useMemo } from "react";
13import {
14 ServerSettings,
15 ServerSettingsPatch,
16 ModelSelection,
17 ThreadEnvMode,
18} from "@t3tools/contracts";
12import { useCallback, useMemo, useSyncExternalStore } from "react";
13import { ServerSettings, ServerSettingsPatch } from "@t3tools/contracts";
1914import {
2015 type ClientSettings,
21 ClientSettingsSchema,
2216 DEFAULT_CLIENT_SETTINGS,
2317 DEFAULT_UNIFIED_SETTINGS,
24 SidebarProjectSortOrder,
25 SidebarThreadSortOrder,
26 TimestampFormat,
2718 UnifiedSettings,
2819} from "@t3tools/contracts/settings";
2920import { ensureLocalApi } from "~/localApi";
30import { useLocalStorage } from "./useLocalStorage";
31import { normalizeCustomModelSlugs } from "~/modelSelection";
32import { Predicate, Schema, Struct } from "effect";
33import { DeepMutable } from "effect/Types";
21import { Struct } from "effect";
3422import { deepMerge } from "@t3tools/shared/Struct";
3523import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";
3624
37const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
38const OLD_SETTINGS_KEY = "t3code:app-settings:v1";
25const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
26
27const clientSettingsListeners = new Set<() => void>();
28let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
29let clientSettingsHydrated = false;
30let clientSettingsHydrationPromise: Promise<void> | null = null;
31
32function emitClientSettingsChange() {
33 for (const listener of clientSettingsListeners) {
34 listener();
35 }
36}
37
38function getClientSettingsSnapshot(): ClientSettings {
39 return clientSettingsSnapshot;
40}
41
42function replaceClientSettingsSnapshot(settings: ClientSettings): void {
43 clientSettingsSnapshot = settings;
44 emitClientSettingsChange();
45}
46
47function subscribeClientSettings(listener: () => void): () => void {
48 clientSettingsListeners.add(listener);
49 void hydrateClientSettings();
50 return () => {
51 clientSettingsListeners.delete(listener);
52 };
53}
54
55async function hydrateClientSettings(): Promise<void> {
56 if (clientSettingsHydrated) {
57 return;
58 }
59 if (clientSettingsHydrationPromise) {
60 return clientSettingsHydrationPromise;
61 }
62
63 const nextHydration = (async () => {
64 try {
65 const persistedSettings = await ensureLocalApi().persistence.getClientSettings();
66 if (persistedSettings) {
67 replaceClientSettingsSnapshot(persistedSettings);
68 }
69 } catch (error) {
70 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
71 } finally {
72 clientSettingsHydrated = true;
73 }
74 })();
75
76 const hydrationPromise = nextHydration.finally(() => {
77 if (clientSettingsHydrationPromise === hydrationPromise) {
78 clientSettingsHydrationPromise = null;
79 }
80 });
81 clientSettingsHydrationPromise = hydrationPromise;
82
83 return clientSettingsHydrationPromise;
84}
85
86function persistClientSettings(settings: ClientSettings): void {
87 replaceClientSettingsSnapshot(settings);
88 void ensureLocalApi()
89 .persistence.setClientSettings(settings)
90 .catch((error) => {
91 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error);
92 });
93}
3994
4095// ── Key sets for routing patches ─────────────────────────────────────
4196
......@@ -69,10 +124,10 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
69124
70125export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
71126 const serverSettings = useServerSettings();
72 const [clientSettings] = useLocalStorage(
73 CLIENT_SETTINGS_STORAGE_KEY,
74 DEFAULT_CLIENT_SETTINGS,
75 ClientSettingsSchema,
127 const clientSettings = useSyncExternalStore(
128 subscribeClientSettings,
129 getClientSettingsSnapshot,
130 () => DEFAULT_CLIENT_SETTINGS,
76131 );
77132
78133 const merged = useMemo<UnifiedSettings>(
......@@ -90,34 +145,28 @@ export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings)
90145 * Returns an updater that routes each key to the correct backing store.
91146 *
92147 * Server keys are optimistically patched in atom-backed server state, then
93 * persisted via RPC. Client keys go straight to localStorage.
148 * persisted via RPC. Client keys go through client persistence.
94149 */
95150export function useUpdateSettings() {
96 const [, setClientSettings] = useLocalStorage(
97 CLIENT_SETTINGS_STORAGE_KEY,
98 DEFAULT_CLIENT_SETTINGS,
99 ClientSettingsSchema,
100 );
151 const updateSettings = useCallback((patch: Partial<UnifiedSettings>) => {
152 const { serverPatch, clientPatch } = splitPatch(patch);
101153
102 const updateSettings = useCallback(
103 (patch: Partial<UnifiedSettings>) => {
104 const { serverPatch, clientPatch } = splitPatch(patch);
105
106 if (Object.keys(serverPatch).length > 0) {
107 const currentServerConfig = getServerConfig();
108 if (currentServerConfig) {
109 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
110 }
111 // Fire-and-forget RPC — push will reconcile on success
112 void ensureLocalApi().server.updateSettings(serverPatch);
154 if (Object.keys(serverPatch).length > 0) {
155 const currentServerConfig = getServerConfig();
156 if (currentServerConfig) {
157 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
113158 }
159 // Fire-and-forget RPC — push will reconcile on success
160 void ensureLocalApi().server.updateSettings(serverPatch);
161 }
114162
115 if (Object.keys(clientPatch).length > 0) {
116 setClientSettings((prev) => ({ ...prev, ...clientPatch }));
117 }
118 },
119 [setClientSettings],
120 );
163 if (Object.keys(clientPatch).length > 0) {
164 persistClientSettings({
165 ...getClientSettingsSnapshot(),
166 ...clientPatch,
167 });
168 }
169 }, []);
121170
122171 const resetSettings = useCallback(() => {
123172 updateSettings(DEFAULT_UNIFIED_SETTINGS);
......@@ -129,132 +178,9 @@ export function useUpdateSettings() {
129178 };
130179}
131180
132// ── One-time migration from localStorage ─────────────────────────────
133
134export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record<string, unknown>) {
135 const patch: DeepMutable<ServerSettingsPatch> = {};
136
137 if (Predicate.isBoolean(legacySettings.enableAssistantStreaming)) {
138 patch.enableAssistantStreaming = legacySettings.enableAssistantStreaming;
139 }
140
141 if (Schema.is(ThreadEnvMode)(legacySettings.defaultThreadEnvMode)) {
142 patch.defaultThreadEnvMode = legacySettings.defaultThreadEnvMode;
143 }
144
145 if (Schema.is(ModelSelection)(legacySettings.textGenerationModelSelection)) {
146 patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection;
147 }
148
149 if (typeof legacySettings.codexBinaryPath === "string") {
150 patch.providers ??= {};
151 patch.providers.codex ??= {};
152 patch.providers.codex.binaryPath = legacySettings.codexBinaryPath;
153 }
154
155 if (typeof legacySettings.codexHomePath === "string") {
156 patch.providers ??= {};
157 patch.providers.codex ??= {};
158 patch.providers.codex.homePath = legacySettings.codexHomePath;
159 }
160
161 if (Array.isArray(legacySettings.customCodexModels)) {
162 patch.providers ??= {};
163 patch.providers.codex ??= {};
164 patch.providers.codex.customModels = normalizeCustomModelSlugs(
165 legacySettings.customCodexModels,
166 new Set<string>(),
167 "codex",
168 );
169 }
170
171 if (Predicate.isString(legacySettings.claudeBinaryPath)) {
172 patch.providers ??= {};
173 patch.providers.claudeAgent ??= {};
174 patch.providers.claudeAgent.binaryPath = legacySettings.claudeBinaryPath;
175 }
176
177 if (Array.isArray(legacySettings.customClaudeModels)) {
178 patch.providers ??= {};
179 patch.providers.claudeAgent ??= {};
180 patch.providers.claudeAgent.customModels = normalizeCustomModelSlugs(
181 legacySettings.customClaudeModels,
182 new Set<string>(),
183 "claudeAgent",
184 );
185 }
186
187 return patch;
188}
189
190export function buildLegacyClientSettingsMigrationPatch(
191 legacySettings: Record<string, unknown>,
192): Partial<DeepMutable<ClientSettings>> {
193 const patch: Partial<DeepMutable<ClientSettings>> = {};
194
195 if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) {
196 patch.confirmThreadArchive = legacySettings.confirmThreadArchive;
197 }
198
199 if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) {
200 patch.confirmThreadDelete = legacySettings.confirmThreadDelete;
201 }
202
203 if (Predicate.isBoolean(legacySettings.diffWordWrap)) {
204 patch.diffWordWrap = legacySettings.diffWordWrap;
205 }
206
207 if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) {
208 patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder;
209 }
210
211 if (Schema.is(SidebarThreadSortOrder)(legacySettings.sidebarThreadSortOrder)) {
212 patch.sidebarThreadSortOrder = legacySettings.sidebarThreadSortOrder;
213 }
214
215 if (Schema.is(TimestampFormat)(legacySettings.timestampFormat)) {
216 patch.timestampFormat = legacySettings.timestampFormat;
217 }
218
219 return patch;
220}
221
222/**
223 * Call once on app startup.
224 * If the legacy localStorage key exists, migrate its values to the new server
225 * and client storage formats, then remove the legacy key so this only runs once.
226 */
227export function migrateLocalSettingsToServer(): void {
228 if (typeof window === "undefined") return;
229
230 const raw = localStorage.getItem(OLD_SETTINGS_KEY);
231 if (!raw) return;
232
233 try {
234 const old = JSON.parse(raw);
235 if (!Predicate.isObject(old)) return;
236
237 // Migrate server-relevant keys via RPC
238 const serverPatch = buildLegacyServerSettingsMigrationPatch(old);
239 if (Object.keys(serverPatch).length > 0) {
240 const api = ensureLocalApi();
241 void api.server.updateSettings(serverPatch);
242 }
243
244 // Migrate client-only keys to the new localStorage key
245 const clientPatch = buildLegacyClientSettingsMigrationPatch(old);
246 if (Object.keys(clientPatch).length > 0) {
247 const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY);
248 const current = existing ? (JSON.parse(existing) as Record<string, unknown>) : {};
249 localStorage.setItem(
250 CLIENT_SETTINGS_STORAGE_KEY,
251 JSON.stringify({ ...current, ...clientPatch }),
252 );
253 }
254 } catch (error) {
255 console.error("[MIGRATION] Error migrating local settings:", error);
256 } finally {
257 // Remove the legacy key regardless to keep migration one-shot behavior.
258 localStorage.removeItem(OLD_SETTINGS_KEY);
259 }
181export function __resetClientSettingsPersistenceForTests(): void {
182 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
183 clientSettingsHydrated = false;
184 clientSettingsHydrationPromise = null;
185 clientSettingsListeners.clear();
260186}
apps/web/src/localApi.test.ts+155-1
......@@ -137,9 +137,36 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn
137137 return testGlobal.window;
138138}
139139
140function createLocalStorageStub(): Storage {
141 const store = new Map<string, string>();
142 return {
143 getItem: (key) => store.get(key) ?? null,
144 setItem: (key, value) => {
145 store.set(key, value);
146 },
147 removeItem: (key) => {
148 store.delete(key);
149 },
150 clear: () => {
151 store.clear();
152 },
153 key: (index) => [...store.keys()][index] ?? null,
154 get length() {
155 return store.size;
156 },
157 };
158}
159
140160function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge {
141161 return {
142162 getLocalEnvironmentBootstrap: () => null,
163 getClientSettings: async () => null,
164 setClientSettings: async () => undefined,
165 getSavedEnvironmentRegistry: async () => [],
166 setSavedEnvironmentRegistry: async () => undefined,
167 getSavedEnvironmentSecret: async () => null,
168 setSavedEnvironmentSecret: async () => true,
169 removeSavedEnvironmentSecret: async () => undefined,
143170 getServerExposureState: async () => ({
144171 mode: "local-only",
145172 endpointUrl: null,
......@@ -242,7 +269,12 @@ beforeEach(() => {
242269 terminalEventListeners.clear();
243270 orchestrationEventListeners.clear();
244271 gitStatusListeners.clear();
245 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
272 const testWindow = getWindowForTest();
273 Reflect.deleteProperty(testWindow, "desktopBridge");
274 Object.defineProperty(testWindow, "localStorage", {
275 configurable: true,
276 value: createLocalStorageStub(),
277 });
246278});
247279
248280afterEach(() => {
......@@ -463,4 +495,126 @@ describe("wsApi", () => {
463495 await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename");
464496 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
465497 });
498
499 it("reads and writes persistence through the desktop bridge when available", async () => {
500 const getClientSettings = vi.fn().mockResolvedValue({
501 confirmThreadArchive: true,
502 confirmThreadDelete: false,
503 diffWordWrap: true,
504 sidebarProjectSortOrder: "manual",
505 sidebarThreadSortOrder: "created_at",
506 timestampFormat: "24-hour",
507 });
508 const setClientSettings = vi.fn().mockResolvedValue(undefined);
509 const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]);
510 const setSavedEnvironmentRegistry = vi.fn().mockResolvedValue(undefined);
511 const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("bearer-token");
512 const setSavedEnvironmentSecret = vi.fn().mockResolvedValue(true);
513 const removeSavedEnvironmentSecret = vi.fn().mockResolvedValue(undefined);
514 getWindowForTest().desktopBridge = makeDesktopBridge({
515 getClientSettings,
516 setClientSettings,
517 getSavedEnvironmentRegistry,
518 setSavedEnvironmentRegistry,
519 getSavedEnvironmentSecret,
520 setSavedEnvironmentSecret,
521 removeSavedEnvironmentSecret,
522 });
523
524 const { createLocalApi } = await import("./localApi");
525 const api = createLocalApi(rpcClientMock as never);
526
527 await api.persistence.getClientSettings();
528 await api.persistence.setClientSettings({
529 confirmThreadArchive: true,
530 confirmThreadDelete: false,
531 diffWordWrap: true,
532 sidebarProjectSortOrder: "manual",
533 sidebarThreadSortOrder: "created_at",
534 timestampFormat: "24-hour",
535 });
536 await api.persistence.getSavedEnvironmentRegistry();
537 await api.persistence.setSavedEnvironmentRegistry([]);
538 await api.persistence.getSavedEnvironmentSecret(EnvironmentId.makeUnsafe("environment-local"));
539 await api.persistence.setSavedEnvironmentSecret(
540 EnvironmentId.makeUnsafe("environment-local"),
541 "bearer-token",
542 );
543 await api.persistence.removeSavedEnvironmentSecret(
544 EnvironmentId.makeUnsafe("environment-local"),
545 );
546
547 expect(getClientSettings).toHaveBeenCalledWith();
548 expect(setClientSettings).toHaveBeenCalledWith({
549 confirmThreadArchive: true,
550 confirmThreadDelete: false,
551 diffWordWrap: true,
552 sidebarProjectSortOrder: "manual",
553 sidebarThreadSortOrder: "created_at",
554 timestampFormat: "24-hour",
555 });
556 expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith();
557 expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]);
558 expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local");
559 expect(setSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local", "bearer-token");
560 expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local");
561 });
562
563 it("falls back to browser storage for persistence when the desktop bridge is missing", async () => {
564 const { createLocalApi } = await import("./localApi");
565 const api = createLocalApi(rpcClientMock as never);
566
567 await api.persistence.setClientSettings({
568 confirmThreadArchive: true,
569 confirmThreadDelete: false,
570 diffWordWrap: true,
571 sidebarProjectSortOrder: "manual",
572 sidebarThreadSortOrder: "created_at",
573 timestampFormat: "24-hour",
574 });
575 await api.persistence.setSavedEnvironmentRegistry([
576 {
577 environmentId: EnvironmentId.makeUnsafe("environment-local"),
578 label: "Primary",
579 httpBaseUrl: "http://localhost:3000",
580 wsBaseUrl: "ws://localhost:3000",
581 createdAt: "2026-04-09T00:00:00.000Z",
582 lastConnectedAt: null,
583 },
584 ]);
585 await api.persistence.setSavedEnvironmentSecret(
586 EnvironmentId.makeUnsafe("environment-local"),
587 "bearer-token",
588 );
589
590 await expect(api.persistence.getClientSettings()).resolves.toEqual({
591 confirmThreadArchive: true,
592 confirmThreadDelete: false,
593 diffWordWrap: true,
594 sidebarProjectSortOrder: "manual",
595 sidebarThreadSortOrder: "created_at",
596 timestampFormat: "24-hour",
597 });
598 await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([
599 {
600 environmentId: EnvironmentId.makeUnsafe("environment-local"),
601 label: "Primary",
602 httpBaseUrl: "http://localhost:3000",
603 wsBaseUrl: "ws://localhost:3000",
604 createdAt: "2026-04-09T00:00:00.000Z",
605 lastConnectedAt: null,
606 },
607 ]);
608 await expect(
609 api.persistence.getSavedEnvironmentSecret(EnvironmentId.makeUnsafe("environment-local")),
610 ).resolves.toBe("bearer-token");
611
612 await api.persistence.removeSavedEnvironmentSecret(
613 EnvironmentId.makeUnsafe("environment-local"),
614 );
615
616 await expect(
617 api.persistence.getSavedEnvironmentSecret(EnvironmentId.makeUnsafe("environment-local")),
618 ).resolves.toBeNull();
619 });
466620});
apps/web/src/localApi.ts+55
......@@ -14,6 +14,15 @@ import {
1414} from "./environments/runtime";
1515import { type WsRpcClient } from "./rpc/wsRpcClient";
1616import { showContextMenuFallback } from "./contextMenuFallback";
17import {
18 readBrowserClientSettings,
19 readBrowserSavedEnvironmentRegistry,
20 readBrowserSavedEnvironmentSecret,
21 removeBrowserSavedEnvironmentSecret,
22 writeBrowserClientSettings,
23 writeBrowserSavedEnvironmentRegistry,
24 writeBrowserSavedEnvironmentSecret,
25} from "./clientPersistenceStorage";
1726
1827let cachedApi: LocalApi | undefined;
1928
......@@ -56,6 +65,50 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
5665 return showContextMenuFallback(items, position);
5766 },
5867 },
68 persistence: {
69 getClientSettings: async () => {
70 if (window.desktopBridge) {
71 return window.desktopBridge.getClientSettings();
72 }
73 return readBrowserClientSettings();
74 },
75 setClientSettings: async (settings) => {
76 if (window.desktopBridge) {
77 return window.desktopBridge.setClientSettings(settings);
78 }
79 writeBrowserClientSettings(settings);
80 },
81 getSavedEnvironmentRegistry: async () => {
82 if (window.desktopBridge) {
83 return window.desktopBridge.getSavedEnvironmentRegistry();
84 }
85 return readBrowserSavedEnvironmentRegistry();
86 },
87 setSavedEnvironmentRegistry: async (records) => {
88 if (window.desktopBridge) {
89 return window.desktopBridge.setSavedEnvironmentRegistry(records);
90 }
91 writeBrowserSavedEnvironmentRegistry(records);
92 },
93 getSavedEnvironmentSecret: async (environmentId) => {
94 if (window.desktopBridge) {
95 return window.desktopBridge.getSavedEnvironmentSecret(environmentId);
96 }
97 return readBrowserSavedEnvironmentSecret(environmentId);
98 },
99 setSavedEnvironmentSecret: async (environmentId, secret) => {
100 if (window.desktopBridge) {
101 return window.desktopBridge.setSavedEnvironmentSecret(environmentId, secret);
102 }
103 return writeBrowserSavedEnvironmentSecret(environmentId, secret);
104 },
105 removeSavedEnvironmentSecret: async (environmentId) => {
106 if (window.desktopBridge) {
107 return window.desktopBridge.removeSavedEnvironmentSecret(environmentId);
108 }
109 removeBrowserSavedEnvironmentSecret(environmentId);
110 },
111 },
59112 server: {
60113 getConfig: rpcClient.server.getConfig,
61114 refreshProviders: rpcClient.server.refreshProviders,
......@@ -89,6 +142,8 @@ export function ensureLocalApi(): LocalApi {
89142
90143export async function __resetLocalApiForTests() {
91144 cachedApi = undefined;
145 const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings");
146 __resetClientSettingsPersistenceForTests();
92147 await resetEnvironmentServiceForTests();
93148 resetGitStatusStateForTests();
94149 resetRequestLatencyStateForTests();
apps/web/src/routes/__root.tsx-2
......@@ -31,7 +31,6 @@ import {
3131} from "../rpc/serverState";
3232import { useStore } from "../store";
3333import { useUiStateStore } from "../uiStateStore";
34import { migrateLocalSettingsToServer } from "../hooks/useSettings";
3534import { syncBrowserChromeTheme } from "../hooks/useTheme";
3635import {
3736 ensureEnvironmentConnectionBootstrapped,
......@@ -213,7 +212,6 @@ function EventRouter() {
213212
214213 updatePrimaryEnvironmentDescriptor(payload.environment);
215214 setActiveEnvironmentId(payload.environment.environmentId);
216 migrateLocalSettingsToServer();
217215 void (async () => {
218216 await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId);
219217 if (disposedRef.current) {
apps/web/vite.config.ts+2
......@@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" };
77
88const port = Number(process.env.PORT ?? 5733);
99const host = process.env.HOST?.trim() || "localhost";
10const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim();
1011const configuredWsUrl = process.env.VITE_WS_URL?.trim();
1112const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
1213
......@@ -58,6 +59,7 @@ export default defineConfig({
5859 include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"],
5960 },
6061 define: {
62 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
6163 // In dev mode, tell the web app where the WebSocket server lives
6264 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
6365 "import.meta.env.APP_VERSION": JSON.stringify(pkg.version),
packages/contracts/src/ipc.ts+31-1
......@@ -49,8 +49,9 @@ import type {
4949 OrchestrationEvent,
5050 OrchestrationReadModel,
5151} from "./orchestration";
52import type { EnvironmentId } from "./baseSchemas";
5253import { EditorId } from "./editor";
53import { ServerSettings, ServerSettingsPatch } from "./settings";
54import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings";
5455
5556export interface ContextMenuItem<T extends string = string> {
5657 id: T;
......@@ -112,6 +113,15 @@ export interface DesktopEnvironmentBootstrap {
112113 bootstrapToken?: string;
113114}
114115
116export interface PersistedSavedEnvironmentRecord {
117 environmentId: EnvironmentId;
118 label: string;
119 wsBaseUrl: string;
120 httpBaseUrl: string;
121 createdAt: string;
122 lastConnectedAt: string | null;
123}
124
115125export type DesktopServerExposureMode = "local-only" | "network-accessible";
116126
117127export interface DesktopServerExposureState {
......@@ -122,6 +132,15 @@ export interface DesktopServerExposureState {
122132
123133export interface DesktopBridge {
124134 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
135 getClientSettings: () => Promise<ClientSettings | null>;
136 setClientSettings: (settings: ClientSettings) => Promise<void>;
137 getSavedEnvironmentRegistry: () => Promise<readonly PersistedSavedEnvironmentRecord[]>;
138 setSavedEnvironmentRegistry: (
139 records: readonly PersistedSavedEnvironmentRecord[],
140 ) => Promise<void>;
141 getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>;
142 setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>;
143 removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;
125144 getServerExposureState: () => Promise<DesktopServerExposureState>;
126145 setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>;
127146 pickFolder: () => Promise<string | null>;
......@@ -165,6 +184,17 @@ export interface LocalApi {
165184 position?: { x: number; y: number },
166185 ) => Promise<T | null>;
167186 };
187 persistence: {
188 getClientSettings: () => Promise<ClientSettings | null>;
189 setClientSettings: (settings: ClientSettings) => Promise<void>;
190 getSavedEnvironmentRegistry: () => Promise<readonly PersistedSavedEnvironmentRecord[]>;
191 setSavedEnvironmentRegistry: (
192 records: readonly PersistedSavedEnvironmentRecord[],
193 ) => Promise<void>;
194 getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>;
195 setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>;
196 removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>;
197 };
168198 server: {
169199 getConfig: () => Promise<ServerConfig>;
170200 refreshProviders: () => Promise<ServerProviderUpdatedPayload>;
scripts/dev-runner.test.ts+2
......@@ -84,6 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
8484
8585 assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3"));
8686 assert.equal(env.T3CODE_PORT, "4222");
87 assert.equal(env.VITE_HTTP_URL, "http://localhost:4222");
8788 assert.equal(env.VITE_WS_URL, "ws://localhost:4222");
8889 assert.equal(env.T3CODE_NO_BROWSER, "1");
8990 assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0");
......@@ -183,6 +184,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
183184 assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733");
184185 assert.equal(env.HOST, "127.0.0.1");
185186 assert.equal(env.T3CODE_PORT, "4222");
187 assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222");
186188 assert.equal(env.T3CODE_MODE, undefined);
187189 assert.equal(env.T3CODE_NO_BROWSER, undefined);
188190 assert.equal(env.T3CODE_HOST, undefined);
scripts/dev-runner.ts+2
......@@ -159,9 +159,11 @@ export function createDevRunnerEnv({
159159
160160 if (!isDesktopMode) {
161161 output.T3CODE_PORT = String(serverPort);
162 output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
162163 output.VITE_WS_URL = `ws://localhost:${serverPort}`;
163164 } else {
164165 output.T3CODE_PORT = String(serverPort);
166 output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
165167 output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
166168 delete output.T3CODE_MODE;
167169 delete output.T3CODE_NO_BROWSER;