| 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 +14,17 @@ import { |
| 14 | nativeImage, | 14 | nativeImage, |
| 15 | nativeTheme, | 15 | nativeTheme, |
| 16 | protocol, | 16 | protocol, |
| 17 | safeStorage, | ||
| 17 | shell, | 18 | shell, |
| 18 | } from "electron"; | 19 | } from "electron"; |
| 19 | import type { MenuItemConstructorOptions } from "electron"; | 20 | import type { MenuItemConstructorOptions } from "electron"; |
| 20 | import * as Effect from "effect/Effect"; | 21 | import * as Effect from "effect/Effect"; |
| 21 | import type { | 22 | import type { |
| 23 | ClientSettings, | ||
| 22 | DesktopTheme, | 24 | DesktopTheme, |
| 23 | DesktopServerExposureMode, | 25 | DesktopServerExposureMode, |
| 24 | DesktopServerExposureState, | 26 | DesktopServerExposureState, |
| 27 | PersistedSavedEnvironmentRecord, | ||
| 25 | DesktopUpdateActionResult, | 28 | DesktopUpdateActionResult, |
| 26 | DesktopUpdateCheckResult, | 29 | DesktopUpdateCheckResult, |
| 27 | DesktopUpdateState, | 30 | DesktopUpdateState, |
| ... | @@ -38,6 +41,15 @@ import { | ... | @@ -38,6 +41,15 @@ import { |
| 38 | setDesktopServerExposurePreference, | 41 | setDesktopServerExposurePreference, |
| 39 | writeDesktopSettings, | 42 | writeDesktopSettings, |
| 40 | } from "./desktopSettings"; | 43 | } from "./desktopSettings"; |
| 44 | import { | ||
| 45 | readClientSettings, | ||
| 46 | readSavedEnvironmentRegistry, | ||
| 47 | readSavedEnvironmentSecret, | ||
| 48 | removeSavedEnvironmentSecret, | ||
| 49 | writeClientSettings, | ||
| 50 | writeSavedEnvironmentRegistry, | ||
| 51 | writeSavedEnvironmentSecret, | ||
| 52 | } from "./clientPersistence"; | ||
| 41 | import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; | 53 | import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; |
| 42 | import { showDesktopConfirmDialog } from "./confirmDialog"; | 54 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 43 | import { resolveDesktopServerExposure } from "./serverExposure"; | 55 | import { resolveDesktopServerExposure } from "./serverExposure"; |
| ... | @@ -71,11 +83,20 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | ... | @@ -71,11 +83,20 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 71 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 83 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 72 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; | 84 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 73 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | 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 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | 93 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; |
| 75 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | 94 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 76 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); | 95 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 77 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); | 96 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 78 | const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); | 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 | const DESKTOP_SCHEME = "t3"; | 100 | const DESKTOP_SCHEME = "t3"; |
| 80 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); | 101 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 81 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 102 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| ... | @@ -204,6 +225,14 @@ function getDesktopServerExposureState(): DesktopServerExposureState { | ... | @@ -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 | function resolveAdvertisedHostOverride(): string | undefined { | 236 | function resolveAdvertisedHostOverride(): string | undefined { |
| 208 | const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); | 237 | const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); |
| 209 | return override && override.length > 0 ? override : undefined; | 238 | return override && override.length > 0 ? override : undefined; |
| ... | @@ -1349,6 +1378,86 @@ function registerIpcHandlers(): void { | ... | @@ -1349,6 +1378,86 @@ function registerIpcHandlers(): void { |
| 1349 | } as const; | 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 | ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); | 1461 | ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); |
| 1353 | ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); | 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,6 +13,13 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 13 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 14 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 15 | const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | 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 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | 23 | const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; |
| 17 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | 24 | const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; |
| 18 | 25 | ||
| ... | @@ -24,6 +31,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -24,6 +31,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 24 | } | 31 | } |
| 25 | return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; | 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 | getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), | 45 | getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), |
| 28 | setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), | 46 | setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), |
| 29 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), | 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,6 +17,9 @@ vi.mock("../editorPreferences", () => ({ |
| 17 | })); | 17 | })); |
| 18 | 18 | ||
| 19 | vi.mock("../localApi", () => ({ | 19 | vi.mock("../localApi", () => ({ |
| 20 | ensureLocalApi: vi.fn(() => { | ||
| 21 | throw new Error("ensureLocalApi not implemented in browser test"); | ||
| 22 | }), | ||
| 20 | readLocalApi: readLocalApiMock, | 23 | readLocalApi: readLocalApiMock, |
| 21 | })); | 24 | })); |
| 22 | 25 |
apps/web/src/components/GitActionsControl.browser.tsx+3| ... | @@ -126,6 +126,9 @@ vi.mock("~/lib/gitStatusState", () => ({ | ... | @@ -126,6 +126,9 @@ vi.mock("~/lib/gitStatusState", () => ({ |
| 126 | })); | 126 | })); |
| 127 | 127 | ||
| 128 | vi.mock("~/localApi", () => ({ | 128 | vi.mock("~/localApi", () => ({ |
| 129 | ensureLocalApi: vi.fn(() => { | ||
| 130 | throw new Error("ensureLocalApi not implemented in browser test"); | ||
| 131 | }), | ||
| 129 | readLocalApi: vi.fn(() => null), | 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,6 +113,9 @@ vi.mock("~/environmentApi", () => ({ |
| 113 | })); | 113 | })); |
| 114 | 114 | ||
| 115 | vi.mock("~/localApi", () => ({ | 115 | vi.mock("~/localApi", () => ({ |
| 116 | ensureLocalApi: vi.fn(() => { | ||
| 117 | throw new Error("ensureLocalApi not implemented in browser test"); | ||
| 118 | }), | ||
| 116 | readLocalApi: readLocalApiMock, | 119 | readLocalApi: readLocalApiMock, |
| 117 | })); | 120 | })); |
| 118 | 121 |
apps/web/src/components/settings/SettingsPanels.browser.tsx+7| ... | @@ -282,6 +282,13 @@ const createDesktopBridgeStub = (overrides?: { | ... | @@ -282,6 +282,13 @@ const createDesktopBridgeStub = (overrides?: { |
| 282 | wsBaseUrl: "ws://127.0.0.1:3773", | 282 | wsBaseUrl: "ws://127.0.0.1:3773", |
| 283 | bootstrapToken: "desktop-bootstrap-token", | 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 | getServerExposureState: vi.fn().mockResolvedValue( | 292 | getServerExposureState: vi.fn().mockResolvedValue( |
| 286 | overrides?.serverExposureState ?? { | 293 | overrides?.serverExposureState ?? { |
| 287 | mode: "local-only", | 294 | mode: "local-only", |
apps/web/src/environments/primary/bootstrap.test.ts+26| ... | @@ -110,6 +110,32 @@ describe("environmentBootstrap", () => { | ... | @@ -110,6 +110,32 @@ describe("environmentBootstrap", () => { |
| 110 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); | 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 | it("uses the current origin as the descriptor base for local dev environments", async () => { | 139 | it("uses the current origin as the descriptor base for local dev environments", async () => { |
| 114 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); | 140 | const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); |
| 115 | vi.stubGlobal("fetch", fetchMock); | 141 | vi.stubGlobal("fetch", fetchMock); |
apps/web/src/environments/primary/target.ts+23-7| ... | @@ -16,6 +16,15 @@ function normalizeBaseUrl(rawValue: string): string { | ... | @@ -16,6 +16,15 @@ function normalizeBaseUrl(rawValue: string): string { |
| 16 | return new URL(rawValue, window.location.origin).toString(); | 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 | function normalizeHostname(hostname: string): string { | 28 | function normalizeHostname(hostname: string): string { |
| 20 | return hostname | 29 | return hostname |
| 21 | .trim() | 30 | .trim() |
| ... | @@ -54,22 +63,29 @@ function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { | ... | @@ -54,22 +63,29 @@ function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { |
| 54 | } | 63 | } |
| 55 | 64 | ||
| 56 | function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { | 65 | function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { |
| 57 | const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim(); | 66 | const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim() || undefined; |
| 58 | const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim(); | 67 | const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim() || undefined; |
| 59 | 68 | ||
| 60 | if (!configuredHttpBaseUrl && !configuredWsBaseUrl) { | 69 | if (!configuredHttpBaseUrl && !configuredWsBaseUrl) { |
| 61 | return null; | 70 | return null; |
| 62 | } | 71 | } |
| 63 | 72 | ||
| 64 | if (!configuredHttpBaseUrl || !configuredWsBaseUrl) { | 73 | const resolvedHttpBaseUrl = |
| 65 | throw new Error("Configured primary environments require both VITE_HTTP_URL and VITE_WS_URL."); | 74 | configuredHttpBaseUrl ?? |
| 66 | } | 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 | return { | 84 | return { |
| 69 | source: "configured", | 85 | source: "configured", |
| 70 | target: { | 86 | target: { |
| 71 | httpBaseUrl: normalizeBaseUrl(configuredHttpBaseUrl), | 87 | httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), |
| 72 | wsBaseUrl: normalizeBaseUrl(configuredWsBaseUrl), | 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,17 +1,43 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { afterEach, describe, expect, it } from "vitest"; | 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 | import { | 8 | import { |
| 5 | resetSavedEnvironmentRegistryStoreForTests, | 9 | resetSavedEnvironmentRegistryStoreForTests, |
| 6 | resetSavedEnvironmentRuntimeStoreForTests, | 10 | resetSavedEnvironmentRuntimeStoreForTests, |
| 7 | useSavedEnvironmentRegistryStore, | 11 | useSavedEnvironmentRegistryStore, |
| 8 | useSavedEnvironmentRuntimeStore, | 12 | useSavedEnvironmentRuntimeStore, |
| 13 | waitForSavedEnvironmentRegistryHydration, | ||
| 9 | } from "./catalog"; | 14 | } from "./catalog"; |
| 10 | 15 | ||
| 11 | describe("environment runtime catalog stores", () => { | 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 | resetSavedEnvironmentRegistryStoreForTests(); | 36 | resetSavedEnvironmentRegistryStoreForTests(); |
| 14 | resetSavedEnvironmentRuntimeStoreForTests(); | 37 | resetSavedEnvironmentRuntimeStoreForTests(); |
| 38 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 39 | await __resetLocalApiForTests(); | ||
| 40 | vi.unstubAllGlobals(); | ||
| 15 | }); | 41 | }); |
| 16 | 42 | ||
| 17 | it("resets the saved environment registry store state", () => { | 43 | it("resets the saved environment registry store state", () => { |
| ... | @@ -22,7 +48,6 @@ describe("environment runtime catalog stores", () => { | ... | @@ -22,7 +48,6 @@ describe("environment runtime catalog stores", () => { |
| 22 | label: "Remote environment", | 48 | label: "Remote environment", |
| 23 | httpBaseUrl: "https://remote.example.com/", | 49 | httpBaseUrl: "https://remote.example.com/", |
| 24 | wsBaseUrl: "wss://remote.example.com/", | 50 | wsBaseUrl: "wss://remote.example.com/", |
| 25 | bearerToken: "token", | ||
| 26 | createdAt: "2026-04-09T00:00:00.000Z", | 51 | createdAt: "2026-04-09T00:00:00.000Z", |
| 27 | lastConnectedAt: null, | 52 | lastConnectedAt: null, |
| 28 | }); | 53 | }); |
| ... | @@ -48,4 +73,69 @@ describe("environment runtime catalog stores", () => { | ... | @@ -48,4 +73,69 @@ describe("environment runtime catalog stores", () => { |
| 48 | 73 | ||
| 49 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); | 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,22 +3,19 @@ import type { |
| 3 | AuthSessionRole, | 3 | AuthSessionRole, |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | ExecutionEnvironmentDescriptor, | 5 | ExecutionEnvironmentDescriptor, |
| 6 | PersistedSavedEnvironmentRecord, | ||
| 6 | ServerConfig, | 7 | ServerConfig, |
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { create } from "zustand"; | 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 | import { getPrimaryKnownEnvironment } from "../primary"; | 12 | import { getPrimaryKnownEnvironment } from "../primary"; |
| 13 | 13 | ||
| 14 | const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; | ||
| 15 | |||
| 16 | export interface SavedEnvironmentRecord { | 14 | export interface SavedEnvironmentRecord { |
| 17 | readonly environmentId: EnvironmentId; | 15 | readonly environmentId: EnvironmentId; |
| 18 | readonly label: string; | 16 | readonly label: string; |
| 19 | readonly wsBaseUrl: string; | 17 | readonly wsBaseUrl: string; |
| 20 | readonly httpBaseUrl: string; | 18 | readonly httpBaseUrl: string; |
| 21 | readonly bearerToken: string; | ||
| 22 | readonly createdAt: string; | 19 | readonly createdAt: string; |
| 23 | readonly lastConnectedAt: string | null; | 20 | readonly lastConnectedAt: string | null; |
| 24 | } | 21 | } |
| ... | @@ -34,62 +31,133 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { | ... | @@ -34,62 +31,133 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { |
| 34 | readonly reset: () => void; | 31 | readonly reset: () => void; |
| 35 | } | 32 | } |
| 36 | 33 | ||
| 37 | function createSavedEnvironmentRegistryStorage() { | 34 | let savedEnvironmentRegistryHydrated = false; |
| 38 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); | 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>()( | 50 | function valuesOfSavedEnvironmentRegistry( |
| 42 | persist( | 51 | byId: Record<EnvironmentId, SavedEnvironmentRecord>, |
| 43 | (set) => ({ | 52 | ): ReadonlyArray<SavedEnvironmentRecord> { |
| 44 | byId: {}, | 53 | return Object.values(byId) as ReadonlyArray<SavedEnvironmentRecord>; |
| 45 | upsert: (record) => | 54 | } |
| 46 | set((state) => ({ | 55 | |
| 47 | byId: { | 56 | function persistSavedEnvironmentRegistryState( |
| 48 | ...state.byId, | 57 | byId: Record<EnvironmentId, SavedEnvironmentRecord>, |
| 49 | [record.environmentId]: record, | 58 | ): void { |
| 50 | }, | 59 | try { |
| 51 | })), | 60 | void ensureLocalApi() |
| 52 | remove: (environmentId) => | 61 | .persistence.setSavedEnvironmentRegistry( |
| 53 | set((state) => { | 62 | valuesOfSavedEnvironmentRegistry(byId).map((record) => |
| 54 | const { [environmentId]: _removed, ...remaining } = state.byId; | 63 | toPersistedSavedEnvironmentRecord(record), |
| 55 | return { | 64 | ), |
| 56 | byId: remaining, | 65 | ) |
| 57 | }; | 66 | .catch((error) => { |
| 58 | }), | 67 | console.error("[SAVED_ENVIRONMENTS] persist failed", error); |
| 59 | markConnected: (environmentId, connectedAt) => | 68 | }); |
| 60 | set((state) => { | 69 | } catch (error) { |
| 61 | const existing = state.byId[environmentId]; | 70 | console.error("[SAVED_ENVIRONMENTS] persist failed", error); |
| 62 | if (!existing) { | 71 | } |
| 63 | return state; | 72 | } |
| 64 | } | 73 | |
| 65 | return { | 74 | function replaceSavedEnvironmentRegistryState( |
| 66 | byId: { | 75 | records: ReadonlyArray<SavedEnvironmentRecord>, |
| 67 | ...state.byId, | 76 | ): void { |
| 68 | [environmentId]: { | 77 | const currentById = useSavedEnvironmentRegistryStore.getState().byId; |
| 69 | ...existing, | 78 | const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record])); |
| 70 | lastConnectedAt: connectedAt, | 79 | useSavedEnvironmentRegistryStore.setState({ |
| 71 | }, | 80 | byId: { |
| 72 | }, | 81 | ...hydratedById, |
| 73 | }; | 82 | ...currentById, |
| 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 | }), | ||
| 87 | }, | 83 | }, |
| 88 | ), | 84 | }); |
| 89 | ); | 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 | export function hasSavedEnvironmentRegistryHydrated(): boolean { | 159 | export function hasSavedEnvironmentRegistryHydrated(): boolean { |
| 92 | return useSavedEnvironmentRegistryStore.persist.hasHydrated(); | 160 | return savedEnvironmentRegistryHydrated; |
| 93 | } | 161 | } |
| 94 | 162 | ||
| 95 | export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { | 163 | export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { |
| ... | @@ -97,17 +165,7 @@ export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { | ... | @@ -97,17 +165,7 @@ export function waitForSavedEnvironmentRegistryHydration(): Promise<void> { |
| 97 | return Promise.resolve(); | 165 | return Promise.resolve(); |
| 98 | } | 166 | } |
| 99 | 167 | ||
| 100 | return new Promise((resolve) => { | 168 | return hydrateSavedEnvironmentRegistry(); |
| 101 | const unsubscribe = useSavedEnvironmentRegistryStore.persist.onFinishHydration(() => { | ||
| 102 | unsubscribe(); | ||
| 103 | resolve(); | ||
| 104 | }); | ||
| 105 | |||
| 106 | if (hasSavedEnvironmentRegistryHydrated()) { | ||
| 107 | unsubscribe(); | ||
| 108 | resolve(); | ||
| 109 | } | ||
| 110 | }); | ||
| 111 | } | 169 | } |
| 112 | 170 | ||
| 113 | export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> { | 171 | export function listSavedEnvironmentRecords(): ReadonlyArray<SavedEnvironmentRecord> { |
| ... | @@ -150,7 +208,39 @@ export function resolveEnvironmentHttpUrl(input: { | ... | @@ -150,7 +208,39 @@ export function resolveEnvironmentHttpUrl(input: { |
| 150 | } | 208 | } |
| 151 | 209 | ||
| 152 | export function resetSavedEnvironmentRegistryStoreForTests() { | 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 | export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; | 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,6 +23,7 @@ import { |
| 23 | markPromotedDraftThreadsByRef, | 23 | markPromotedDraftThreadsByRef, |
| 24 | useComposerDraftStore, | 24 | useComposerDraftStore, |
| 25 | } from "~/composerDraftStore"; | 25 | } from "~/composerDraftStore"; |
| 26 | import { ensureLocalApi } from "~/localApi"; | ||
| 26 | import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; | 27 | import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; |
| 27 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; | 28 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; |
| 28 | import { projectQueryKeys } from "~/lib/projectReactQuery"; | 29 | import { projectQueryKeys } from "~/lib/projectReactQuery"; |
| ... | @@ -39,10 +40,14 @@ import { | ... | @@ -39,10 +40,14 @@ import { |
| 39 | getSavedEnvironmentRecord, | 40 | getSavedEnvironmentRecord, |
| 40 | hasSavedEnvironmentRegistryHydrated, | 41 | hasSavedEnvironmentRegistryHydrated, |
| 41 | listSavedEnvironmentRecords, | 42 | listSavedEnvironmentRecords, |
| 43 | persistSavedEnvironmentRecord, | ||
| 44 | readSavedEnvironmentBearerToken, | ||
| 45 | removeSavedEnvironmentBearerToken, | ||
| 42 | type SavedEnvironmentRecord, | 46 | type SavedEnvironmentRecord, |
| 43 | useSavedEnvironmentRegistryStore, | 47 | useSavedEnvironmentRegistryStore, |
| 44 | useSavedEnvironmentRuntimeStore, | 48 | useSavedEnvironmentRuntimeStore, |
| 45 | waitForSavedEnvironmentRegistryHydration, | 49 | waitForSavedEnvironmentRegistryHydration, |
| 50 | writeSavedEnvironmentBearerToken, | ||
| 46 | } from "./catalog"; | 51 | } from "./catalog"; |
| 47 | import { createEnvironmentConnection, type EnvironmentConnection } from "./connection"; | 52 | import { createEnvironmentConnection, type EnvironmentConnection } from "./connection"; |
| 48 | import { | 53 | import { |
| ... | @@ -306,7 +311,10 @@ function createPrimaryEnvironmentClient( | ... | @@ -306,7 +311,10 @@ function createPrimaryEnvironmentClient( |
| 306 | return createWsRpcClient(new WsTransport(wsBaseUrl)); | 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 | useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); | 318 | useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); |
| 311 | 319 | ||
| 312 | return createWsRpcClient( | 320 | return createWsRpcClient( |
| ... | @@ -315,7 +323,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie | ... | @@ -315,7 +323,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie |
| 315 | resolveRemoteWebSocketConnectionUrl({ | 323 | resolveRemoteWebSocketConnectionUrl({ |
| 316 | wsBaseUrl: record.wsBaseUrl, | 324 | wsBaseUrl: record.wsBaseUrl, |
| 317 | httpBaseUrl: record.httpBaseUrl, | 325 | httpBaseUrl: record.httpBaseUrl, |
| 318 | bearerToken: record.bearerToken, | 326 | bearerToken, |
| 319 | }), | 327 | }), |
| 320 | { | 328 | { |
| 321 | onAttempt: () => { | 329 | onAttempt: () => { |
| ... | @@ -341,6 +349,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie | ... | @@ -341,6 +349,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie |
| 341 | 349 | ||
| 342 | async function refreshSavedEnvironmentMetadata( | 350 | async function refreshSavedEnvironmentMetadata( |
| 343 | record: SavedEnvironmentRecord, | 351 | record: SavedEnvironmentRecord, |
| 352 | bearerToken: string, | ||
| 344 | client: WsRpcClient, | 353 | client: WsRpcClient, |
| 345 | roleHint?: AuthSessionRole | null, | 354 | roleHint?: AuthSessionRole | null, |
| 346 | configHint?: ServerConfig | null, | 355 | configHint?: ServerConfig | null, |
| ... | @@ -349,7 +358,7 @@ async function refreshSavedEnvironmentMetadata( | ... | @@ -349,7 +358,7 @@ async function refreshSavedEnvironmentMetadata( |
| 349 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), | 358 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), |
| 350 | fetchRemoteSessionState({ | 359 | fetchRemoteSessionState({ |
| 351 | httpBaseUrl: record.httpBaseUrl, | 360 | httpBaseUrl: record.httpBaseUrl, |
| 352 | bearerToken: record.bearerToken, | 361 | bearerToken, |
| 353 | }), | 362 | }), |
| 354 | ]); | 363 | ]); |
| 355 | 364 | ||
| ... | @@ -408,6 +417,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -408,6 +417,7 @@ async function ensureSavedEnvironmentConnection( |
| 408 | record: SavedEnvironmentRecord, | 417 | record: SavedEnvironmentRecord, |
| 409 | options?: { | 418 | options?: { |
| 410 | readonly client?: WsRpcClient; | 419 | readonly client?: WsRpcClient; |
| 420 | readonly bearerToken?: string; | ||
| 411 | readonly role?: AuthSessionRole | null; | 421 | readonly role?: AuthSessionRole | null; |
| 412 | readonly serverConfig?: ServerConfig | null; | 422 | readonly serverConfig?: ServerConfig | null; |
| 413 | }, | 423 | }, |
| ... | @@ -417,7 +427,20 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -417,7 +427,20 @@ async function ensureSavedEnvironmentConnection( |
| 417 | return existing; | 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 | const knownEnvironment = createKnownEnvironment({ | 444 | const knownEnvironment = createKnownEnvironment({ |
| 422 | id: record.environmentId, | 445 | id: record.environmentId, |
| 423 | label: record.label, | 446 | label: record.label, |
| ... | @@ -435,7 +458,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -435,7 +458,7 @@ async function ensureSavedEnvironmentConnection( |
| 435 | }, | 458 | }, |
| 436 | client, | 459 | client, |
| 437 | refreshMetadata: async () => { | 460 | refreshMetadata: async () => { |
| 438 | await refreshSavedEnvironmentMetadata(record, client); | 461 | await refreshSavedEnvironmentMetadata(record, bearerToken, client); |
| 439 | }, | 462 | }, |
| 440 | onConfigSnapshot: (config) => { | 463 | onConfigSnapshot: (config) => { |
| 441 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 464 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| ... | @@ -456,6 +479,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -456,6 +479,7 @@ async function ensureSavedEnvironmentConnection( |
| 456 | try { | 479 | try { |
| 457 | await refreshSavedEnvironmentMetadata( | 480 | await refreshSavedEnvironmentMetadata( |
| 458 | record, | 481 | record, |
| 482 | bearerToken, | ||
| 459 | client, | 483 | client, |
| 460 | options?.role ?? null, | 484 | options?.role ?? null, |
| 461 | options?.serverConfig ?? null, | 485 | options?.serverConfig ?? null, |
| ... | @@ -552,6 +576,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P | ... | @@ -552,6 +576,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P |
| 552 | 576 | ||
| 553 | export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> { | 577 | export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise<void> { |
| 554 | useSavedEnvironmentRegistryStore.getState().remove(environmentId); | 578 | useSavedEnvironmentRegistryStore.getState().remove(environmentId); |
| 579 | await removeSavedEnvironmentBearerToken(environmentId); | ||
| 555 | await disconnectSavedEnvironment(environmentId); | 580 | await disconnectSavedEnvironment(environmentId); |
| 556 | } | 581 | } |
| 557 | 582 | ||
| ... | @@ -585,12 +610,30 @@ export async function addSavedEnvironment(input: { | ... | @@ -585,12 +610,30 @@ export async function addSavedEnvironment(input: { |
| 585 | label: input.label.trim() || descriptor.label, | 610 | label: input.label.trim() || descriptor.label, |
| 586 | wsBaseUrl: resolvedTarget.wsBaseUrl, | 611 | wsBaseUrl: resolvedTarget.wsBaseUrl, |
| 587 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 612 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 588 | bearerToken: bearerSession.sessionToken, | ||
| 589 | createdAt: isoNow(), | 613 | createdAt: isoNow(), |
| 590 | lastConnectedAt: isoNow(), | 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 | await ensureSavedEnvironmentConnection(record, { | 635 | await ensureSavedEnvironmentConnection(record, { |
| 636 | bearerToken: bearerSession.sessionToken, | ||
| 594 | role: bearerSession.role, | 637 | role: bearerSession.role, |
| 595 | }); | 638 | }); |
| 596 | useSavedEnvironmentRegistryStore.getState().upsert(record); | 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,33 +9,88 @@ |
| 9 | * write. The hook transparently routes reads/writes to the correct backing | 9 | * write. The hook transparently routes reads/writes to the correct backing |
| 10 | * store. | 10 | * store. |
| 11 | */ | 11 | */ |
| 12 | import { useCallback, useMemo } from "react"; | 12 | import { useCallback, useMemo, useSyncExternalStore } from "react"; |
| 13 | import { | 13 | import { ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; |
| 14 | ServerSettings, | ||
| 15 | ServerSettingsPatch, | ||
| 16 | ModelSelection, | ||
| 17 | ThreadEnvMode, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 19 | import { | 14 | import { |
| 20 | type ClientSettings, | 15 | type ClientSettings, |
| 21 | ClientSettingsSchema, | ||
| 22 | DEFAULT_CLIENT_SETTINGS, | 16 | DEFAULT_CLIENT_SETTINGS, |
| 23 | DEFAULT_UNIFIED_SETTINGS, | 17 | DEFAULT_UNIFIED_SETTINGS, |
| 24 | SidebarProjectSortOrder, | ||
| 25 | SidebarThreadSortOrder, | ||
| 26 | TimestampFormat, | ||
| 27 | UnifiedSettings, | 18 | UnifiedSettings, |
| 28 | } from "@t3tools/contracts/settings"; | 19 | } from "@t3tools/contracts/settings"; |
| 29 | import { ensureLocalApi } from "~/localApi"; | 20 | import { ensureLocalApi } from "~/localApi"; |
| 30 | import { useLocalStorage } from "./useLocalStorage"; | 21 | import { Struct } from "effect"; |
| 31 | import { normalizeCustomModelSlugs } from "~/modelSelection"; | ||
| 32 | import { Predicate, Schema, Struct } from "effect"; | ||
| 33 | import { DeepMutable } from "effect/Types"; | ||
| 34 | import { deepMerge } from "@t3tools/shared/Struct"; | 22 | import { deepMerge } from "@t3tools/shared/Struct"; |
| 35 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; | 23 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; |
| 36 | 24 | ||
| 37 | const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; | 25 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| 38 | const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; | 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 | // ── Key sets for routing patches ───────────────────────────────────── | 95 | // ── Key sets for routing patches ───────────────────────────────────── |
| 41 | 96 | ||
| ... | @@ -69,10 +124,10 @@ function splitPatch(patch: Partial<UnifiedSettings>): { | ... | @@ -69,10 +124,10 @@ function splitPatch(patch: Partial<UnifiedSettings>): { |
| 69 | 124 | ||
| 70 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { | 125 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { |
| 71 | const serverSettings = useServerSettings(); | 126 | const serverSettings = useServerSettings(); |
| 72 | const [clientSettings] = useLocalStorage( | 127 | const clientSettings = useSyncExternalStore( |
| 73 | CLIENT_SETTINGS_STORAGE_KEY, | 128 | subscribeClientSettings, |
| 74 | DEFAULT_CLIENT_SETTINGS, | 129 | getClientSettingsSnapshot, |
| 75 | ClientSettingsSchema, | 130 | () => DEFAULT_CLIENT_SETTINGS, |
| 76 | ); | 131 | ); |
| 77 | 132 | ||
| 78 | const merged = useMemo<UnifiedSettings>( | 133 | const merged = useMemo<UnifiedSettings>( |
| ... | @@ -90,34 +145,28 @@ export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) | ... | @@ -90,34 +145,28 @@ export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) |
| 90 | * Returns an updater that routes each key to the correct backing store. | 145 | * Returns an updater that routes each key to the correct backing store. |
| 91 | * | 146 | * |
| 92 | * Server keys are optimistically patched in atom-backed server state, then | 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 | export function useUpdateSettings() { | 150 | export function useUpdateSettings() { |
| 96 | const [, setClientSettings] = useLocalStorage( | 151 | const updateSettings = useCallback((patch: Partial<UnifiedSettings>) => { |
| 97 | CLIENT_SETTINGS_STORAGE_KEY, | 152 | const { serverPatch, clientPatch } = splitPatch(patch); |
| 98 | DEFAULT_CLIENT_SETTINGS, | ||
| 99 | ClientSettingsSchema, | ||
| 100 | ); | ||
| 101 | 153 | ||
| 102 | const updateSettings = useCallback( | 154 | if (Object.keys(serverPatch).length > 0) { |
| 103 | (patch: Partial<UnifiedSettings>) => { | 155 | const currentServerConfig = getServerConfig(); |
| 104 | const { serverPatch, clientPatch } = splitPatch(patch); | 156 | if (currentServerConfig) { |
| 105 | 157 | applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); | |
| 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); | ||
| 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) { | 163 | if (Object.keys(clientPatch).length > 0) { |
| 116 | setClientSettings((prev) => ({ ...prev, ...clientPatch })); | 164 | persistClientSettings({ |
| 117 | } | 165 | ...getClientSettingsSnapshot(), |
| 118 | }, | 166 | ...clientPatch, |
| 119 | [setClientSettings], | 167 | }); |
| 120 | ); | 168 | } |
| 169 | }, []); | ||
| 121 | 170 | ||
| 122 | const resetSettings = useCallback(() => { | 171 | const resetSettings = useCallback(() => { |
| 123 | updateSettings(DEFAULT_UNIFIED_SETTINGS); | 172 | updateSettings(DEFAULT_UNIFIED_SETTINGS); |
| ... | @@ -129,132 +178,9 @@ export function useUpdateSettings() { | ... | @@ -129,132 +178,9 @@ export function useUpdateSettings() { |
| 129 | }; | 178 | }; |
| 130 | } | 179 | } |
| 131 | 180 | ||
| 132 | // ── One-time migration from localStorage ───────────────────────────── | 181 | export function __resetClientSettingsPersistenceForTests(): void { |
| 133 | 182 | clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; | |
| 134 | export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record<string, unknown>) { | 183 | clientSettingsHydrated = false; |
| 135 | const patch: DeepMutable<ServerSettingsPatch> = {}; | 184 | clientSettingsHydrationPromise = null; |
| 136 | 185 | clientSettingsListeners.clear(); | |
| 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 | } | ||
| 260 | } | 186 | } |
apps/web/src/localApi.test.ts+155-1| ... | @@ -137,9 +137,36 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn | ... | @@ -137,9 +137,36 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn |
| 137 | return testGlobal.window; | 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 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { | 160 | function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { |
| 141 | return { | 161 | return { |
| 142 | getLocalEnvironmentBootstrap: () => null, | 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 | getServerExposureState: async () => ({ | 170 | getServerExposureState: async () => ({ |
| 144 | mode: "local-only", | 171 | mode: "local-only", |
| 145 | endpointUrl: null, | 172 | endpointUrl: null, |
| ... | @@ -242,7 +269,12 @@ beforeEach(() => { | ... | @@ -242,7 +269,12 @@ beforeEach(() => { |
| 242 | terminalEventListeners.clear(); | 269 | terminalEventListeners.clear(); |
| 243 | orchestrationEventListeners.clear(); | 270 | orchestrationEventListeners.clear(); |
| 244 | gitStatusListeners.clear(); | 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 | afterEach(() => { | 280 | afterEach(() => { |
| ... | @@ -463,4 +495,126 @@ describe("wsApi", () => { | ... | @@ -463,4 +495,126 @@ describe("wsApi", () => { |
| 463 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); | 495 | await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); |
| 464 | expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); | 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,6 +14,15 @@ import { |
| 14 | } from "./environments/runtime"; | 14 | } from "./environments/runtime"; |
| 15 | import { type WsRpcClient } from "./rpc/wsRpcClient"; | 15 | import { type WsRpcClient } from "./rpc/wsRpcClient"; |
| 16 | import { showContextMenuFallback } from "./contextMenuFallback"; | 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 | let cachedApi: LocalApi | undefined; | 27 | let cachedApi: LocalApi | undefined; |
| 19 | 28 | ||
| ... | @@ -56,6 +65,50 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | ... | @@ -56,6 +65,50 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { |
| 56 | return showContextMenuFallback(items, position); | 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 | server: { | 112 | server: { |
| 60 | getConfig: rpcClient.server.getConfig, | 113 | getConfig: rpcClient.server.getConfig, |
| 61 | refreshProviders: rpcClient.server.refreshProviders, | 114 | refreshProviders: rpcClient.server.refreshProviders, |
| ... | @@ -89,6 +142,8 @@ export function ensureLocalApi(): LocalApi { | ... | @@ -89,6 +142,8 @@ export function ensureLocalApi(): LocalApi { |
| 89 | 142 | ||
| 90 | export async function __resetLocalApiForTests() { | 143 | export async function __resetLocalApiForTests() { |
| 91 | cachedApi = undefined; | 144 | cachedApi = undefined; |
| 145 | const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); | ||
| 146 | __resetClientSettingsPersistenceForTests(); | ||
| 92 | await resetEnvironmentServiceForTests(); | 147 | await resetEnvironmentServiceForTests(); |
| 93 | resetGitStatusStateForTests(); | 148 | resetGitStatusStateForTests(); |
| 94 | resetRequestLatencyStateForTests(); | 149 | resetRequestLatencyStateForTests(); |
apps/web/src/routes/__root.tsx-2| ... | @@ -31,7 +31,6 @@ import { | ... | @@ -31,7 +31,6 @@ import { |
| 31 | } from "../rpc/serverState"; | 31 | } from "../rpc/serverState"; |
| 32 | import { useStore } from "../store"; | 32 | import { useStore } from "../store"; |
| 33 | import { useUiStateStore } from "../uiStateStore"; | 33 | import { useUiStateStore } from "../uiStateStore"; |
| 34 | import { migrateLocalSettingsToServer } from "../hooks/useSettings"; | ||
| 35 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; | 34 | import { syncBrowserChromeTheme } from "../hooks/useTheme"; |
| 36 | import { | 35 | import { |
| 37 | ensureEnvironmentConnectionBootstrapped, | 36 | ensureEnvironmentConnectionBootstrapped, |
| ... | @@ -213,7 +212,6 @@ function EventRouter() { | ... | @@ -213,7 +212,6 @@ function EventRouter() { |
| 213 | 212 | ||
| 214 | updatePrimaryEnvironmentDescriptor(payload.environment); | 213 | updatePrimaryEnvironmentDescriptor(payload.environment); |
| 215 | setActiveEnvironmentId(payload.environment.environmentId); | 214 | setActiveEnvironmentId(payload.environment.environmentId); |
| 216 | migrateLocalSettingsToServer(); | ||
| 217 | void (async () => { | 215 | void (async () => { |
| 218 | await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); | 216 | await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); |
| 219 | if (disposedRef.current) { | 217 | if (disposedRef.current) { |
apps/web/vite.config.ts+2| ... | @@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" }; | ... | @@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" }; |
| 7 | 7 | ||
| 8 | const port = Number(process.env.PORT ?? 5733); | 8 | const port = Number(process.env.PORT ?? 5733); |
| 9 | const host = process.env.HOST?.trim() || "localhost"; | 9 | const host = process.env.HOST?.trim() || "localhost"; |
| 10 | const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); | ||
| 10 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); | 11 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); |
| 11 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); | 12 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); |
| 12 | 13 | ||
| ... | @@ -58,6 +59,7 @@ export default defineConfig({ | ... | @@ -58,6 +59,7 @@ export default defineConfig({ |
| 58 | include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], | 59 | include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], |
| 59 | }, | 60 | }, |
| 60 | define: { | 61 | define: { |
| 62 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), | ||
| 61 | // In dev mode, tell the web app where the WebSocket server lives | 63 | // In dev mode, tell the web app where the WebSocket server lives |
| 62 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), | 64 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 63 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), | 65 | "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), |
packages/contracts/src/ipc.ts+31-1| ... | @@ -49,8 +49,9 @@ import type { | ... | @@ -49,8 +49,9 @@ import type { |
| 49 | OrchestrationEvent, | 49 | OrchestrationEvent, |
| 50 | OrchestrationReadModel, | 50 | OrchestrationReadModel, |
| 51 | } from "./orchestration"; | 51 | } from "./orchestration"; |
| 52 | import type { EnvironmentId } from "./baseSchemas"; | ||
| 52 | import { EditorId } from "./editor"; | 53 | import { EditorId } from "./editor"; |
| 53 | import { ServerSettings, ServerSettingsPatch } from "./settings"; | 54 | import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; |
| 54 | 55 | ||
| 55 | export interface ContextMenuItem<T extends string = string> { | 56 | export interface ContextMenuItem<T extends string = string> { |
| 56 | id: T; | 57 | id: T; |
| ... | @@ -112,6 +113,15 @@ export interface DesktopEnvironmentBootstrap { | ... | @@ -112,6 +113,15 @@ export interface DesktopEnvironmentBootstrap { |
| 112 | bootstrapToken?: string; | 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 | export type DesktopServerExposureMode = "local-only" | "network-accessible"; | 125 | export type DesktopServerExposureMode = "local-only" | "network-accessible"; |
| 116 | 126 | ||
| 117 | export interface DesktopServerExposureState { | 127 | export interface DesktopServerExposureState { |
| ... | @@ -122,6 +132,15 @@ export interface DesktopServerExposureState { | ... | @@ -122,6 +132,15 @@ export interface DesktopServerExposureState { |
| 122 | 132 | ||
| 123 | export interface DesktopBridge { | 133 | export interface DesktopBridge { |
| 124 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | 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 | getServerExposureState: () => Promise<DesktopServerExposureState>; | 144 | getServerExposureState: () => Promise<DesktopServerExposureState>; |
| 126 | setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>; | 145 | setServerExposureMode: (mode: DesktopServerExposureMode) => Promise<DesktopServerExposureState>; |
| 127 | pickFolder: () => Promise<string | null>; | 146 | pickFolder: () => Promise<string | null>; |
| ... | @@ -165,6 +184,17 @@ export interface LocalApi { | ... | @@ -165,6 +184,17 @@ export interface LocalApi { |
| 165 | position?: { x: number; y: number }, | 184 | position?: { x: number; y: number }, |
| 166 | ) => Promise<T | null>; | 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 | server: { | 198 | server: { |
| 169 | getConfig: () => Promise<ServerConfig>; | 199 | getConfig: () => Promise<ServerConfig>; |
| 170 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; | 200 | refreshProviders: () => Promise<ServerProviderUpdatedPayload>; |
scripts/dev-runner.test.ts+2| ... | @@ -84,6 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -84,6 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 84 | 84 | ||
| 85 | assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); | 85 | assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); |
| 86 | assert.equal(env.T3CODE_PORT, "4222"); | 86 | assert.equal(env.T3CODE_PORT, "4222"); |
| 87 | assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); | ||
| 87 | assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); | 88 | assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); |
| 88 | assert.equal(env.T3CODE_NO_BROWSER, "1"); | 89 | assert.equal(env.T3CODE_NO_BROWSER, "1"); |
| 89 | assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); | 90 | assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); |
| ... | @@ -183,6 +184,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -183,6 +184,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 183 | assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); | 184 | assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); |
| 184 | assert.equal(env.HOST, "127.0.0.1"); | 185 | assert.equal(env.HOST, "127.0.0.1"); |
| 185 | assert.equal(env.T3CODE_PORT, "4222"); | 186 | assert.equal(env.T3CODE_PORT, "4222"); |
| 187 | assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222"); | ||
| 186 | assert.equal(env.T3CODE_MODE, undefined); | 188 | assert.equal(env.T3CODE_MODE, undefined); |
| 187 | assert.equal(env.T3CODE_NO_BROWSER, undefined); | 189 | assert.equal(env.T3CODE_NO_BROWSER, undefined); |
| 188 | assert.equal(env.T3CODE_HOST, undefined); | 190 | assert.equal(env.T3CODE_HOST, undefined); |
scripts/dev-runner.ts+2| ... | @@ -159,9 +159,11 @@ export function createDevRunnerEnv({ | ... | @@ -159,9 +159,11 @@ export function createDevRunnerEnv({ |
| 159 | 159 | ||
| 160 | if (!isDesktopMode) { | 160 | if (!isDesktopMode) { |
| 161 | output.T3CODE_PORT = String(serverPort); | 161 | output.T3CODE_PORT = String(serverPort); |
| 162 | output.VITE_HTTP_URL = `http://localhost:${serverPort}`; | ||
| 162 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; | 163 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; |
| 163 | } else { | 164 | } else { |
| 164 | output.T3CODE_PORT = String(serverPort); | 165 | output.T3CODE_PORT = String(serverPort); |
| 166 | output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; | ||
| 165 | output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; | 167 | output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; |
| 166 | delete output.T3CODE_MODE; | 168 | delete output.T3CODE_MODE; |
| 167 | delete output.T3CODE_NO_BROWSER; | 169 | delete output.T3CODE_NO_BROWSER; |