| author | |
| committer | |
| log | e32077cee78c6867cc79b2964c82e4c12d3e213f |
| tree | 00e543975f692fe10f64e50bf1c38393d11cfd51 |
| parent | 5d9eb183123466fdf4b972fe33a4823d089370d3 |
| signature | Signed by PGP key B5690EEEBB952194 |
25 files changed, 1667 insertions(+), 277 deletions(-)
apps/desktop/src/clientPersistence.test.ts created+234| ... | ... | @@ -0,0 +1,234 @@ |
| 1 | import * as fs from "node:fs"; | |
| 2 | import * as os from "node:os"; | |
| 3 | import * as path from "node:path"; | |
| 4 | ||
| 5 | import { | |
| 6 | EnvironmentId, | |
| 7 | type ClientSettings, | |
| 8 | type PersistedSavedEnvironmentRecord, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { afterEach, describe, expect, it } from "vitest"; | |
| 11 | ||
| 12 | import { | |
| 13 | readClientSettings, | |
| 14 | readSavedEnvironmentRegistry, | |
| 15 | readSavedEnvironmentSecret, | |
| 16 | removeSavedEnvironmentSecret, | |
| 17 | writeClientSettings, | |
| 18 | writeSavedEnvironmentRegistry, | |
| 19 | writeSavedEnvironmentSecret, | |
| 20 | type DesktopSecretStorage, | |
| 21 | } from "./clientPersistence"; | |
| 22 | ||
| 23 | const tempDirectories: string[] = []; | |
| 24 | ||
| 25 | afterEach(() => { | |
| 26 | for (const directory of tempDirectories.splice(0)) { | |
| 27 | fs.rmSync(directory, { recursive: true, force: true }); | |
| 28 | } | |
| 29 | }); | |
| 30 | ||
| 31 | function 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 | ||
| 37 | function 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 | ||
| 51 | const clientSettings: ClientSettings = { | |
| 52 | confirmThreadArchive: true, | |
| 53 | confirmThreadDelete: false, | |
| 54 | diffWordWrap: true, | |
| 55 | sidebarProjectSortOrder: "manual", | |
| 56 | sidebarThreadSortOrder: "created_at", | |
| 57 | timestampFormat: "24-hour", | |
| 58 | }; | |
| 59 | ||
| 60 | const 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 | ||
| 69 | describe("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 @@ |
| 1 | import * as FS from "node:fs"; | |
| 2 | import * as Path from "node:path"; | |
| 3 | ||
| 4 | import type { ClientSettings, PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; | |
| 5 | import { Predicate } from "effect"; | |
| 6 | ||
| 7 | interface ClientSettingsDocument { | |
| 8 | readonly settings: ClientSettings; | |
| 9 | } | |
| 10 | ||
| 11 | interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { | |
| 12 | readonly encryptedBearerToken?: string; | |
| 13 | } | |
| 14 | ||
| 15 | interface SavedEnvironmentRegistryDocument { | |
| 16 | readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; | |
| 17 | } | |
| 18 | ||
| 19 | export interface DesktopSecretStorage { | |
| 20 | readonly isEncryptionAvailable: () => boolean; | |
| 21 | readonly encryptString: (value: string) => Buffer; | |
| 22 | readonly decryptString: (value: Buffer) => string; | |
| 23 | } | |
| 24 | ||
| 25 | function 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 | ||
| 36 | function 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 | ||
| 44 | function 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 | ||
| 59 | function 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 | ||
| 72 | function 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 | ||
| 85 | export function readClientSettings(settingsPath: string): ClientSettings | null { | |
| 86 | return readJsonFile<ClientSettingsDocument>(settingsPath)?.settings ?? null; | |
| 87 | } | |
| 88 | ||
| 89 | export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { | |
| 90 | writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); | |
| 91 | } | |
| 92 | ||
| 93 | export function readSavedEnvironmentRegistry( | |
| 94 | registryPath: string, | |
| 95 | ): readonly PersistedSavedEnvironmentRecord[] { | |
| 96 | return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => | |
| 97 | toPersistedSavedEnvironmentRecord(record), | |
| 98 | ); | |
| 99 | } | |
| 100 | ||
| 101 | export 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 | ||
| 131 | export 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 | ||
| 155 | export 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 | ||
| 193 | export 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 { |
| 14 | 14 | nativeImage, |
| 15 | 15 | nativeTheme, |
| 16 | 16 | protocol, |
| 17 | safeStorage, | |
| 17 | 18 | shell, |
| 18 | 19 | } from "electron"; |
| 19 | 20 | import type { MenuItemConstructorOptions } from "electron"; |
| 20 | 21 | import * as Effect from "effect/Effect"; |
| 21 | 22 | import type { |
| 23 | ClientSettings, | |
| 22 | 24 | DesktopTheme, |
| 23 | 25 | DesktopServerExposureMode, |
| 24 | 26 | DesktopServerExposureState, |
| 27 | PersistedSavedEnvironmentRecord, | |
| 25 | 28 | DesktopUpdateActionResult, |
| 26 | 29 | DesktopUpdateCheckResult, |
| 27 | 30 | DesktopUpdateState, |
| ... | ... | @@ -38,6 +41,15 @@ import { |
| 38 | 41 | setDesktopServerExposurePreference, |
| 39 | 42 | writeDesktopSettings, |
| 40 | 43 | } from "./desktopSettings"; |
| 44 | import { | |
| 45 | readClientSettings, | |
| 46 | readSavedEnvironmentRegistry, | |
| 47 | readSavedEnvironmentSecret, | |
| 48 | removeSavedEnvironmentSecret, | |
| 49 | writeClientSettings, | |
| 50 | writeSavedEnvironmentRegistry, | |
| 51 | writeSavedEnvironmentSecret, | |
| 52 | } from "./clientPersistence"; | |
| 41 | 53 | import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; |
| 42 | 54 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 43 | 55 | import { resolveDesktopServerExposure } from "./serverExposure"; |
| ... | ... | @@ -71,11 +83,20 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 71 | 83 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 72 | 84 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 73 | 85 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; |
| 86 | const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | |
| 87 | const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | |
| 88 | const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | |
| 89 | const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | |
| 90 | const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | |
| 91 | const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | |
| 92 | const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | |
| 74 | 93 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; |
| 75 | 94 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 76 | 95 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 77 | 96 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 78 | 97 | const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); |
| 98 | const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); | |
| 99 | const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); | |
| 79 | 100 | const DESKTOP_SCHEME = "t3"; |
| 80 | 101 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 81 | 102 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| ... | ... | @@ -204,6 +225,14 @@ function getDesktopServerExposureState(): DesktopServerExposureState { |
| 204 | 225 | }; |
| 205 | 226 | } |
| 206 | 227 | |
| 228 | function 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 | ||
| 207 | 236 | function resolveAdvertisedHostOverride(): string | undefined { |
| 208 | 237 | const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); |
| 209 | 238 | return override && override.length > 0 ? override : undefined; |
| ... | ... | @@ -1349,6 +1378,86 @@ function registerIpcHandlers(): void { |
| 1349 | 1378 | } as const; |
| 1350 | 1379 | }); |
| 1351 | 1380 | |
| 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 | ||
| 1352 | 1461 | ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); |
| 1353 | 1462 | ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); |
| 1354 | 1463 |
apps/desktop/src/preload.ts+18| ... | ... | @@ -13,6 +13,13 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 15 | 15 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; |
| 16 | const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | |
| 17 | const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | |
| 18 | const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | |
| 19 | const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | |
| 20 | const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | |
| 21 | const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | |
| 22 | const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | |
| 16 | 23 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; |
| 17 | 24 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 18 | 25 | |
| ... | ... | @@ -24,6 +31,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 24 | 31 | } |
| 25 | 32 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; |
| 26 | 33 | }, |
| 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), | |
| 27 | 45 | getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), |
| 28 | 46 | setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), |
| 29 | 47 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
apps/web/src/clientPersistenceStorage.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; | |
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 3 | ||
| 4 | const testEnvironmentId = EnvironmentId.makeUnsafe("environment-1"); | |
| 5 | ||
| 6 | const 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 | ||
| 15 | function 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 | ||
| 35 | function 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 | ||
| 45 | afterEach(() => { | |
| 46 | vi.resetModules(); | |
| 47 | vi.unstubAllGlobals(); | |
| 48 | vi.restoreAllMocks(); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("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 @@ |
| 1 | import { | |
| 2 | ClientSettingsSchema, | |
| 3 | EnvironmentId, | |
| 4 | type ClientSettings, | |
| 5 | type EnvironmentId as EnvironmentIdValue, | |
| 6 | type PersistedSavedEnvironmentRecord, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; | |
| 11 | ||
| 12 | export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; | |
| 13 | export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; | |
| 14 | ||
| 15 | const 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 | }); | |
| 24 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; | |
| 25 | ||
| 26 | const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ | |
| 27 | version: Schema.optionalKey(Schema.Number), | |
| 28 | records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)), | |
| 29 | }); | |
| 30 | type BrowserSavedEnvironmentRegistryDocument = | |
| 31 | typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type; | |
| 32 | ||
| 33 | function hasWindow(): boolean { | |
| 34 | return typeof window !== "undefined"; | |
| 35 | } | |
| 36 | ||
| 37 | function 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 | ||
| 50 | export 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 | ||
| 62 | export function writeBrowserClientSettings(settings: ClientSettings): void { | |
| 63 | if (!hasWindow()) { | |
| 64 | return; | |
| 65 | } | |
| 66 | ||
| 67 | setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); | |
| 68 | } | |
| 69 | ||
| 70 | function 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 | ||
| 86 | function 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 | ||
| 100 | function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray<BrowserSavedEnvironmentRecord> { | |
| 101 | return readBrowserSavedEnvironmentRegistryDocument().records ?? []; | |
| 102 | } | |
| 103 | ||
| 104 | function writeBrowserSavedEnvironmentRecords( | |
| 105 | records: ReadonlyArray<BrowserSavedEnvironmentRecord>, | |
| 106 | ): void { | |
| 107 | writeBrowserSavedEnvironmentRegistryDocument({ | |
| 108 | version: 1, | |
| 109 | records, | |
| 110 | }); | |
| 111 | } | |
| 112 | ||
| 113 | export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray<PersistedSavedEnvironmentRecord> { | |
| 114 | return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) => | |
| 115 | toPersistedSavedEnvironmentRecord(record), | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | export 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 | ||
| 145 | export function readBrowserSavedEnvironmentSecret( | |
| 146 | environmentId: EnvironmentIdValue, | |
| 147 | ): string | null { | |
| 148 | return ( | |
| 149 | readBrowserSavedEnvironmentRecordsWithSecrets().find( | |
| 150 | (record) => record.environmentId === environmentId, | |
| 151 | )?.bearerToken ?? null | |
| 152 | ); | |
| 153 | } | |
| 154 | ||
| 155 | export 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 | ||
| 183 | export 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", () => ({ |
| 17 | 17 | })); |
| 18 | 18 | |
| 19 | 19 | vi.mock("../localApi", () => ({ |
| 20 | ensureLocalApi: vi.fn(() => { | |
| 21 | throw new Error("ensureLocalApi not implemented in browser test"); | |
| 22 | }), | |
| 20 | 23 | readLocalApi: readLocalApiMock, |
| 21 | 24 | })); |
| 22 | 25 |
apps/web/src/components/GitActionsControl.browser.tsx+3| ... | ... | @@ -126,6 +126,9 @@ vi.mock("~/lib/gitStatusState", () => ({ |
| 126 | 126 | })); |
| 127 | 127 | |
| 128 | 128 | vi.mock("~/localApi", () => ({ |
| 129 | ensureLocalApi: vi.fn(() => { | |
| 130 | throw new Error("ensureLocalApi not implemented in browser test"); | |
| 131 | }), | |
| 129 | 132 | readLocalApi: vi.fn(() => null), |
| 130 | 133 | })); |
| 131 | 134 |
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+3| ... | ... | @@ -113,6 +113,9 @@ vi.mock("~/environmentApi", () => ({ |
| 113 | 113 | })); |
| 114 | 114 | |
| 115 | 115 | vi.mock("~/localApi", () => ({ |
| 116 | ensureLocalApi: vi.fn(() => { | |
| 117 | throw new Error("ensureLocalApi not implemented in browser test"); | |
| 118 | }), | |
| 116 | 119 | readLocalApi: readLocalApiMock, |
| 117 | 120 | })); |
| 118 | 121 |
apps/web/src/components/settings/SettingsPanels.browser.tsx+7| ... | ... | @@ -282,6 +282,13 @@ const createDesktopBridgeStub = (overrides?: { |
| 282 | 282 | wsBaseUrl: "ws://127.0.0.1:3773", |
| 283 | 283 | bootstrapToken: "desktop-bootstrap-token", |
| 284 | 284 | }), |
| 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), | |
| 285 | 292 | getServerExposureState: vi.fn().mockResolvedValue( |
| 286 | 293 | overrides?.serverExposureState ?? { |
| 287 | 294 | mode: "local-only", |
apps/web/src/environments/primary/bootstrap.test.ts+26| ... | ... | @@ -110,6 +110,32 @@ describe("environmentBootstrap", () => { |
| 110 | 110 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); |
| 111 | 111 | }); |
| 112 | 112 | |
| 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 | ||
| 113 | 139 | it("uses the current origin as the descriptor base for local dev environments", async () => { |
| 114 | 140 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); |
| 115 | 141 | vi.stubGlobal("fetch", fetchMock); |
apps/web/src/environments/primary/target.ts+23-7| ... | ... | @@ -16,6 +16,15 @@ function normalizeBaseUrl(rawValue: string): string { |
| 16 | 16 | return new URL(rawValue, window.location.origin).toString(); |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | function 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 | ||
| 19 | 28 | function normalizeHostname(hostname: string): string { |
| 20 | 29 | return hostname |
| 21 | 30 | .trim() |
| ... | ... | @@ -54,22 +63,29 @@ function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { |
| 54 | 63 | } |
| 55 | 64 | |
| 56 | 65 | function 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; | |
| 59 | 68 | |
| 60 | 69 | if (!configuredHttpBaseUrl && !configuredWsBaseUrl) { |
| 61 | 70 | return null; |
| 62 | 71 | } |
| 63 | 72 | |
| 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:")); | |
| 67 | 83 | |
| 68 | 84 | return { |
| 69 | 85 | source: "configured", |
| 70 | 86 | target: { |
| 71 | httpBaseUrl: normalizeBaseUrl(configuredHttpBaseUrl), | |
| 72 | wsBaseUrl: normalizeBaseUrl(configuredWsBaseUrl), | |
| 87 | httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), | |
| 88 | wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), | |
| 73 | 89 | }, |
| 74 | 90 | }; |
| 75 | 91 | } |
apps/web/src/environments/runtime/catalog.test.ts+94-4| ... | ... | @@ -1,17 +1,43 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { afterEach, describe, expect, it } from "vitest"; | |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | type LocalApi, | |
| 4 | type PersistedSavedEnvironmentRecord, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | 7 | |
| 4 | 8 | import { |
| 5 | 9 | resetSavedEnvironmentRegistryStoreForTests, |
| 6 | 10 | resetSavedEnvironmentRuntimeStoreForTests, |
| 7 | 11 | useSavedEnvironmentRegistryStore, |
| 8 | 12 | useSavedEnvironmentRuntimeStore, |
| 13 | waitForSavedEnvironmentRegistryHydration, | |
| 9 | 14 | } from "./catalog"; |
| 10 | 15 | |
| 11 | 16 | describe("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 () => { | |
| 13 | 36 | resetSavedEnvironmentRegistryStoreForTests(); |
| 14 | 37 | resetSavedEnvironmentRuntimeStoreForTests(); |
| 38 | const { __resetLocalApiForTests } = await import("../../localApi"); | |
| 39 | await __resetLocalApiForTests(); | |
| 40 | vi.unstubAllGlobals(); | |
| 15 | 41 | }); |
| 16 | 42 | |
| 17 | 43 | it("resets the saved environment registry store state", () => { |
| ... | ... | @@ -22,7 +48,6 @@ describe("environment runtime catalog stores", () => { |
| 22 | 48 | label: "Remote environment", |
| 23 | 49 | httpBaseUrl: "https://remote.example.com/", |
| 24 | 50 | wsBaseUrl: "wss://remote.example.com/", |
| 25 | bearerToken: "token", | |
| 26 | 51 | createdAt: "2026-04-09T00:00:00.000Z", |
| 27 | 52 | lastConnectedAt: null, |
| 28 | 53 | }); |
| ... | ... | @@ -48,4 +73,69 @@ describe("environment runtime catalog stores", () => { |
| 48 | 73 | |
| 49 | 74 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); |
| 50 | 75 | }); |
| 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 | }); | |
| 51 | 141 | }); |
apps/web/src/environments/runtime/catalog.ts+158-68| ... | ... | @@ -3,22 +3,19 @@ import type { |
| 3 | 3 | AuthSessionRole, |
| 4 | 4 | EnvironmentId, |
| 5 | 5 | ExecutionEnvironmentDescriptor, |
| 6 | PersistedSavedEnvironmentRecord, | |
| 6 | 7 | ServerConfig, |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import { create } from "zustand"; |
| 9 | import { createJSONStorage, persist } from "zustand/middleware"; | |
| 10 | 10 | |
| 11 | import { resolveStorage } from "../../lib/storage"; | |
| 11 | import { ensureLocalApi } from "../../localApi"; | |
| 12 | 12 | import { getPrimaryKnownEnvironment } from "../primary"; |
| 13 | 13 | |
| 14 | const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; | |
| 15 | ||
| 16 | 14 | export interface SavedEnvironmentRecord { |
| 17 | 15 | readonly environmentId: EnvironmentId; |
| 18 | 16 | readonly label: string; |
| 19 | 17 | readonly wsBaseUrl: string; |
| 20 | 18 | readonly httpBaseUrl: string; |
| 21 | readonly bearerToken: string; | |
| 22 | 19 | readonly createdAt: string; |
| 23 | 20 | readonly lastConnectedAt: string | null; |
| 24 | 21 | } |
| ... | ... | @@ -34,62 +31,133 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { |
| 34 | 31 | readonly reset: () => void; |
| 35 | 32 | } |
| 36 | 33 | |
| 37 | function createSavedEnvironmentRegistryStorage() { | |
| 38 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); | |
| 34 | let savedEnvironmentRegistryHydrated = false; | |
| 35 | let savedEnvironmentRegistryHydrationPromise: Promise<void> | null = null; | |
| 36 | ||
| 37 | function 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 | }; | |
| 39 | 48 | } |
| 40 | 49 | |
| 41 | export 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 | }), | |
| 50 | function valuesOfSavedEnvironmentRegistry( | |
| 51 | byId: Record<EnvironmentId, SavedEnvironmentRecord>, | |
| 52 | ): ReadonlyArray<SavedEnvironmentRecord> { | |
| 53 | return Object.values(byId) as ReadonlyArray<SavedEnvironmentRecord>; | |
| 54 | } | |
| 55 | ||
| 56 | function 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 | ||
| 74 | function 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, | |
| 87 | 83 | }, |
| 88 | ), | |
| 89 | ); | |
| 84 | }); | |
| 85 | } | |
| 86 | ||
| 87 | async 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 | ||
| 116 | export 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 | })); | |
| 90 | 158 | |
| 91 | 159 | export function hasSavedEnvironmentRegistryHydrated(): boolean { |
| 92 | return useSavedEnvironmentRegistryStore.persist.hasHydrated(); | |
| 160 | return savedEnvironmentRegistryHydrated; | |
| 93 | 161 | } |
| 94 | 162 | |
| 95 | 163 | export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { |
| ... | ... | @@ -97,17 +165,7 @@ export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { |
| 97 | 165 | return Promise.resolve(); |
| 98 | 166 | } |
| 99 | 167 | |
| 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(); | |
| 111 | 169 | } |
| 112 | 170 | |
| 113 | 171 | export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> { |
| ... | ... | @@ -150,7 +208,39 @@ export function resolveEnvironmentHttpUrl(input: { |
| 150 | 208 | } |
| 151 | 209 | |
| 152 | 210 | export function resetSavedEnvironmentRegistryStoreForTests() { |
| 153 | useSavedEnvironmentRegistryStore.getState().reset(); | |
| 211 | savedEnvironmentRegistryHydrated = false; | |
| 212 | savedEnvironmentRegistryHydrationPromise = null; | |
| 213 | useSavedEnvironmentRegistryStore.setState({ byId: {} }); | |
| 214 | } | |
| 215 | ||
| 216 | export 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 | ||
| 227 | export async function readSavedEnvironmentBearerToken( | |
| 228 | environmentId: EnvironmentId, | |
| 229 | ): Promise<string | null> { | |
| 230 | return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); | |
| 231 | } | |
| 232 | ||
| 233 | export async function writeSavedEnvironmentBearerToken( | |
| 234 | environmentId: EnvironmentId, | |
| 235 | bearerToken: string, | |
| 236 | ): Promise<boolean> { | |
| 237 | return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken); | |
| 238 | } | |
| 239 | ||
| 240 | export async function removeSavedEnvironmentBearerToken( | |
| 241 | environmentId: EnvironmentId, | |
| 242 | ): Promise<void> { | |
| 243 | await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId); | |
| 154 | 244 | } |
| 155 | 245 | |
| 156 | 246 | export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; |
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | ||
| 4 | const mockResolveRemotePairingTarget = vi.fn(); | |
| 5 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); | |
| 6 | const mockBootstrapRemoteBearerSession = vi.fn(); | |
| 7 | const mockPersistSavedEnvironmentRecord = vi.fn(); | |
| 8 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); | |
| 9 | const mockSetSavedEnvironmentRegistry = vi.fn(); | |
| 10 | const mockUpsert = vi.fn(); | |
| 11 | const mockListSavedEnvironmentRecords = vi.fn(); | |
| 12 | ||
| 13 | vi.mock("../remote/target", () => ({ | |
| 14 | resolveRemotePairingTarget: mockResolveRemotePairingTarget, | |
| 15 | })); | |
| 16 | ||
| 17 | vi.mock("../remote/api", () => ({ | |
| 18 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, | |
| 19 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | |
| 20 | fetchRemoteSessionState: vi.fn(), | |
| 21 | resolveRemoteWebSocketConnectionUrl: vi.fn(), | |
| 22 | })); | |
| 23 | ||
| 24 | vi.mock("~/localApi", () => ({ | |
| 25 | ensureLocalApi: () => ({ | |
| 26 | persistence: { | |
| 27 | setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry, | |
| 28 | }, | |
| 29 | }), | |
| 30 | })); | |
| 31 | ||
| 32 | vi.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 | ||
| 57 | vi.mock("./connection", () => ({ | |
| 58 | createEnvironmentConnection: vi.fn(), | |
| 59 | })); | |
| 60 | ||
| 61 | describe("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 { |
| 23 | 23 | markPromotedDraftThreadsByRef, |
| 24 | 24 | useComposerDraftStore, |
| 25 | 25 | } from "~/composerDraftStore"; |
| 26 | import { ensureLocalApi } from "~/localApi"; | |
| 26 | 27 | import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; |
| 27 | 28 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; |
| 28 | 29 | import { projectQueryKeys } from "~/lib/projectReactQuery"; |
| ... | ... | @@ -39,10 +40,14 @@ import { |
| 39 | 40 | getSavedEnvironmentRecord, |
| 40 | 41 | hasSavedEnvironmentRegistryHydrated, |
| 41 | 42 | listSavedEnvironmentRecords, |
| 43 | persistSavedEnvironmentRecord, | |
| 44 | readSavedEnvironmentBearerToken, | |
| 45 | removeSavedEnvironmentBearerToken, | |
| 42 | 46 | type SavedEnvironmentRecord, |
| 43 | 47 | useSavedEnvironmentRegistryStore, |
| 44 | 48 | useSavedEnvironmentRuntimeStore, |
| 45 | 49 | waitForSavedEnvironmentRegistryHydration, |
| 50 | writeSavedEnvironmentBearerToken, | |
| 46 | 51 | } from "./catalog"; |
| 47 | 52 | import { createEnvironmentConnection, type EnvironmentConnection } from "./connection"; |
| 48 | 53 | import { |
| ... | ... | @@ -306,7 +311,10 @@ function createPrimaryEnvironmentClient( |
| 306 | 311 | return createWsRpcClient(new WsTransport(wsBaseUrl)); |
| 307 | 312 | } |
| 308 | 313 | |
| 309 | function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClient { | |
| 314 | function createSavedEnvironmentClient( | |
| 315 | record: SavedEnvironmentRecord, | |
| 316 | bearerToken: string, | |
| 317 | ): WsRpcClient { | |
| 310 | 318 | useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); |
| 311 | 319 | |
| 312 | 320 | return createWsRpcClient( |
| ... | ... | @@ -315,7 +323,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie |
| 315 | 323 | resolveRemoteWebSocketConnectionUrl({ |
| 316 | 324 | wsBaseUrl: record.wsBaseUrl, |
| 317 | 325 | httpBaseUrl: record.httpBaseUrl, |
| 318 | bearerToken: record.bearerToken, | |
| 326 | bearerToken, | |
| 319 | 327 | }), |
| 320 | 328 | { |
| 321 | 329 | onAttempt: () => { |
| ... | ... | @@ -341,6 +349,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie |
| 341 | 349 | |
| 342 | 350 | async function refreshSavedEnvironmentMetadata( |
| 343 | 351 | record: SavedEnvironmentRecord, |
| 352 | bearerToken: string, | |
| 344 | 353 | client: WsRpcClient, |
| 345 | 354 | roleHint?: AuthSessionRole | null, |
| 346 | 355 | configHint?: ServerConfig | null, |
| ... | ... | @@ -349,7 +358,7 @@ async function refreshSavedEnvironmentMetadata( |
| 349 | 358 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), |
| 350 | 359 | fetchRemoteSessionState({ |
| 351 | 360 | httpBaseUrl: record.httpBaseUrl, |
| 352 | bearerToken: record.bearerToken, | |
| 361 | bearerToken, | |
| 353 | 362 | }), |
| 354 | 363 | ]); |
| 355 | 364 | |
| ... | ... | @@ -408,6 +417,7 @@ async function ensureSavedEnvironmentConnection( |
| 408 | 417 | record: SavedEnvironmentRecord, |
| 409 | 418 | options?: { |
| 410 | 419 | readonly client?: WsRpcClient; |
| 420 | readonly bearerToken?: string; | |
| 411 | 421 | readonly role?: AuthSessionRole | null; |
| 412 | 422 | readonly serverConfig?: ServerConfig | null; |
| 413 | 423 | }, |
| ... | ... | @@ -417,7 +427,20 @@ async function ensureSavedEnvironmentConnection( |
| 417 | 427 | return existing; |
| 418 | 428 | } |
| 419 | 429 | |
| 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); | |
| 421 | 444 | const knownEnvironment = createKnownEnvironment({ |
| 422 | 445 | id: record.environmentId, |
| 423 | 446 | label: record.label, |
| ... | ... | @@ -435,7 +458,7 @@ async function ensureSavedEnvironmentConnection( |
| 435 | 458 | }, |
| 436 | 459 | client, |
| 437 | 460 | refreshMetadata: async () => { |
| 438 | await refreshSavedEnvironmentMetadata(record, client); | |
| 461 | await refreshSavedEnvironmentMetadata(record, bearerToken, client); | |
| 439 | 462 | }, |
| 440 | 463 | onConfigSnapshot: (config) => { |
| 441 | 464 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| ... | ... | @@ -456,6 +479,7 @@ async function ensureSavedEnvironmentConnection( |
| 456 | 479 | try { |
| 457 | 480 | await refreshSavedEnvironmentMetadata( |
| 458 | 481 | record, |
| 482 | bearerToken, | |
| 459 | 483 | client, |
| 460 | 484 | options?.role ?? null, |
| 461 | 485 | options?.serverConfig ?? null, |
| ... | ... | @@ -552,6 +576,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P |
| 552 | 576 | |
| 553 | 577 | export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> { |
| 554 | 578 | useSavedEnvironmentRegistryStore.getState().remove(environmentId); |
| 579 | await removeSavedEnvironmentBearerToken(environmentId); | |
| 555 | 580 | await disconnectSavedEnvironment(environmentId); |
| 556 | 581 | } |
| 557 | 582 | |
| ... | ... | @@ -585,12 +610,30 @@ export async function addSavedEnvironment(input: { |
| 585 | 610 | label: input.label.trim() || descriptor.label, |
| 586 | 611 | wsBaseUrl: resolvedTarget.wsBaseUrl, |
| 587 | 612 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 588 | bearerToken: bearerSession.sessionToken, | |
| 589 | 613 | createdAt: isoNow(), |
| 590 | 614 | lastConnectedAt: isoNow(), |
| 591 | 615 | }; |
| 592 | 616 | |
| 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 | } | |
| 593 | 635 | await ensureSavedEnvironmentConnection(record, { |
| 636 | bearerToken: bearerSession.sessionToken, | |
| 594 | 637 | role: bearerSession.role, |
| 595 | 638 | }); |
| 596 | 639 | useSavedEnvironmentRegistryStore.getState().upsert(record); |
apps/web/src/hooks/useSettings.test.ts deleted-16| ... | ... | @@ -1,16 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { buildLegacyClientSettingsMigrationPatch } from "./useSettings"; | |
| 3 | ||
| 4 | describe("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 @@ |
| 9 | 9 | * write. The hook transparently routes reads/writes to the correct backing |
| 10 | 10 | * store. |
| 11 | 11 | */ |
| 12 | import { useCallback, useMemo } from "react"; | |
| 13 | import { | |
| 14 | ServerSettings, | |
| 15 | ServerSettingsPatch, | |
| 16 | ModelSelection, | |
| 17 | ThreadEnvMode, | |
| 18 | } from "@t3tools/contracts"; | |
| 12 | import { useCallback, useMemo, useSyncExternalStore } from "react"; | |
| 13 | import { ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; | |
| 19 | 14 | import { |
| 20 | 15 | type ClientSettings, |
| 21 | ClientSettingsSchema, | |
| 22 | 16 | DEFAULT_CLIENT_SETTINGS, |
| 23 | 17 | DEFAULT_UNIFIED_SETTINGS, |
| 24 | SidebarProjectSortOrder, | |
| 25 | SidebarThreadSortOrder, | |
| 26 | TimestampFormat, | |
| 27 | 18 | UnifiedSettings, |
| 28 | 19 | } from "@t3tools/contracts/settings"; |
| 29 | 20 | import { ensureLocalApi } from "~/localApi"; |
| 30 | import { useLocalStorage } from "./useLocalStorage"; | |
| 31 | import { normalizeCustomModelSlugs } from "~/modelSelection"; | |
| 32 | import { Predicate, Schema, Struct } from "effect"; | |
| 33 | import { DeepMutable } from "effect/Types"; | |
| 21 | import { Struct } from "effect"; | |
| 34 | 22 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 35 | 23 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; |
| 36 | 24 | |
| 37 | const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; | |
| 38 | const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; | |
| 25 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; | |
| 26 | ||
| 27 | const clientSettingsListeners = new Set<() => void>(); | |
| 28 | let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | |
| 29 | let clientSettingsHydrated = false; | |
| 30 | let clientSettingsHydrationPromise: Promise<void> | null = null; | |
| 31 | ||
| 32 | function emitClientSettingsChange() { | |
| 33 | for (const listener of clientSettingsListeners) { | |
| 34 | listener(); | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | function getClientSettingsSnapshot(): ClientSettings { | |
| 39 | return clientSettingsSnapshot; | |
| 40 | } | |
| 41 | ||
| 42 | function replaceClientSettingsSnapshot(settings: ClientSettings): void { | |
| 43 | clientSettingsSnapshot = settings; | |
| 44 | emitClientSettingsChange(); | |
| 45 | } | |
| 46 | ||
| 47 | function subscribeClientSettings(listener: () => void): () => void { | |
| 48 | clientSettingsListeners.add(listener); | |
| 49 | void hydrateClientSettings(); | |
| 50 | return () => { | |
| 51 | clientSettingsListeners.delete(listener); | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | async 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 | ||
| 86 | function 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 | } | |
| 39 | 94 | |
| 40 | 95 | // ── Key sets for routing patches ───────────────────────────────────── |
| 41 | 96 | |
| ... | ... | @@ -69,10 +124,10 @@ function splitPatch(patch: Partial<UnifiedSettings>): { |
| 69 | 124 | |
| 70 | 125 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { |
| 71 | 126 | 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, | |
| 76 | 131 | ); |
| 77 | 132 | |
| 78 | 133 | const merged = useMemo<UnifiedSettings>( |
| ... | ... | @@ -90,34 +145,28 @@ export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) |
| 90 | 145 | * Returns an updater that routes each key to the correct backing store. |
| 91 | 146 | * |
| 92 | 147 | * 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. | |
| 94 | 149 | */ |
| 95 | 150 | export 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); | |
| 101 | 153 | |
| 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)); | |
| 113 | 158 | } |
| 159 | // Fire-and-forget RPC — push will reconcile on success | |
| 160 | void ensureLocalApi().server.updateSettings(serverPatch); | |
| 161 | } | |
| 114 | 162 | |
| 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 | }, []); | |
| 121 | 170 | |
| 122 | 171 | const resetSettings = useCallback(() => { |
| 123 | 172 | updateSettings(DEFAULT_UNIFIED_SETTINGS); |
| ... | ... | @@ -129,132 +178,9 @@ export function useUpdateSettings() { |
| 129 | 178 | }; |
| 130 | 179 | } |
| 131 | 180 | |
| 132 | // ── One-time migration from localStorage ───────────────────────────── | |
| 133 | ||
| 134 | export 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 | ||
| 190 | export 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 | */ | |
| 227 | export 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 | } | |
| 181 | export function __resetClientSettingsPersistenceForTests(): void { | |
| 182 | clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | |
| 183 | clientSettingsHydrated = false; | |
| 184 | clientSettingsHydrationPromise = null; | |
| 185 | clientSettingsListeners.clear(); | |
| 260 | 186 | } |
apps/web/src/localApi.test.ts+155-1| ... | ... | @@ -137,9 +137,36 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 137 | 137 | return testGlobal.window; |
| 138 | 138 | } |
| 139 | 139 | |
| 140 | function 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 | ||
| 140 | 160 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { |
| 141 | 161 | return { |
| 142 | 162 | 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, | |
| 143 | 170 | getServerExposureState: async () => ({ |
| 144 | 171 | mode: "local-only", |
| 145 | 172 | endpointUrl: null, |
| ... | ... | @@ -242,7 +269,12 @@ beforeEach(() => { |
| 242 | 269 | terminalEventListeners.clear(); |
| 243 | 270 | orchestrationEventListeners.clear(); |
| 244 | 271 | 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 | }); | |
| 246 | 278 | }); |
| 247 | 279 | |
| 248 | 280 | afterEach(() => { |
| ... | ... | @@ -463,4 +495,126 @@ describe("wsApi", () => { |
| 463 | 495 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); |
| 464 | 496 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); |
| 465 | 497 | }); |
| 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 | }); | |
| 466 | 620 | }); |
apps/web/src/localApi.ts+55| ... | ... | @@ -14,6 +14,15 @@ import { |
| 14 | 14 | } from "./environments/runtime"; |
| 15 | 15 | import { type WsRpcClient } from "./rpc/wsRpcClient"; |
| 16 | 16 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 17 | import { | |
| 18 | readBrowserClientSettings, | |
| 19 | readBrowserSavedEnvironmentRegistry, | |
| 20 | readBrowserSavedEnvironmentSecret, | |
| 21 | removeBrowserSavedEnvironmentSecret, | |
| 22 | writeBrowserClientSettings, | |
| 23 | writeBrowserSavedEnvironmentRegistry, | |
| 24 | writeBrowserSavedEnvironmentSecret, | |
| 25 | } from "./clientPersistenceStorage"; | |
| 17 | 26 | |
| 18 | 27 | let cachedApi: LocalApi | undefined; |
| 19 | 28 | |
| ... | ... | @@ -56,6 +65,50 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { |
| 56 | 65 | return showContextMenuFallback(items, position); |
| 57 | 66 | }, |
| 58 | 67 | }, |
| 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 | }, | |
| 59 | 112 | server: { |
| 60 | 113 | getConfig: rpcClient.server.getConfig, |
| 61 | 114 | refreshProviders: rpcClient.server.refreshProviders, |
| ... | ... | @@ -89,6 +142,8 @@ export function ensureLocalApi(): LocalApi { |
| 89 | 142 | |
| 90 | 143 | export async function __resetLocalApiForTests() { |
| 91 | 144 | cachedApi = undefined; |
| 145 | const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); | |
| 146 | __resetClientSettingsPersistenceForTests(); | |
| 92 | 147 | await resetEnvironmentServiceForTests(); |
| 93 | 148 | resetGitStatusStateForTests(); |
| 94 | 149 | resetRequestLatencyStateForTests(); |
apps/web/src/routes/__root.tsx-2| ... | ... | @@ -31,7 +31,6 @@ import { |
| 31 | 31 | } from "../rpc/serverState"; |
| 32 | 32 | import { useStore } from "../store"; |
| 33 | 33 | import { useUiStateStore } from "../uiStateStore"; |
| 34 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; | |
| 35 | 34 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 36 | 35 | import { |
| 37 | 36 | ensureEnvironmentConnectionBootstrapped, |
| ... | ... | @@ -213,7 +212,6 @@ function EventRouter() { |
| 213 | 212 | |
| 214 | 213 | updatePrimaryEnvironmentDescriptor(payload.environment); |
| 215 | 214 | setActiveEnvironmentId(payload.environment.environmentId); |
| 216 | migrateLocalSettingsToServer(); | |
| 217 | 215 | void (async () => { |
| 218 | 216 | await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); |
| 219 | 217 | if (disposedRef.current) { |
apps/web/vite.config.ts+2| ... | ... | @@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" }; |
| 7 | 7 | |
| 8 | 8 | const port = Number(process.env.PORT ?? 5733); |
| 9 | 9 | const host = process.env.HOST?.trim() || "localhost"; |
| 10 | const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); | |
| 10 | 11 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); |
| 11 | 12 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); |
| 12 | 13 | |
| ... | ... | @@ -58,6 +59,7 @@ export default defineConfig({ |
| 58 | 59 | include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], |
| 59 | 60 | }, |
| 60 | 61 | define: { |
| 62 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), | |
| 61 | 63 | // In dev mode, tell the web app where the WebSocket server lives |
| 62 | 64 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 63 | 65 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), |
packages/contracts/src/ipc.ts+31-1| ... | ... | @@ -49,8 +49,9 @@ import type { |
| 49 | 49 | OrchestrationEvent, |
| 50 | 50 | OrchestrationReadModel, |
| 51 | 51 | } from "./orchestration"; |
| 52 | import type { EnvironmentId } from "./baseSchemas"; | |
| 52 | 53 | import { EditorId } from "./editor"; |
| 53 | import { ServerSettings, ServerSettingsPatch } from "./settings"; | |
| 54 | import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; | |
| 54 | 55 | |
| 55 | 56 | export interface ContextMenuItem<T extends string = string> { |
| 56 | 57 | id: T; |
| ... | ... | @@ -112,6 +113,15 @@ export interface DesktopEnvironmentBootstrap { |
| 112 | 113 | bootstrapToken?: string; |
| 113 | 114 | } |
| 114 | 115 | |
| 116 | export interface PersistedSavedEnvironmentRecord { | |
| 117 | environmentId: EnvironmentId; | |
| 118 | label: string; | |
| 119 | wsBaseUrl: string; | |
| 120 | httpBaseUrl: string; | |
| 121 | createdAt: string; | |
| 122 | lastConnectedAt: string | null; | |
| 123 | } | |
| 124 | ||
| 115 | 125 | export type DesktopServerExposureMode = "local-only" | "network-accessible"; |
| 116 | 126 | |
| 117 | 127 | export interface DesktopServerExposureState { |
| ... | ... | @@ -122,6 +132,15 @@ export interface DesktopServerExposureState { |
| 122 | 132 | |
| 123 | 133 | export interface DesktopBridge { |
| 124 | 134 | 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>; | |
| 125 | 144 | getServerExposureState: () => Promise<DesktopServerExposureState>; |
| 126 | 145 | setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>; |
| 127 | 146 | pickFolder: () => Promise<string | null>; |
| ... | ... | @@ -165,6 +184,17 @@ export interface LocalApi { |
| 165 | 184 | position?: { x: number; y: number }, |
| 166 | 185 | ) => Promise<T | null>; |
| 167 | 186 | }; |
| 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 | }; | |
| 168 | 198 | server: { |
| 169 | 199 | getConfig: () => Promise<ServerConfig>; |
| 170 | 200 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; |
scripts/dev-runner.test.ts+2| ... | ... | @@ -84,6 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 84 | 84 | |
| 85 | 85 | assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); |
| 86 | 86 | assert.equal(env.T3CODE_PORT, "4222"); |
| 87 | assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); | |
| 87 | 88 | assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); |
| 88 | 89 | assert.equal(env.T3CODE_NO_BROWSER, "1"); |
| 89 | 90 | assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); |
| ... | ... | @@ -183,6 +184,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 183 | 184 | assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); |
| 184 | 185 | assert.equal(env.HOST, "127.0.0.1"); |
| 185 | 186 | assert.equal(env.T3CODE_PORT, "4222"); |
| 187 | assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222"); | |
| 186 | 188 | assert.equal(env.T3CODE_MODE, undefined); |
| 187 | 189 | assert.equal(env.T3CODE_NO_BROWSER, undefined); |
| 188 | 190 | assert.equal(env.T3CODE_HOST, undefined); |
scripts/dev-runner.ts+2| ... | ... | @@ -159,9 +159,11 @@ export function createDevRunnerEnv({ |
| 159 | 159 | |
| 160 | 160 | if (!isDesktopMode) { |
| 161 | 161 | output.T3CODE_PORT = String(serverPort); |
| 162 | output.VITE_HTTP_URL = `http://localhost:${serverPort}`; | |
| 162 | 163 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; |
| 163 | 164 | } else { |
| 164 | 165 | output.T3CODE_PORT = String(serverPort); |
| 166 | output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; | |
| 165 | 167 | output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; |
| 166 | 168 | delete output.T3CODE_MODE; |
| 167 | 169 | delete output.T3CODE_NO_BROWSER; |