From e32077cee78c6867cc79b2964c82e4c12d3e213f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Apr 2026 20:18:41 -0700 Subject: [PATCH] Persist client settings and saved environment secrets (#1868) --- apps/desktop/src/clientPersistence.test.ts | 234 +++++++++++++++ apps/desktop/src/clientPersistence.ts | 216 ++++++++++++++ apps/desktop/src/main.ts | 109 +++++++ apps/desktop/src/preload.ts | 18 ++ apps/web/src/clientPersistenceStorage.test.ts | 80 ++++++ apps/web/src/clientPersistenceStorage.ts | 194 +++++++++++++ .../src/components/ChatMarkdown.browser.tsx | 3 + .../components/GitActionsControl.browser.tsx | 3 + .../ThreadTerminalDrawer.browser.tsx | 3 + .../settings/SettingsPanels.browser.tsx | 7 + .../environments/primary/bootstrap.test.ts | 26 ++ apps/web/src/environments/primary/target.ts | 30 +- .../src/environments/runtime/catalog.test.ts | 98 ++++++- apps/web/src/environments/runtime/catalog.ts | 226 ++++++++++----- .../service.addSavedEnvironment.test.ts | 105 +++++++ apps/web/src/environments/runtime/service.ts | 55 +++- apps/web/src/hooks/useSettings.test.ts | 16 -- apps/web/src/hooks/useSettings.ts | 270 +++++++----------- apps/web/src/localApi.test.ts | 156 +++++++++- apps/web/src/localApi.ts | 55 ++++ apps/web/src/routes/__root.tsx | 2 - apps/web/vite.config.ts | 2 + packages/contracts/src/ipc.ts | 32 ++- scripts/dev-runner.test.ts | 2 + scripts/dev-runner.ts | 2 + 25 files changed, 1667 insertions(+), 277 deletions(-) create mode 100644 apps/desktop/src/clientPersistence.test.ts create mode 100644 apps/desktop/src/clientPersistence.ts create mode 100644 apps/web/src/clientPersistenceStorage.test.ts create mode 100644 apps/web/src/clientPersistenceStorage.ts create mode 100644 apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts delete mode 100644 apps/web/src/hooks/useSettings.test.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a8410c8d9b2e1bc52f025939eeb7acb208aaed5 --- /dev/null +++ b/apps/desktop/src/clientPersistence.test.ts @@ -0,0 +1,234 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + EnvironmentId, + type ClientSettings, + type PersistedSavedEnvironmentRecord, +} from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + readClientSettings, + readSavedEnvironmentRegistry, + readSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + writeClientSettings, + writeSavedEnvironmentRegistry, + writeSavedEnvironmentSecret, + type DesktopSecretStorage, +} from "./clientPersistence"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeTempPath(fileName: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-")); + tempDirectories.push(directory); + return path.join(directory, fileName); +} + +function makeSecretStorage(available: boolean): DesktopSecretStorage { + return { + isEncryptionAvailable: () => available, + encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), + decryptString: (value) => { + const decoded = value.toString("utf8"); + if (!decoded.startsWith("enc:")) { + throw new Error("invalid secret"); + } + return decoded.slice("enc:".length); + }, + }; +} + +const clientSettings: ClientSettings = { + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", +}; + +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { + environmentId: EnvironmentId.makeUnsafe("environment-1"), + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: "2026-04-09T01:00:00.000Z", +}; + +describe("clientPersistence", () => { + it("persists and reloads client settings", () => { + const settingsPath = makeTempPath("client-settings.json"); + + writeClientSettings(settingsPath, clientSettings); + + expect(readClientSettings(settingsPath)).toEqual(clientSettings); + }); + + it("persists and reloads saved environment metadata", () => { + const registryPath = makeTempPath("saved-environments.json"); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); + }); + + it("persists encrypted saved environment secrets when encryption is available", () => { + const registryPath = makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + expect( + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage, + }), + ).toBe(true); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage, + }), + ).toBe("bearer-token"); + + expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ + records: [ + { + ...savedRegistryRecord, + encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), + }, + ], + }); + }); + + it("preserves existing secrets when encryption is unavailable", () => { + const registryPath = makeTempPath("saved-environments.json"); + const availableSecretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage: availableSecretStorage, + }); + + expect( + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + secretStorage: makeSecretStorage(false), + }), + ).toBe(false); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage: availableSecretStorage, + }), + ).toBe("bearer-token"); + }); + + it("removes saved environment secrets", () => { + const registryPath = makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage, + }); + + removeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + }); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage, + }), + ).toBeNull(); + }); + + it("treats malformed secrets documents as empty", () => { + const registryPath = makeTempPath("saved-environments.json"); + fs.writeFileSync(registryPath, "{}\n", "utf8"); + + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage: makeSecretStorage(true), + }), + ).toBeNull(); + + expect(() => + removeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + }), + ).not.toThrow(); + }); + + it("returns false when writing a secret without metadata", () => { + const registryPath = makeTempPath("saved-environments.json"); + + expect( + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage: makeSecretStorage(true), + }), + ).toBe(false); + }); + + it("preserves encrypted secrets when metadata is rewritten", () => { + const registryPath = makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + writeSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage, + }); + + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + + expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); + expect( + readSavedEnvironmentSecret({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage, + }), + ).toBe("bearer-token"); + }); +}); diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts new file mode 100644 index 0000000000000000000000000000000000000000..183de1a971b16536f536373e244e42e66a1955bf --- /dev/null +++ b/apps/desktop/src/clientPersistence.ts @@ -0,0 +1,216 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +import type { ClientSettings, PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { Predicate } from "effect"; + +interface ClientSettingsDocument { + readonly settings: ClientSettings; +} + +interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { + readonly encryptedBearerToken?: string; +} + +interface SavedEnvironmentRegistryDocument { + readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +export interface DesktopSecretStorage { + readonly isEncryptionAvailable: () => boolean; + readonly encryptString: (value: string) => Buffer; + readonly decryptString: (value: Buffer) => string; +} + +function readJsonFile(filePath: string): T | null { + try { + if (!FS.existsSync(filePath)) { + return null; + } + return JSON.parse(FS.readFileSync(filePath, "utf8")) as T; + } catch { + return null; + } +} + +function writeJsonFile(filePath: string, value: unknown): void { + const directory = Path.dirname(filePath); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + FS.mkdirSync(directory, { recursive: true }); + FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + FS.renameSync(tempPath, filePath); +} + +function isPersistedSavedEnvironmentStorageRecord( + value: unknown, +): value is PersistedSavedEnvironmentStorageRecord { + return ( + Predicate.isObject(value) && + typeof value.environmentId === "string" && + typeof value.label === "string" && + typeof value.httpBaseUrl === "string" && + typeof value.wsBaseUrl === "string" && + typeof value.createdAt === "string" && + (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && + (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") + ); +} + +function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument { + const parsed = readJsonFile(filePath); + if (!Predicate.isObject(parsed)) { + return { records: [] }; + } + + return { + records: Array.isArray(parsed.records) + ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord) + : [], + }; +} + +function toPersistedSavedEnvironmentRecord( + record: PersistedSavedEnvironmentStorageRecord, +): PersistedSavedEnvironmentRecord { + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; +} + +export function readClientSettings(settingsPath: string): ClientSettings | null { + return readJsonFile(settingsPath)?.settings ?? null; +} + +export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { + writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); +} + +export function readSavedEnvironmentRegistry( + registryPath: string, +): readonly PersistedSavedEnvironmentRecord[] { + return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => + toPersistedSavedEnvironmentRecord(record), + ); +} + +export function writeSavedEnvironmentRegistry( + registryPath: string, + records: readonly PersistedSavedEnvironmentRecord[], +): void { + const currentDocument = readSavedEnvironmentRegistryDocument(registryPath); + const encryptedBearerTokenById = new Map( + currentDocument.records.flatMap((record) => + record.encryptedBearerToken + ? [[record.environmentId, record.encryptedBearerToken] as const] + : [], + ), + ); + writeJsonFile(registryPath, { + records: records.map((record) => { + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); + return encryptedBearerToken + ? { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + encryptedBearerToken, + } + : record; + }), + } satisfies SavedEnvironmentRegistryDocument); +} + +export function readSavedEnvironmentSecret(input: { + readonly registryPath: string; + readonly environmentId: string; + readonly secretStorage: DesktopSecretStorage; +}): string | null { + const document = readSavedEnvironmentRegistryDocument(input.registryPath); + const encoded = document.records.find( + (record) => record.environmentId === input.environmentId, + )?.encryptedBearerToken; + if (!encoded) { + return null; + } + + if (!input.secretStorage.isEncryptionAvailable()) { + return null; + } + + try { + return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); + } catch { + return null; + } +} + +export function writeSavedEnvironmentSecret(input: { + readonly registryPath: string; + readonly environmentId: string; + readonly secret: string; + readonly secretStorage: DesktopSecretStorage; +}): boolean { + const document = readSavedEnvironmentRegistryDocument(input.registryPath); + + if (!input.secretStorage.isEncryptionAvailable()) { + return false; + } + + let found = false; + + writeJsonFile(input.registryPath, { + records: document.records.map((record) => { + if (record.environmentId !== input.environmentId) { + return record; + } + + found = true; + const encryptedBearerToken = input.secretStorage + .encryptString(input.secret) + .toString("base64"); + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + encryptedBearerToken, + } satisfies PersistedSavedEnvironmentStorageRecord; + }), + } satisfies SavedEnvironmentRegistryDocument); + return found; +} + +export function removeSavedEnvironmentSecret(input: { + readonly registryPath: string; + readonly environmentId: string; +}): void { + const document = readSavedEnvironmentRegistryDocument(input.registryPath); + if ( + !document.records.some( + (record) => + record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + writeJsonFile(input.registryPath, { + records: document.records.map((record) => { + if (record.environmentId !== input.environmentId) { + return record; + } + + return toPersistedSavedEnvironmentRecord(record); + }), + } satisfies SavedEnvironmentRegistryDocument); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9725c24f5bf75daa139a95552eab5343fbc9beee..0c3268f492269effdd700cab659912008eb89deb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,14 +14,17 @@ import { nativeImage, nativeTheme, protocol, + safeStorage, shell, } from "electron"; import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { + ClientSettings, DesktopTheme, DesktopServerExposureMode, DesktopServerExposureState, + PersistedSavedEnvironmentRecord, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, @@ -38,6 +41,15 @@ import { setDesktopServerExposurePreference, writeDesktopSettings, } from "./desktopSettings"; +import { + readClientSettings, + readSavedEnvironmentRegistry, + readSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + writeClientSettings, + writeSavedEnvironmentRegistry, + writeSavedEnvironmentSecret, +} from "./clientPersistence"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { resolveDesktopServerExposure } from "./serverExposure"; @@ -71,11 +83,20 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; +const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; +const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; +const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; +const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; +const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); +const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); +const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -204,6 +225,14 @@ function getDesktopServerExposureState(): DesktopServerExposureState { }; } +function getDesktopSecretStorage() { + return { + isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), + encryptString: (value: string) => safeStorage.encryptString(value), + decryptString: (value: Buffer) => safeStorage.decryptString(value), + } as const; +} + function resolveAdvertisedHostOverride(): string | undefined { const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); return override && override.length > 0 ? override : undefined; @@ -1349,6 +1378,86 @@ function registerIpcHandlers(): void { } as const; }); + ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); + ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); + + ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); + ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { + if (typeof rawSettings !== "object" || rawSettings === null) { + throw new Error("Invalid client settings payload."); + } + + writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); + }); + + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); + ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => + readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH), + ); + + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); + ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => { + if (!Array.isArray(rawRecords)) { + throw new Error("Invalid saved environment registry payload."); + } + + writeSavedEnvironmentRegistry( + SAVED_ENVIRONMENT_REGISTRY_PATH, + rawRecords as readonly PersistedSavedEnvironmentRecord[], + ); + }); + + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + return null; + } + + return readSavedEnvironmentSecret({ + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, + environmentId: rawEnvironmentId, + secretStorage: getDesktopSecretStorage(), + }); + }, + ); + + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + throw new Error("Invalid saved environment id."); + } + if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { + throw new Error("Invalid saved environment secret."); + } + + return writeSavedEnvironmentSecret({ + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, + environmentId: rawEnvironmentId, + secret: rawSecret, + secretStorage: getDesktopSecretStorage(), + }); + }, + ); + + ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + return; + } + + removeSavedEnvironmentSecret({ + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, + environmentId: rawEnvironmentId, + }); + }, + ); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 60392f7dba149a4595b3d4722643c6a4c0190528..e3107a9248599f1c3fe242937f9235ad665c9332 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,6 +13,13 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; +const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; +const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; +const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; +const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; +const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; @@ -24,6 +31,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), + setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), + getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), + setSavedEnvironmentRegistry: (records) => + ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), + getSavedEnvironmentSecret: (environmentId) => + ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + setSavedEnvironmentSecret: (environmentId, secret) => + ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), + removeSavedEnvironmentSecret: (environmentId) => + ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed88fe6f7088a8b3e2fe4136426cfdd9b77598b7 --- /dev/null +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -0,0 +1,80 @@ +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const testEnvironmentId = EnvironmentId.makeUnsafe("environment-1"); + +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { + environmentId: testEnvironmentId, + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: null, +}; + +function createLocalStorageStub(): Storage { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} + +function getTestWindow(): Window & typeof globalThis { + const localStorage = createLocalStorageStub(); + const testWindow = { + localStorage, + } as Window & typeof globalThis; + vi.stubGlobal("window", testWindow); + vi.stubGlobal("localStorage", localStorage); + return testWindow; +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("clientPersistenceStorage", () => { + it("stores browser secrets inline with the saved environment record", async () => { + const testWindow = getTestWindow(); + const { + SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, + readBrowserSavedEnvironmentRegistry, + readBrowserSavedEnvironmentSecret, + writeBrowserSavedEnvironmentRegistry, + writeBrowserSavedEnvironmentSecret, + } = await import("./clientPersistenceStorage"); + + writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); + expect(writeBrowserSavedEnvironmentSecret(testEnvironmentId, "bearer-token")).toBe(true); + writeBrowserSavedEnvironmentRegistry([savedRegistryRecord]); + + expect(readBrowserSavedEnvironmentRegistry()).toEqual([savedRegistryRecord]); + expect(readBrowserSavedEnvironmentSecret(testEnvironmentId)).toBe("bearer-token"); + expect( + JSON.parse(testWindow.localStorage.getItem(SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY)!), + ).toEqual({ + version: 1, + records: [ + { + ...savedRegistryRecord, + bearerToken: "bearer-token", + }, + ], + }); + }); +}); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts new file mode 100644 index 0000000000000000000000000000000000000000..70f51d5c30aa6ba017935aadf472e49bfe65e400 --- /dev/null +++ b/apps/web/src/clientPersistenceStorage.ts @@ -0,0 +1,194 @@ +import { + ClientSettingsSchema, + EnvironmentId, + type ClientSettings, + type EnvironmentId as EnvironmentIdValue, + type PersistedSavedEnvironmentRecord, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; + +export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +export const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; + +const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + bearerToken: Schema.optionalKey(Schema.String), +}); +type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; + +const BrowserSavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), + records: Schema.optionalKey(Schema.Array(BrowserSavedEnvironmentRecordSchema)), +}); +type BrowserSavedEnvironmentRegistryDocument = + typeof BrowserSavedEnvironmentRegistryDocumentSchema.Type; + +function hasWindow(): boolean { + return typeof window !== "undefined"; +} + +function toPersistedSavedEnvironmentRecord( + record: PersistedSavedEnvironmentRecord, +): PersistedSavedEnvironmentRecord { + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; +} + +export function readBrowserClientSettings(): ClientSettings | null { + if (!hasWindow()) { + return null; + } + + try { + return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); + } catch { + return null; + } +} + +export function writeBrowserClientSettings(settings: ClientSettings): void { + if (!hasWindow()) { + return; + } + + setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); +} + +function readBrowserSavedEnvironmentRegistryDocument(): BrowserSavedEnvironmentRegistryDocument { + if (!hasWindow()) { + return {}; + } + + try { + const parsed = getLocalStorageItem( + SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, + BrowserSavedEnvironmentRegistryDocumentSchema, + ); + return parsed ?? {}; + } catch { + return {}; + } +} + +function writeBrowserSavedEnvironmentRegistryDocument( + document: BrowserSavedEnvironmentRegistryDocument, +): void { + if (!hasWindow()) { + return; + } + + setLocalStorageItem( + SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, + document, + BrowserSavedEnvironmentRegistryDocumentSchema, + ); +} + +function readBrowserSavedEnvironmentRecordsWithSecrets(): ReadonlyArray { + return readBrowserSavedEnvironmentRegistryDocument().records ?? []; +} + +function writeBrowserSavedEnvironmentRecords( + records: ReadonlyArray, +): void { + writeBrowserSavedEnvironmentRegistryDocument({ + version: 1, + records, + }); +} + +export function readBrowserSavedEnvironmentRegistry(): ReadonlyArray { + return readBrowserSavedEnvironmentRecordsWithSecrets().map((record) => + toPersistedSavedEnvironmentRecord(record), + ); +} + +export function writeBrowserSavedEnvironmentRegistry( + records: ReadonlyArray, +): void { + const existing = new Map( + readBrowserSavedEnvironmentRecordsWithSecrets().map( + (record) => [record.environmentId, record] as const, + ), + ); + writeBrowserSavedEnvironmentRecords( + records.map((record) => { + const bearerToken = existing.get(record.environmentId)?.bearerToken; + return bearerToken + ? { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + bearerToken, + } + : toPersistedSavedEnvironmentRecord(record); + }), + ); +} + +export function readBrowserSavedEnvironmentSecret( + environmentId: EnvironmentIdValue, +): string | null { + return ( + readBrowserSavedEnvironmentRecordsWithSecrets().find( + (record) => record.environmentId === environmentId, + )?.bearerToken ?? null + ); +} + +export function writeBrowserSavedEnvironmentSecret( + environmentId: EnvironmentIdValue, + secret: string, +): boolean { + const document = readBrowserSavedEnvironmentRegistryDocument(); + const records = document.records ?? []; + let found = false; + writeBrowserSavedEnvironmentRegistryDocument({ + version: document.version ?? 1, + records: records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + found = true; + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + bearerToken: secret, + } satisfies BrowserSavedEnvironmentRecord; + }), + }); + return found; +} + +export function removeBrowserSavedEnvironmentSecret(environmentId: EnvironmentIdValue): void { + const document = readBrowserSavedEnvironmentRegistryDocument(); + writeBrowserSavedEnvironmentRegistryDocument({ + version: document.version ?? 1, + records: (document.records ?? []).map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); +} diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 28f8b5fb5312ab8dc73283c50d14b9ba2f10d0a9..48f345bd2f9bc44b3237e32bb76816f8029f48af 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -17,6 +17,9 @@ vi.mock("../editorPreferences", () => ({ })); vi.mock("../localApi", () => ({ + ensureLocalApi: vi.fn(() => { + throw new Error("ensureLocalApi not implemented in browser test"); + }), readLocalApi: readLocalApiMock, })); diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 544bbaddecc467e32c68a42c9d5928f77daa83c3..71131d6696e14e36f4ce87eaeb95470e4c2ce532 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -126,6 +126,9 @@ vi.mock("~/lib/gitStatusState", () => ({ })); vi.mock("~/localApi", () => ({ + ensureLocalApi: vi.fn(() => { + throw new Error("ensureLocalApi not implemented in browser test"); + }), readLocalApi: vi.fn(() => null), })); diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 4066882f7bd84f05b655f9bf4aa1996beadc6a74..599f85a1c8b868d7954e8747e92323a10cd8718b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -113,6 +113,9 @@ vi.mock("~/environmentApi", () => ({ })); vi.mock("~/localApi", () => ({ + ensureLocalApi: vi.fn(() => { + throw new Error("ensureLocalApi not implemented in browser test"); + }), readLocalApi: readLocalApiMock, })); diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index cb4c0e37fb66b0860face10d02eb8ac31c8e5ba3..b48c1ef223c07750b99c7db7b1617d9f52a77c5e 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -282,6 +282,13 @@ const createDesktopBridgeStub = (overrides?: { wsBaseUrl: "ws://127.0.0.1:3773", bootstrapToken: "desktop-bootstrap-token", }), + getClientSettings: vi.fn().mockResolvedValue(null), + setClientSettings: vi.fn().mockResolvedValue(undefined), + getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]), + setSavedEnvironmentRegistry: vi.fn().mockResolvedValue(undefined), + getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), + setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), + removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), getServerExposureState: vi.fn().mockResolvedValue( overrides?.serverExposureState ?? { mode: "local-only", diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index dae6d289253f643f41b26bde5657ffe7ce38b28b..353c151b915a3fece62c3c0ef8e52c1ff1bf04d9 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -110,6 +110,32 @@ describe("environmentBootstrap", () => { expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); }); + it("derives the websocket url when only VITE_HTTP_URL is configured", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); + + await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); + expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); + expect(getPrimaryKnownEnvironment()?.target).toEqual({ + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + }); + }); + + it("derives the http url when only VITE_WS_URL is configured", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); + + await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT); + expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment"); + expect(getPrimaryKnownEnvironment()?.target).toEqual({ + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + }); + }); + it("uses the current origin as the descriptor base for local dev environments", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT)); vi.stubGlobal("fetch", fetchMock); diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index 3c0090b792cb7b155ca47070453d89b5ff9de2d2..04b7d903d4b9d2171f3fbc72d8ba43d06b4f2729 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -16,6 +16,15 @@ function normalizeBaseUrl(rawValue: string): string { return new URL(rawValue, window.location.origin).toString(); } +function swapBaseUrlProtocol( + rawValue: string, + nextProtocol: "http:" | "https:" | "ws:" | "wss:", +): string { + const url = new URL(normalizeBaseUrl(rawValue)); + url.protocol = nextProtocol; + return url.toString(); +} + function normalizeHostname(hostname: string): string { return hostname .trim() @@ -54,22 +63,29 @@ function resolveHttpRequestBaseUrl(httpBaseUrl: string): string { } function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null { - const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim(); - const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim(); + const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim() || undefined; + const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim() || undefined; if (!configuredHttpBaseUrl && !configuredWsBaseUrl) { return null; } - if (!configuredHttpBaseUrl || !configuredWsBaseUrl) { - throw new Error("Configured primary environments require both VITE_HTTP_URL and VITE_WS_URL."); - } + const resolvedHttpBaseUrl = + configuredHttpBaseUrl ?? + (configuredWsBaseUrl?.startsWith("wss:") + ? swapBaseUrlProtocol(configuredWsBaseUrl, "https:") + : swapBaseUrlProtocol(configuredWsBaseUrl!, "http:")); + const resolvedWsBaseUrl = + configuredWsBaseUrl ?? + (configuredHttpBaseUrl?.startsWith("https:") + ? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:") + : swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:")); return { source: "configured", target: { - httpBaseUrl: normalizeBaseUrl(configuredHttpBaseUrl), - wsBaseUrl: normalizeBaseUrl(configuredWsBaseUrl), + httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl), + wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl), }, }; } diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index d0578946f4fd46f376009fe939fcddea71c377e4..4422a4f68be4adc0672979501ec0b437344418e4 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -1,17 +1,43 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vitest"; +import { + EnvironmentId, + type LocalApi, + type PersistedSavedEnvironmentRecord, +} from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetSavedEnvironmentRegistryStoreForTests, resetSavedEnvironmentRuntimeStoreForTests, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, + waitForSavedEnvironmentRegistryHydration, } from "./catalog"; describe("environment runtime catalog stores", () => { - afterEach(() => { + beforeEach(async () => { + vi.stubGlobal("window", { + nativeApi: { + persistence: { + getClientSettings: async () => null, + setClientSettings: async () => undefined, + getSavedEnvironmentRegistry: async () => [], + setSavedEnvironmentRegistry: async () => undefined, + getSavedEnvironmentSecret: async () => null, + setSavedEnvironmentSecret: async () => true, + removeSavedEnvironmentSecret: async () => undefined, + }, + } satisfies Pick, + }); + const { __resetLocalApiForTests } = await import("../../localApi"); + await __resetLocalApiForTests(); + }); + + afterEach(async () => { resetSavedEnvironmentRegistryStoreForTests(); resetSavedEnvironmentRuntimeStoreForTests(); + const { __resetLocalApiForTests } = await import("../../localApi"); + await __resetLocalApiForTests(); + vi.unstubAllGlobals(); }); it("resets the saved environment registry store state", () => { @@ -22,7 +48,6 @@ describe("environment runtime catalog stores", () => { label: "Remote environment", httpBaseUrl: "https://remote.example.com/", wsBaseUrl: "wss://remote.example.com/", - bearerToken: "token", createdAt: "2026-04-09T00:00:00.000Z", lastConnectedAt: null, }); @@ -48,4 +73,69 @@ describe("environment runtime catalog stores", () => { expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); }); + + it("does not throw when local api lookup fails during registry persistence", async () => { + vi.unstubAllGlobals(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { __resetLocalApiForTests } = await import("../../localApi"); + await __resetLocalApiForTests(); + + expect(() => + useSavedEnvironmentRegistryStore.getState().upsert({ + environmentId: EnvironmentId.makeUnsafe("environment-1"), + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: null, + }), + ).not.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith("[SAVED_ENVIRONMENTS] persist failed", expect.any(Error)); + }); + + it("does not let stale hydration overwrite records added while hydration is in flight", async () => { + let resolveRegistryRead: () => void = () => { + throw new Error("Registry read resolver was not initialized."); + }; + + vi.stubGlobal("window", { + nativeApi: { + persistence: { + getClientSettings: async () => null, + setClientSettings: async () => undefined, + getSavedEnvironmentRegistry: () => + new Promise((resolve) => { + resolveRegistryRead = () => resolve([]); + }), + setSavedEnvironmentRegistry: async () => undefined, + getSavedEnvironmentSecret: async () => null, + setSavedEnvironmentSecret: async () => true, + removeSavedEnvironmentSecret: async () => undefined, + }, + } satisfies Pick, + }); + + const { __resetLocalApiForTests } = await import("../../localApi"); + await __resetLocalApiForTests(); + + const hydrationPromise = waitForSavedEnvironmentRegistryHydration(); + + const environmentId = EnvironmentId.makeUnsafe("environment-1"); + const record = { + environmentId, + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: null, + } as const; + + useSavedEnvironmentRegistryStore.getState().upsert(record); + + resolveRegistryRead(); + await hydrationPromise; + + expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record); + }); }); diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts index 11c11fdfa868f8d797a2786fb293630410bfe772..6aecf80f8049d1cabe23bdcafcc6b836ccb87a6d 100644 --- a/apps/web/src/environments/runtime/catalog.ts +++ b/apps/web/src/environments/runtime/catalog.ts @@ -3,22 +3,19 @@ import type { AuthSessionRole, EnvironmentId, ExecutionEnvironmentDescriptor, + PersistedSavedEnvironmentRecord, ServerConfig, } from "@t3tools/contracts"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; -import { resolveStorage } from "../../lib/storage"; +import { ensureLocalApi } from "../../localApi"; import { getPrimaryKnownEnvironment } from "../primary"; -const SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY = "t3code:saved-environment-registry:v1"; - export interface SavedEnvironmentRecord { readonly environmentId: EnvironmentId; readonly label: string; readonly wsBaseUrl: string; readonly httpBaseUrl: string; - readonly bearerToken: string; readonly createdAt: string; readonly lastConnectedAt: string | null; } @@ -34,62 +31,133 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { readonly reset: () => void; } -function createSavedEnvironmentRegistryStorage() { - return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); +let savedEnvironmentRegistryHydrated = false; +let savedEnvironmentRegistryHydrationPromise: Promise | null = null; + +function toPersistedSavedEnvironmentRecord( + record: SavedEnvironmentRecord, +): PersistedSavedEnvironmentRecord { + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; +} + +function valuesOfSavedEnvironmentRegistry( + byId: Record, +): ReadonlyArray { + return Object.values(byId) as ReadonlyArray; +} + +function persistSavedEnvironmentRegistryState( + byId: Record, +): void { + try { + void ensureLocalApi() + .persistence.setSavedEnvironmentRegistry( + valuesOfSavedEnvironmentRegistry(byId).map((record) => + toPersistedSavedEnvironmentRecord(record), + ), + ) + .catch((error) => { + console.error("[SAVED_ENVIRONMENTS] persist failed", error); + }); + } catch (error) { + console.error("[SAVED_ENVIRONMENTS] persist failed", error); + } } -export const useSavedEnvironmentRegistryStore = create()( - persist( - (set) => ({ - byId: {}, - upsert: (record) => - set((state) => ({ - byId: { - ...state.byId, - [record.environmentId]: record, - }, - })), - remove: (environmentId) => - set((state) => { - const { [environmentId]: _removed, ...remaining } = state.byId; - return { - byId: remaining, - }; - }), - markConnected: (environmentId, connectedAt) => - set((state) => { - const existing = state.byId[environmentId]; - if (!existing) { - return state; - } - return { - byId: { - ...state.byId, - [environmentId]: { - ...existing, - lastConnectedAt: connectedAt, - }, - }, - }; - }), - reset: () => - set({ - byId: {}, - }), - }), - { - name: SAVED_ENVIRONMENT_REGISTRY_STORAGE_KEY, - version: 1, - storage: createJSONStorage(createSavedEnvironmentRegistryStorage), - partialize: (state) => ({ - byId: state.byId, - }), +function replaceSavedEnvironmentRegistryState( + records: ReadonlyArray, +): void { + const currentById = useSavedEnvironmentRegistryStore.getState().byId; + const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record])); + useSavedEnvironmentRegistryStore.setState({ + byId: { + ...hydratedById, + ...currentById, }, - ), -); + }); +} + +async function hydrateSavedEnvironmentRegistry(): Promise { + if (savedEnvironmentRegistryHydrated) { + return; + } + if (savedEnvironmentRegistryHydrationPromise) { + return savedEnvironmentRegistryHydrationPromise; + } + + const nextHydration = (async () => { + try { + const persistedRecords = await ensureLocalApi().persistence.getSavedEnvironmentRegistry(); + replaceSavedEnvironmentRegistryState(persistedRecords); + } catch (error) { + console.error("[SAVED_ENVIRONMENTS] hydrate failed", error); + } finally { + savedEnvironmentRegistryHydrated = true; + } + })(); + + const hydrationPromise = nextHydration.finally(() => { + if (savedEnvironmentRegistryHydrationPromise === hydrationPromise) { + savedEnvironmentRegistryHydrationPromise = null; + } + }); + savedEnvironmentRegistryHydrationPromise = hydrationPromise; + + return savedEnvironmentRegistryHydrationPromise; +} + +export const useSavedEnvironmentRegistryStore = create()((set) => ({ + byId: {}, + upsert: (record) => + set((state) => { + const byId = { + ...state.byId, + [record.environmentId]: record, + }; + persistSavedEnvironmentRegistryState(byId); + return { byId }; + }), + remove: (environmentId) => + set((state) => { + const { [environmentId]: _removed, ...remaining } = state.byId; + persistSavedEnvironmentRegistryState(remaining); + return { + byId: remaining, + }; + }), + markConnected: (environmentId, connectedAt) => + set((state) => { + const existing = state.byId[environmentId]; + if (!existing) { + return state; + } + const byId = { + ...state.byId, + [environmentId]: { + ...existing, + lastConnectedAt: connectedAt, + }, + }; + persistSavedEnvironmentRegistryState(byId); + return { byId }; + }), + reset: () => { + persistSavedEnvironmentRegistryState({}); + set({ + byId: {}, + }); + }, +})); export function hasSavedEnvironmentRegistryHydrated(): boolean { - return useSavedEnvironmentRegistryStore.persist.hasHydrated(); + return savedEnvironmentRegistryHydrated; } export function waitForSavedEnvironmentRegistryHydration(): Promise { @@ -97,17 +165,7 @@ export function waitForSavedEnvironmentRegistryHydration(): Promise { return Promise.resolve(); } - return new Promise((resolve) => { - const unsubscribe = useSavedEnvironmentRegistryStore.persist.onFinishHydration(() => { - unsubscribe(); - resolve(); - }); - - if (hasSavedEnvironmentRegistryHydrated()) { - unsubscribe(); - resolve(); - } - }); + return hydrateSavedEnvironmentRegistry(); } export function listSavedEnvironmentRecords(): ReadonlyArray { @@ -150,7 +208,39 @@ export function resolveEnvironmentHttpUrl(input: { } export function resetSavedEnvironmentRegistryStoreForTests() { - useSavedEnvironmentRegistryStore.getState().reset(); + savedEnvironmentRegistryHydrated = false; + savedEnvironmentRegistryHydrationPromise = null; + useSavedEnvironmentRegistryStore.setState({ byId: {} }); +} + +export async function persistSavedEnvironmentRecord(record: SavedEnvironmentRecord): Promise { + const byId = { + ...useSavedEnvironmentRegistryStore.getState().byId, + [record.environmentId]: record, + }; + + await ensureLocalApi().persistence.setSavedEnvironmentRegistry( + valuesOfSavedEnvironmentRegistry(byId).map((entry) => toPersistedSavedEnvironmentRecord(entry)), + ); +} + +export async function readSavedEnvironmentBearerToken( + environmentId: EnvironmentId, +): Promise { + return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); +} + +export async function writeSavedEnvironmentBearerToken( + environmentId: EnvironmentId, + bearerToken: string, +): Promise { + return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken); +} + +export async function removeSavedEnvironmentBearerToken( + environmentId: EnvironmentId, +): Promise { + await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId); } export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..41f36f3667472a9d88aeaab843619ac37ed04545 --- /dev/null +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -0,0 +1,105 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockResolveRemotePairingTarget = vi.fn(); +const mockFetchRemoteEnvironmentDescriptor = vi.fn(); +const mockBootstrapRemoteBearerSession = vi.fn(); +const mockPersistSavedEnvironmentRecord = vi.fn(); +const mockWriteSavedEnvironmentBearerToken = vi.fn(); +const mockSetSavedEnvironmentRegistry = vi.fn(); +const mockUpsert = vi.fn(); +const mockListSavedEnvironmentRecords = vi.fn(); + +vi.mock("../remote/target", () => ({ + resolveRemotePairingTarget: mockResolveRemotePairingTarget, +})); + +vi.mock("../remote/api", () => ({ + bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, + fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, + fetchRemoteSessionState: vi.fn(), + resolveRemoteWebSocketConnectionUrl: vi.fn(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ + persistence: { + setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry, + }, + }), +})); + +vi.mock("./catalog", () => ({ + getSavedEnvironmentRecord: vi.fn(), + hasSavedEnvironmentRegistryHydrated: vi.fn(), + listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, + persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, + readSavedEnvironmentBearerToken: vi.fn(), + removeSavedEnvironmentBearerToken: vi.fn(), + useSavedEnvironmentRegistryStore: { + getState: () => ({ + upsert: mockUpsert, + remove: vi.fn(), + markConnected: vi.fn(), + }), + }, + useSavedEnvironmentRuntimeStore: { + getState: () => ({ + ensure: vi.fn(), + patch: vi.fn(), + clear: vi.fn(), + }), + }, + waitForSavedEnvironmentRegistryHydration: vi.fn(), + writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, +})); + +vi.mock("./connection", () => ({ + createEnvironmentConnection: vi.fn(), +})); + +describe("addSavedEnvironment", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mockResolveRemotePairingTarget.mockReturnValue({ + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + credential: "pairing-code", + }); + mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ + environmentId: EnvironmentId.makeUnsafe("environment-1"), + label: "Remote environment", + }); + mockBootstrapRemoteBearerSession.mockResolvedValue({ + sessionToken: "bearer-token", + role: "owner", + }); + mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); + mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); + mockListSavedEnvironmentRecords.mockReturnValue([]); + }); + + it("rolls back persisted metadata when bearer token persistence fails", async () => { + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "remote.example.com", + pairingCode: "123456", + }), + ).rejects.toThrow("Unable to persist saved environment credentials."); + + expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledTimes(1); + expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( + EnvironmentId.makeUnsafe("environment-1"), + "bearer-token", + ); + expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]); + expect(mockUpsert).not.toHaveBeenCalled(); + + await resetEnvironmentServiceForTests(); + }); +}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index b53c9c5266f48e28db6440e24a40eaf2ebf722a9..7eb8aff43bd482a81bef76723f94f926aedbe535 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -23,6 +23,7 @@ import { markPromotedDraftThreadsByRef, useComposerDraftStore, } from "~/composerDraftStore"; +import { ensureLocalApi } from "~/localApi"; import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; import { projectQueryKeys } from "~/lib/projectReactQuery"; @@ -39,10 +40,14 @@ import { getSavedEnvironmentRecord, hasSavedEnvironmentRegistryHydrated, listSavedEnvironmentRecords, + persistSavedEnvironmentRecord, + readSavedEnvironmentBearerToken, + removeSavedEnvironmentBearerToken, type SavedEnvironmentRecord, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, waitForSavedEnvironmentRegistryHydration, + writeSavedEnvironmentBearerToken, } from "./catalog"; import { createEnvironmentConnection, type EnvironmentConnection } from "./connection"; import { @@ -306,7 +311,10 @@ function createPrimaryEnvironmentClient( return createWsRpcClient(new WsTransport(wsBaseUrl)); } -function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClient { +function createSavedEnvironmentClient( + record: SavedEnvironmentRecord, + bearerToken: string, +): WsRpcClient { useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); return createWsRpcClient( @@ -315,7 +323,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie resolveRemoteWebSocketConnectionUrl({ wsBaseUrl: record.wsBaseUrl, httpBaseUrl: record.httpBaseUrl, - bearerToken: record.bearerToken, + bearerToken, }), { onAttempt: () => { @@ -341,6 +349,7 @@ function createSavedEnvironmentClient(record: SavedEnvironmentRecord): WsRpcClie async function refreshSavedEnvironmentMetadata( record: SavedEnvironmentRecord, + bearerToken: string, client: WsRpcClient, roleHint?: AuthSessionRole | null, configHint?: ServerConfig | null, @@ -349,7 +358,7 @@ async function refreshSavedEnvironmentMetadata( configHint ? Promise.resolve(configHint) : client.server.getConfig(), fetchRemoteSessionState({ httpBaseUrl: record.httpBaseUrl, - bearerToken: record.bearerToken, + bearerToken, }), ]); @@ -408,6 +417,7 @@ async function ensureSavedEnvironmentConnection( record: SavedEnvironmentRecord, options?: { readonly client?: WsRpcClient; + readonly bearerToken?: string; readonly role?: AuthSessionRole | null; readonly serverConfig?: ServerConfig | null; }, @@ -417,7 +427,20 @@ async function ensureSavedEnvironmentConnection( return existing; } - const client = options?.client ?? createSavedEnvironmentClient(record); + const bearerToken = + options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); + if (!bearerToken) { + useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { + authState: "requires-auth", + role: null, + connectionState: "disconnected", + lastError: "Saved environment is missing its saved credential. Pair it again.", + lastErrorAt: isoNow(), + }); + throw new Error("Saved environment is missing its saved credential."); + } + + const client = options?.client ?? createSavedEnvironmentClient(record, bearerToken); const knownEnvironment = createKnownEnvironment({ id: record.environmentId, label: record.label, @@ -435,7 +458,7 @@ async function ensureSavedEnvironmentConnection( }, client, refreshMetadata: async () => { - await refreshSavedEnvironmentMetadata(record, client); + await refreshSavedEnvironmentMetadata(record, bearerToken, client); }, onConfigSnapshot: (config) => { useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { @@ -456,6 +479,7 @@ async function ensureSavedEnvironmentConnection( try { await refreshSavedEnvironmentMetadata( record, + bearerToken, client, options?.role ?? null, options?.serverConfig ?? null, @@ -552,6 +576,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise { useSavedEnvironmentRegistryStore.getState().remove(environmentId); + await removeSavedEnvironmentBearerToken(environmentId); await disconnectSavedEnvironment(environmentId); } @@ -585,12 +610,30 @@ export async function addSavedEnvironment(input: { label: input.label.trim() || descriptor.label, wsBaseUrl: resolvedTarget.wsBaseUrl, httpBaseUrl: resolvedTarget.httpBaseUrl, - bearerToken: bearerSession.sessionToken, createdAt: isoNow(), lastConnectedAt: isoNow(), }; + await persistSavedEnvironmentRecord(record); + const didPersistBearerToken = await writeSavedEnvironmentBearerToken( + environmentId, + bearerSession.sessionToken, + ); + if (!didPersistBearerToken) { + await ensureLocalApi().persistence.setSavedEnvironmentRegistry( + listSavedEnvironmentRecords().map((entry) => ({ + environmentId: entry.environmentId, + label: entry.label, + httpBaseUrl: entry.httpBaseUrl, + wsBaseUrl: entry.wsBaseUrl, + createdAt: entry.createdAt, + lastConnectedAt: entry.lastConnectedAt, + })), + ); + throw new Error("Unable to persist saved environment credentials."); + } await ensureSavedEnvironmentConnection(record, { + bearerToken: bearerSession.sessionToken, role: bearerSession.role, }); useSavedEnvironmentRegistryStore.getState().upsert(record); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts deleted file mode 100644 index 832ee17f7ffd9e5dea5586df2f02a97025c2d0cc..0000000000000000000000000000000000000000 --- a/apps/web/src/hooks/useSettings.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildLegacyClientSettingsMigrationPatch } from "./useSettings"; - -describe("buildLegacyClientSettingsMigrationPatch", () => { - it("migrates archive confirmation from legacy local settings", () => { - expect( - buildLegacyClientSettingsMigrationPatch({ - confirmThreadArchive: true, - confirmThreadDelete: false, - }), - ).toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: false, - }); - }); -}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index a953bc4656f5ebc198d1e6eea6f7755ddd04615f..3dc2cf9b2011aeff9e0343b16a1b930741e52943 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -9,33 +9,88 @@ * write. The hook transparently routes reads/writes to the correct backing * store. */ -import { useCallback, useMemo } from "react"; -import { - ServerSettings, - ServerSettingsPatch, - ModelSelection, - ThreadEnvMode, -} from "@t3tools/contracts"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; import { type ClientSettings, - ClientSettingsSchema, DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, - SidebarProjectSortOrder, - SidebarThreadSortOrder, - TimestampFormat, UnifiedSettings, } from "@t3tools/contracts/settings"; import { ensureLocalApi } from "~/localApi"; -import { useLocalStorage } from "./useLocalStorage"; -import { normalizeCustomModelSlugs } from "~/modelSelection"; -import { Predicate, Schema, Struct } from "effect"; -import { DeepMutable } from "effect/Types"; +import { Struct } from "effect"; import { deepMerge } from "@t3tools/shared/Struct"; import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; -const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; -const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; +const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; + +const clientSettingsListeners = new Set<() => void>(); +let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; +let clientSettingsHydrated = false; +let clientSettingsHydrationPromise: Promise | null = null; + +function emitClientSettingsChange() { + for (const listener of clientSettingsListeners) { + listener(); + } +} + +function getClientSettingsSnapshot(): ClientSettings { + return clientSettingsSnapshot; +} + +function replaceClientSettingsSnapshot(settings: ClientSettings): void { + clientSettingsSnapshot = settings; + emitClientSettingsChange(); +} + +function subscribeClientSettings(listener: () => void): () => void { + clientSettingsListeners.add(listener); + void hydrateClientSettings(); + return () => { + clientSettingsListeners.delete(listener); + }; +} + +async function hydrateClientSettings(): Promise { + if (clientSettingsHydrated) { + return; + } + if (clientSettingsHydrationPromise) { + return clientSettingsHydrationPromise; + } + + const nextHydration = (async () => { + try { + const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + if (persistedSettings) { + replaceClientSettingsSnapshot(persistedSettings); + } + } catch (error) { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); + } finally { + clientSettingsHydrated = true; + } + })(); + + const hydrationPromise = nextHydration.finally(() => { + if (clientSettingsHydrationPromise === hydrationPromise) { + clientSettingsHydrationPromise = null; + } + }); + clientSettingsHydrationPromise = hydrationPromise; + + return clientSettingsHydrationPromise; +} + +function persistClientSettings(settings: ClientSettings): void { + replaceClientSettingsSnapshot(settings); + void ensureLocalApi() + .persistence.setClientSettings(settings) + .catch((error) => { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error); + }); +} // ── Key sets for routing patches ───────────────────────────────────── @@ -69,10 +124,10 @@ function splitPatch(patch: Partial): { export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); - const [clientSettings] = useLocalStorage( - CLIENT_SETTINGS_STORAGE_KEY, - DEFAULT_CLIENT_SETTINGS, - ClientSettingsSchema, + const clientSettings = useSyncExternalStore( + subscribeClientSettings, + getClientSettingsSnapshot, + () => DEFAULT_CLIENT_SETTINGS, ); const merged = useMemo( @@ -90,34 +145,28 @@ export function useSettings(selector?: (s: UnifiedSettings) * Returns an updater that routes each key to the correct backing store. * * Server keys are optimistically patched in atom-backed server state, then - * persisted via RPC. Client keys go straight to localStorage. + * persisted via RPC. Client keys go through client persistence. */ export function useUpdateSettings() { - const [, setClientSettings] = useLocalStorage( - CLIENT_SETTINGS_STORAGE_KEY, - DEFAULT_CLIENT_SETTINGS, - ClientSettingsSchema, - ); + const updateSettings = useCallback((patch: Partial) => { + const { serverPatch, clientPatch } = splitPatch(patch); - const updateSettings = useCallback( - (patch: Partial) => { - const { serverPatch, clientPatch } = splitPatch(patch); - - if (Object.keys(serverPatch).length > 0) { - const currentServerConfig = getServerConfig(); - if (currentServerConfig) { - applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); - } - // Fire-and-forget RPC — push will reconcile on success - void ensureLocalApi().server.updateSettings(serverPatch); + if (Object.keys(serverPatch).length > 0) { + const currentServerConfig = getServerConfig(); + if (currentServerConfig) { + applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); } + // Fire-and-forget RPC — push will reconcile on success + void ensureLocalApi().server.updateSettings(serverPatch); + } - if (Object.keys(clientPatch).length > 0) { - setClientSettings((prev) => ({ ...prev, ...clientPatch })); - } - }, - [setClientSettings], - ); + if (Object.keys(clientPatch).length > 0) { + persistClientSettings({ + ...getClientSettingsSnapshot(), + ...clientPatch, + }); + } + }, []); const resetSettings = useCallback(() => { updateSettings(DEFAULT_UNIFIED_SETTINGS); @@ -129,132 +178,9 @@ export function useUpdateSettings() { }; } -// ── One-time migration from localStorage ───────────────────────────── - -export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record) { - const patch: DeepMutable = {}; - - if (Predicate.isBoolean(legacySettings.enableAssistantStreaming)) { - patch.enableAssistantStreaming = legacySettings.enableAssistantStreaming; - } - - if (Schema.is(ThreadEnvMode)(legacySettings.defaultThreadEnvMode)) { - patch.defaultThreadEnvMode = legacySettings.defaultThreadEnvMode; - } - - if (Schema.is(ModelSelection)(legacySettings.textGenerationModelSelection)) { - patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection; - } - - if (typeof legacySettings.codexBinaryPath === "string") { - patch.providers ??= {}; - patch.providers.codex ??= {}; - patch.providers.codex.binaryPath = legacySettings.codexBinaryPath; - } - - if (typeof legacySettings.codexHomePath === "string") { - patch.providers ??= {}; - patch.providers.codex ??= {}; - patch.providers.codex.homePath = legacySettings.codexHomePath; - } - - if (Array.isArray(legacySettings.customCodexModels)) { - patch.providers ??= {}; - patch.providers.codex ??= {}; - patch.providers.codex.customModels = normalizeCustomModelSlugs( - legacySettings.customCodexModels, - new Set(), - "codex", - ); - } - - if (Predicate.isString(legacySettings.claudeBinaryPath)) { - patch.providers ??= {}; - patch.providers.claudeAgent ??= {}; - patch.providers.claudeAgent.binaryPath = legacySettings.claudeBinaryPath; - } - - if (Array.isArray(legacySettings.customClaudeModels)) { - patch.providers ??= {}; - patch.providers.claudeAgent ??= {}; - patch.providers.claudeAgent.customModels = normalizeCustomModelSlugs( - legacySettings.customClaudeModels, - new Set(), - "claudeAgent", - ); - } - - return patch; -} - -export function buildLegacyClientSettingsMigrationPatch( - legacySettings: Record, -): Partial> { - const patch: Partial> = {}; - - if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) { - patch.confirmThreadArchive = legacySettings.confirmThreadArchive; - } - - if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) { - patch.confirmThreadDelete = legacySettings.confirmThreadDelete; - } - - if (Predicate.isBoolean(legacySettings.diffWordWrap)) { - patch.diffWordWrap = legacySettings.diffWordWrap; - } - - if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) { - patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder; - } - - if (Schema.is(SidebarThreadSortOrder)(legacySettings.sidebarThreadSortOrder)) { - patch.sidebarThreadSortOrder = legacySettings.sidebarThreadSortOrder; - } - - if (Schema.is(TimestampFormat)(legacySettings.timestampFormat)) { - patch.timestampFormat = legacySettings.timestampFormat; - } - - return patch; -} - -/** - * Call once on app startup. - * If the legacy localStorage key exists, migrate its values to the new server - * and client storage formats, then remove the legacy key so this only runs once. - */ -export function migrateLocalSettingsToServer(): void { - if (typeof window === "undefined") return; - - const raw = localStorage.getItem(OLD_SETTINGS_KEY); - if (!raw) return; - - try { - const old = JSON.parse(raw); - if (!Predicate.isObject(old)) return; - - // Migrate server-relevant keys via RPC - const serverPatch = buildLegacyServerSettingsMigrationPatch(old); - if (Object.keys(serverPatch).length > 0) { - const api = ensureLocalApi(); - void api.server.updateSettings(serverPatch); - } - - // Migrate client-only keys to the new localStorage key - const clientPatch = buildLegacyClientSettingsMigrationPatch(old); - if (Object.keys(clientPatch).length > 0) { - const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY); - const current = existing ? (JSON.parse(existing) as Record) : {}; - localStorage.setItem( - CLIENT_SETTINGS_STORAGE_KEY, - JSON.stringify({ ...current, ...clientPatch }), - ); - } - } catch (error) { - console.error("[MIGRATION] Error migrating local settings:", error); - } finally { - // Remove the legacy key regardless to keep migration one-shot behavior. - localStorage.removeItem(OLD_SETTINGS_KEY); - } +export function __resetClientSettingsPersistenceForTests(): void { + clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; + clientSettingsHydrated = false; + clientSettingsHydrationPromise = null; + clientSettingsListeners.clear(); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 911bce3222911d0860a58c4c875242928a62a763..bd04cabfaa9207122af81021e3171442949e80e9 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -137,9 +137,36 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn return testGlobal.window; } +function createLocalStorageStub(): Storage { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }; +} + function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { return { getLocalEnvironmentBootstrap: () => null, + getClientSettings: async () => null, + setClientSettings: async () => undefined, + getSavedEnvironmentRegistry: async () => [], + setSavedEnvironmentRegistry: async () => undefined, + getSavedEnvironmentSecret: async () => null, + setSavedEnvironmentSecret: async () => true, + removeSavedEnvironmentSecret: async () => undefined, getServerExposureState: async () => ({ mode: "local-only", endpointUrl: null, @@ -242,7 +269,12 @@ beforeEach(() => { terminalEventListeners.clear(); orchestrationEventListeners.clear(); gitStatusListeners.clear(); - Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); + const testWindow = getWindowForTest(); + Reflect.deleteProperty(testWindow, "desktopBridge"); + Object.defineProperty(testWindow, "localStorage", { + configurable: true, + value: createLocalStorageStub(), + }); }); afterEach(() => { @@ -463,4 +495,126 @@ describe("wsApi", () => { await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); + + it("reads and writes persistence through the desktop bridge when available", async () => { + const getClientSettings = vi.fn().mockResolvedValue({ + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", + }); + const setClientSettings = vi.fn().mockResolvedValue(undefined); + const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); + const setSavedEnvironmentRegistry = vi.fn().mockResolvedValue(undefined); + const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("bearer-token"); + const setSavedEnvironmentSecret = vi.fn().mockResolvedValue(true); + const removeSavedEnvironmentSecret = vi.fn().mockResolvedValue(undefined); + getWindowForTest().desktopBridge = makeDesktopBridge({ + getClientSettings, + setClientSettings, + getSavedEnvironmentRegistry, + setSavedEnvironmentRegistry, + getSavedEnvironmentSecret, + setSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + }); + + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(rpcClientMock as never); + + await api.persistence.getClientSettings(); + await api.persistence.setClientSettings({ + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", + }); + await api.persistence.getSavedEnvironmentRegistry(); + await api.persistence.setSavedEnvironmentRegistry([]); + await api.persistence.getSavedEnvironmentSecret(EnvironmentId.makeUnsafe("environment-local")); + await api.persistence.setSavedEnvironmentSecret( + EnvironmentId.makeUnsafe("environment-local"), + "bearer-token", + ); + await api.persistence.removeSavedEnvironmentSecret( + EnvironmentId.makeUnsafe("environment-local"), + ); + + expect(getClientSettings).toHaveBeenCalledWith(); + expect(setClientSettings).toHaveBeenCalledWith({ + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", + }); + expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); + expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); + expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); + expect(setSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local", "bearer-token"); + expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); + }); + + it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(rpcClientMock as never); + + await api.persistence.setClientSettings({ + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", + }); + await api.persistence.setSavedEnvironmentRegistry([ + { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Primary", + httpBaseUrl: "http://localhost:3000", + wsBaseUrl: "ws://localhost:3000", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: null, + }, + ]); + await api.persistence.setSavedEnvironmentSecret( + EnvironmentId.makeUnsafe("environment-local"), + "bearer-token", + ); + + await expect(api.persistence.getClientSettings()).resolves.toEqual({ + confirmThreadArchive: true, + confirmThreadDelete: false, + diffWordWrap: true, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", + }); + await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ + { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Primary", + httpBaseUrl: "http://localhost:3000", + wsBaseUrl: "ws://localhost:3000", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: null, + }, + ]); + await expect( + api.persistence.getSavedEnvironmentSecret(EnvironmentId.makeUnsafe("environment-local")), + ).resolves.toBe("bearer-token"); + + await api.persistence.removeSavedEnvironmentSecret( + EnvironmentId.makeUnsafe("environment-local"), + ); + + await expect( + api.persistence.getSavedEnvironmentSecret(EnvironmentId.makeUnsafe("environment-local")), + ).resolves.toBeNull(); + }); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2a51f102973455a6a8b77297cf227cc7352d549b..40ed014580acb50449cae7186be3e5ce57e6aae1 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -14,6 +14,15 @@ import { } from "./environments/runtime"; import { type WsRpcClient } from "./rpc/wsRpcClient"; import { showContextMenuFallback } from "./contextMenuFallback"; +import { + readBrowserClientSettings, + readBrowserSavedEnvironmentRegistry, + readBrowserSavedEnvironmentSecret, + removeBrowserSavedEnvironmentSecret, + writeBrowserClientSettings, + writeBrowserSavedEnvironmentRegistry, + writeBrowserSavedEnvironmentSecret, +} from "./clientPersistenceStorage"; let cachedApi: LocalApi | undefined; @@ -56,6 +65,50 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { return showContextMenuFallback(items, position); }, }, + persistence: { + getClientSettings: async () => { + if (window.desktopBridge) { + return window.desktopBridge.getClientSettings(); + } + return readBrowserClientSettings(); + }, + setClientSettings: async (settings) => { + if (window.desktopBridge) { + return window.desktopBridge.setClientSettings(settings); + } + writeBrowserClientSettings(settings); + }, + getSavedEnvironmentRegistry: async () => { + if (window.desktopBridge) { + return window.desktopBridge.getSavedEnvironmentRegistry(); + } + return readBrowserSavedEnvironmentRegistry(); + }, + setSavedEnvironmentRegistry: async (records) => { + if (window.desktopBridge) { + return window.desktopBridge.setSavedEnvironmentRegistry(records); + } + writeBrowserSavedEnvironmentRegistry(records); + }, + getSavedEnvironmentSecret: async (environmentId) => { + if (window.desktopBridge) { + return window.desktopBridge.getSavedEnvironmentSecret(environmentId); + } + return readBrowserSavedEnvironmentSecret(environmentId); + }, + setSavedEnvironmentSecret: async (environmentId, secret) => { + if (window.desktopBridge) { + return window.desktopBridge.setSavedEnvironmentSecret(environmentId, secret); + } + return writeBrowserSavedEnvironmentSecret(environmentId, secret); + }, + removeSavedEnvironmentSecret: async (environmentId) => { + if (window.desktopBridge) { + return window.desktopBridge.removeSavedEnvironmentSecret(environmentId); + } + removeBrowserSavedEnvironmentSecret(environmentId); + }, + }, server: { getConfig: rpcClient.server.getConfig, refreshProviders: rpcClient.server.refreshProviders, @@ -89,6 +142,8 @@ export function ensureLocalApi(): LocalApi { export async function __resetLocalApiForTests() { cachedApi = undefined; + const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); + __resetClientSettingsPersistenceForTests(); await resetEnvironmentServiceForTests(); resetGitStatusStateForTests(); resetRequestLatencyStateForTests(); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7259fd3b2da88497eeee972dd76ceb5fcdee1635..ce8d0d5dc9e27a42499548f25c9e76d2b6717b55 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -31,7 +31,6 @@ import { } from "../rpc/serverState"; import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; -import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { ensureEnvironmentConnectionBootstrapped, @@ -213,7 +212,6 @@ function EventRouter() { updatePrimaryEnvironmentDescriptor(payload.environment); setActiveEnvironmentId(payload.environment.environmentId); - migrateLocalSettingsToServer(); void (async () => { await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); if (disposedRef.current) { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7c123b4fed20c2bb358388daf2203681241efe12..01b5076695231348487f3a58514e5ca925ceae51 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; +const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); @@ -58,6 +59,7 @@ export default defineConfig({ include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"], }, define: { + "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 462c6862c7766f0dfda7e3d9b7ef28e93e11d8de..31e869380539a313d1a1dbc415ccbf3432d77229 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -49,8 +49,9 @@ import type { OrchestrationEvent, OrchestrationReadModel, } from "./orchestration"; +import type { EnvironmentId } from "./baseSchemas"; import { EditorId } from "./editor"; -import { ServerSettings, ServerSettingsPatch } from "./settings"; +import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; export interface ContextMenuItem { id: T; @@ -112,6 +113,15 @@ export interface DesktopEnvironmentBootstrap { bootstrapToken?: string; } +export interface PersistedSavedEnvironmentRecord { + environmentId: EnvironmentId; + label: string; + wsBaseUrl: string; + httpBaseUrl: string; + createdAt: string; + lastConnectedAt: string | null; +} + export type DesktopServerExposureMode = "local-only" | "network-accessible"; export interface DesktopServerExposureState { @@ -122,6 +132,15 @@ export interface DesktopServerExposureState { export interface DesktopBridge { getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; + getClientSettings: () => Promise; + setClientSettings: (settings: ClientSettings) => Promise; + getSavedEnvironmentRegistry: () => Promise; + setSavedEnvironmentRegistry: ( + records: readonly PersistedSavedEnvironmentRecord[], + ) => Promise; + getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; + setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; + removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; pickFolder: () => Promise; @@ -165,6 +184,17 @@ export interface LocalApi { position?: { x: number; y: number }, ) => Promise; }; + persistence: { + getClientSettings: () => Promise; + setClientSettings: (settings: ClientSettings) => Promise; + getSavedEnvironmentRegistry: () => Promise; + setSavedEnvironmentRegistry: ( + records: readonly PersistedSavedEnvironmentRecord[], + ) => Promise; + getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; + setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; + removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; + }; server: { getConfig: () => Promise; refreshProviders: () => Promise; diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index fc4a7b5733dc57ac409af532ca4bd5d02f9938c3..0918e6986a77a8e24e06bd9f0cd2c8d80a08c8d6 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -84,6 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); assert.equal(env.T3CODE_PORT, "4222"); + assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); assert.equal(env.T3CODE_NO_BROWSER, "1"); assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); @@ -183,6 +184,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); assert.equal(env.HOST, "127.0.0.1"); assert.equal(env.T3CODE_PORT, "4222"); + assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222"); assert.equal(env.T3CODE_MODE, undefined); assert.equal(env.T3CODE_NO_BROWSER, undefined); assert.equal(env.T3CODE_HOST, undefined); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index b50c8237f68091873d20d6a42e9821977a703908..4522c21d7027c5e81b8715692d86115e6a63b01f 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -159,9 +159,11 @@ export function createDevRunnerEnv({ if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); + output.VITE_HTTP_URL = `http://localhost:${serverPort}`; output.VITE_WS_URL = `ws://localhost:${serverPort}`; } else { output.T3CODE_PORT = String(serverPort); + output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`; delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; -- 2.54.0