diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 3c95b266bc1..3f1ae561671 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -105,10 +105,12 @@ const withIdentity = ( input: { readonly calls?: ElectronAppCalls; readonly environment?: TestEnvironmentInput; - readonly legacyPathExists?: boolean; - readonly legacyPathProbeError?: PlatformError.PlatformError; + readonly existingUserDataPaths?: readonly string[]; + readonly pathProbeError?: PlatformError.PlatformError; + readonly renameError?: PlatformError.PlatformError; readonly packageJson?: string; readonly pngIconPath?: Option.Option; + readonly renamedPaths?: Array<{ readonly from: string; readonly to: string }>; } = {}, ) => { const calls: ElectronAppCalls = input.calls ?? { @@ -123,13 +125,17 @@ const withIdentity = ( Layer.provideMerge( FileSystem.layerNoop({ exists: (path) => - input.legacyPathProbeError - ? Effect.fail(input.legacyPathProbeError) - : Effect.succeed( - input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), - ), + input.pathProbeError + ? Effect.fail(input.pathProbeError) + : Effect.succeed(input.existingUserDataPaths?.includes(path) === true), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), + rename: (from, to) => + input.renameError + ? Effect.fail(input.renameError) + : Effect.sync(() => { + input.renamedPaths?.push({ from, to }); + }), }), ), Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())), @@ -141,25 +147,106 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { - it.effect("keeps using the legacy userData path when it already exists", () => - withIdentity( + it.effect("keeps the canonical userData path when canonical and legacy paths both exist", () => { + const renamedPaths: Array<{ readonly from: string; readonly to: string }> = []; + + return withIdentity( Effect.gen(function* () { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const userDataPath = yield* identity.resolveUserDataPath; - assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code"); + assert.deepEqual(renamedPaths, []); }), - { legacyPathExists: true }, - ), - ); + { + existingUserDataPaths: [ + "/Users/alice/Library/Application Support/t3code", + "/Users/alice/Library/Application Support/T3 Code (Alpha)", + ], + renamedPaths, + }, + ); + }); - it.effect("preserves failures while inspecting the legacy userData path", () => { + it.effect("migrates the stage-matched legacy userData path into the canonical path", () => { + const renamedPaths: Array<{ readonly from: string; readonly to: string }> = []; const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; + const canonicalPath = "/Users/alice/Library/Application Support/t3code"; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, canonicalPath); + assert.deepEqual(renamedPaths, [{ from: legacyPath, to: canonicalPath }]); + }), + { + existingUserDataPaths: [legacyPath], + renamedPaths, + }, + ); + }); + + it.effect("prefers the historical packaged profile when Nightly legacy profiles coexist", () => { + const renamedPaths: Array<{ readonly from: string; readonly to: string }> = []; + const historicalLegacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; + const nightlyLegacyPath = "/Users/alice/Library/Application Support/T3 Code (Nightly)"; + const canonicalPath = "/Users/alice/Library/Application Support/t3code"; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, canonicalPath); + assert.deepEqual(renamedPaths, [{ from: historicalLegacyPath, to: canonicalPath }]); + }), + { + environment: { + appVersion: "0.0.29-nightly.20260723.864", + }, + existingUserDataPaths: [historicalLegacyPath, nightlyLegacyPath], + renamedPaths, + }, + ); + }); + + it.effect("preserves failures while inspecting the canonical userData path", () => { + const canonicalPath = "/Users/alice/Library/Application Support/t3code"; const cause = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", method: "exists", description: "permission denied", + pathOrDescriptor: canonicalPath, + }); + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); + + assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathInspectionError); + assert.equal(error.path, canonicalPath); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to inspect desktop user-data path at "${canonicalPath}".`, + ); + }), + { pathProbeError: cause }, + ); + }); + + it.effect("preserves failures while migrating a legacy userData path", () => { + const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; + const canonicalPath = "/Users/alice/Library/Application Support/t3code"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + description: "permission denied", pathOrDescriptor: legacyPath, }); @@ -168,15 +255,19 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); - assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); - assert.equal(error.legacyPath, legacyPath); + assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathMigrationError); + assert.equal(error.path, legacyPath); + assert.equal(error.targetPath, canonicalPath); assert.strictEqual(error.cause, cause); assert.equal( error.message, - `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, + `Failed to migrate legacy desktop user-data path from "${legacyPath}" to "${canonicalPath}".`, ); }), - { legacyPathProbeError: cause }, + { + existingUserDataPaths: [legacyPath], + renameError: cause, + }, ); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 385e694338d..c4525abaa5e 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,22 +18,38 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass()( - "DesktopUserDataPathResolutionError", +export class DesktopUserDataPathInspectionError extends Schema.TaggedErrorClass()( + "DesktopUserDataPathInspectionError", { - legacyPath: Schema.String, + path: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; + return `Failed to inspect desktop user-data path at "${this.path}".`; + } +} + +export class DesktopUserDataPathMigrationError extends Schema.TaggedErrorClass()( + "DesktopUserDataPathMigrationError", + { + path: Schema.String, + targetPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to migrate legacy desktop user-data path from "${this.path}" to "${this.targetPath}".`; } } export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, { - readonly resolveUserDataPath: Effect.Effect; + readonly resolveUserDataPath: Effect.Effect< + string, + DesktopUserDataPathInspectionError | DesktopUserDataPathMigrationError + >; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -91,22 +107,51 @@ export const make = Effect.gen(function* () { }); const resolveUserDataPath = Effect.gen(function* () { - const legacyPath = environment.path.join( + const canonicalPath = environment.path.join( environment.appDataDirectory, - environment.legacyUserDataDirName, + environment.userDataDirName, ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + const canonicalPathExists = yield* fileSystem.exists(canonicalPath).pipe( Effect.mapError( (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, + new DesktopUserDataPathInspectionError({ + path: canonicalPath, cause, }), ), ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + if (canonicalPathExists) { + return canonicalPath; + } + + for (const legacyUserDataDirName of environment.legacyUserDataDirNames) { + const legacyPath = environment.path.join(environment.appDataDirectory, legacyUserDataDirName); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathInspectionError({ + path: legacyPath, + cause, + }), + ), + ); + if (!legacyPathExists) { + continue; + } + + yield* fileSystem.rename(legacyPath, canonicalPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathMigrationError({ + path: legacyPath, + targetPath: canonicalPath, + cause, + }), + ), + ); + return canonicalPath; + } + return canonicalPath; }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); const configure = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 15d23f8e152..e9fbc649f21 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -98,6 +98,21 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("derives the historical userData candidate from the packaged release stage", () => + Effect.gen(function* () { + const alpha = yield* makeEnvironment({ isPackaged: true, appVersion: "0.0.29-alpha.1" }); + const nightly = yield* makeEnvironment({ + isPackaged: true, + appVersion: "0.0.29-nightly.20260723.864", + }); + + assert.deepEqual(alpha.legacyUserDataDirNames, ["T3 Code (Alpha)"]); + assert.deepEqual(nightly.legacyUserDataDirNames, ["T3 Code (Alpha)", "T3 Code (Nightly)"]); + assert.equal(alpha.userDataDirName, "t3code"); + assert.equal(nightly.userDataDirName, "t3code"); + }), + ); + it.effect("keeps implicit development state separate from production state", () => Effect.gen(function* () { const development = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index c991f5b39d6..b15252c1424 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -67,7 +67,7 @@ export class DesktopEnvironment extends Context.Service< readonly linuxDesktopEntryName: string; readonly linuxWmClass: string; readonly userDataDirName: string; - readonly legacyUserDataDirName: string; + readonly legacyUserDataDirNames: readonly string[]; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; @@ -161,7 +161,9 @@ const make = Effect.fn("desktop.environment.make")(function* ( isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata", ); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const legacyUserDataDirNames = isDevelopment + ? [displayName] + : [...new Set(["T3 Code (Alpha)", displayName])]; const resourcesPath = input.resourcesPath; return DesktopEnvironment.of({ @@ -206,7 +208,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", userDataDirName, - legacyUserDataDirName, + legacyUserDataDirNames, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index f8e05915718..b20acd2ccd5 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -80,6 +80,7 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo > { const shutdown = yield* DesktopShutdown.DesktopShutdown; const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.flushRendererState; yield* desktopWindow.flushMainWindowBounds; yield* shutdown.request; yield* shutdown.awaitComplete; diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..6f232116a10 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -77,6 +77,7 @@ function makePoolLayer( handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, + flushRendererState: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..6cd31ecac19 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { getRendererState, setRendererState } from "./methods/rendererState.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -55,6 +56,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); + yield* ipc.handle(getRendererState); + yield* ipc.handle(setRendererState); yield* ipc.handle(getConnectionCatalog); yield* ipc.handle(setConnectionCatalog); yield* ipc.handle(clearConnectionCatalog); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 95c725130e5..61d7910959c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -18,6 +18,10 @@ export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +export const GET_RENDERER_STATE_CHANNEL = "desktop:get-renderer-state"; +export const SET_RENDERER_STATE_CHANNEL = "desktop:set-renderer-state"; +export const REQUEST_RENDERER_STATE_FLUSH_CHANNEL = "desktop:request-renderer-state-flush"; +export const RENDERER_STATE_FLUSH_COMPLETE_CHANNEL = "desktop:renderer-state-flush-complete"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; diff --git a/apps/desktop/src/ipc/methods/rendererState.ts b/apps/desktop/src/ipc/methods/rendererState.ts new file mode 100644 index 00000000000..91a961e973e --- /dev/null +++ b/apps/desktop/src/ipc/methods/rendererState.ts @@ -0,0 +1,28 @@ +import { DesktopRendererStateKeySchema, DesktopRendererStateWriteSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopRendererState from "../../settings/DesktopRendererState.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const getRendererState = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_RENDERER_STATE_CHANNEL, + payload: DesktopRendererStateKeySchema, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.rendererState.get")(function* (key) { + const rendererState = yield* DesktopRendererState.DesktopRendererState; + return Option.getOrNull(yield* rendererState.get(key)); + }), +}); + +export const setRendererState = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_RENDERER_STATE_CHANNEL, + payload: DesktopRendererStateWriteSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.rendererState.set")(function* ({ key, value }) { + const rendererState = yield* DesktopRendererState.DesktopRendererState; + yield* rendererState.set(key, value); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..3474f35024c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -46,6 +46,7 @@ import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; +import * as DesktopRendererState from "./settings/DesktopRendererState.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; @@ -127,6 +128,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopShutdown.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, + DesktopRendererState.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, DesktopObservability.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 228114fd1d1..8990aa7e692 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -47,6 +47,29 @@ contextBridge.exposeInMainWorld("desktopBridge", { getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), + getRendererState: (key) => ipcRenderer.invoke(IpcChannels.GET_RENDERER_STATE_CHANNEL, key), + setRendererState: (key, value) => + ipcRenderer.invoke(IpcChannels.SET_RENDERER_STATE_CHANNEL, { key, value }), + onRendererStateFlush: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, requestId: unknown) => { + if (typeof requestId !== "string") return; + void Promise.resolve() + .then(listener) + .then( + () => { + ipcRenderer.send(IpcChannels.RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, requestId, true); + }, + () => { + ipcRenderer.send(IpcChannels.RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, requestId, false); + }, + ); + }; + + ipcRenderer.on(IpcChannels.REQUEST_RENDERER_STATE_FLUSH_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.REQUEST_RENDERER_STATE_FLUSH_CHANNEL, wrappedListener); + }; + }, getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44cf7..cbc8de121c0 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -3,20 +3,13 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; -import * as References from "effect/References"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; -interface LogRecord { - readonly message: unknown; - readonly annotations: Readonly>; -} - const baseDir = "/virtual-home"; function makeLayer(fileSystemLayer: Layer.Layer) { @@ -41,44 +34,29 @@ function makeLayer(fileSystemLayer: Layer.Layer) { ); } -const readWithLogs = (fileSystemLayer: Layer.Layer) => { - const records: Array = []; - const logger = Logger.make(({ fiber, message }) => { - records.push({ - message, - annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, - }); - }); - - return Effect.gen(function* () { +const readResult = (fileSystemLayer: Layer.Layer) => + Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, - records, }; - }).pipe( - Effect.provide( - Layer.mergeAll( - makeLayer(fileSystemLayer), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); -}; + }).pipe(Effect.provide(makeLayer(fileSystemLayer))); describe("DesktopClientSettings diagnostics", () => { - it.effect("treats a missing settings file as expected without warning", () => + it.effect("treats a missing settings file as expected", () => Effect.gen(function* () { - const result = yield* readWithLogs(FileSystem.layerNoop({})); + const result = yield* readResult(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); - assert.deepEqual(result.records, []); + assert.equal(result.result._tag, "Success"); + if (result.result._tag === "Success") { + assert.isTrue(Option.isNone(result.result.success)); + } }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("fails non-missing filesystem reads with the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -87,43 +65,47 @@ describe("DesktopClientSettings diagnostics", () => { }); return Effect.gen(function* () { - const result = yield* readWithLogs( + const result = yield* readResult( FileSystem.layerNoop({ readFileString: () => Effect.fail(permissionError), }), ); - assert.isTrue(Option.isNone(result.result)); - assert.equal(result.records.length, 1); - assert.deepEqual(result.records[0]?.message, [ - "Could not read desktop client settings.", - permissionError, - ]); - assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); + assert.equal(result.result._tag, "Failure"); + if (result.result._tag === "Failure") { + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "read-settings-file"); + assert.equal(result.result.failure.path, result.settingsPath); + assert.equal(result.result.failure.cause, permissionError); + } }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("fails malformed settings documents with the settings path", () => Effect.gen(function* () { - const result = yield* readWithLogs( + const result = yield* readResult( FileSystem.layerNoop({ readFileString: () => Effect.succeed("{not-json"), }), ); - assert.isTrue(Option.isNone(result.result)); - assert.equal(result.records.length, 1); - const message = result.records[0]?.message; - if (!Array.isArray(message)) { - return assert.fail("expected structured warning arguments"); - } - assert.equal(message[0], "Could not decode desktop client settings."); - const schemaError = message[1]; - if (schemaError === null || typeof schemaError !== "object") { - return assert.fail("expected the schema error in the warning"); + assert.equal(result.result._tag, "Failure"); + if (result.result._tag === "Failure") { + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "decode-document"); + assert.equal(result.result.failure.path, result.settingsPath); + const schemaError = result.result.failure.cause; + if (schemaError === null || typeof schemaError !== "object") { + return assert.fail("expected the schema error as the failure cause"); + } + assert.equal("_tag" in schemaError ? schemaError._tag : undefined, "SchemaError"); } - assert.equal("_tag" in schemaError ? schemaError._tag : undefined, "SchemaError"); - assert.equal(result.records[0]?.annotations.settingsPath, result.settingsPath); }), ); }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..cb862e1de50 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -195,7 +195,7 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => + it.effect("protects malformed client settings documents from replacement", () => withClientSettings( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -204,7 +204,11 @@ describe("DesktopClientSettings", () => { yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); - assert.isTrue(Option.isNone(yield* settings.get)); + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.equal(yield* fileSystem.readFileString(environment.clientSettingsPath), "{not-json"); }), ), ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27a2..94f64325af0 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -31,6 +31,11 @@ const decodeClientSettingsJson = (raw: string): Effect.Effect()( + "DesktopClientSettingsReadError", + { + operation: DesktopClientSettingsReadOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} + export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass()( "DesktopClientSettingsWriteError", { @@ -55,7 +73,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>; + readonly get: Effect.Effect, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect; @@ -65,16 +83,19 @@ export class DesktopClientSettings extends Context.Service< const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect> => +): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) - : Effect.logWarning("Could not read desktop client settings.", cause).pipe( - Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + : Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-settings-file", + path: settingsPath, + cause, + }), ), }), Effect.flatMap( @@ -83,13 +104,14 @@ const readClientSettings = ( onSome: (raw) => decodeClientSettingsJson(raw).pipe( Effect.map((settings) => Option.some(settings)), - Effect.catchTags({ - SchemaError: (cause) => - Effect.logWarning("Could not decode desktop client settings.", cause).pipe( - Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), - ), - }), + Effect.mapError( + (cause) => + new DesktopClientSettingsReadError({ + operation: "decode-document", + path: settingsPath, + cause, + }), + ), ), }), ), diff --git a/apps/desktop/src/settings/DesktopRendererState.test.ts b/apps/desktop/src/settings/DesktopRendererState.test.ts new file mode 100644 index 00000000000..f0f3bc709f9 --- /dev/null +++ b/apps/desktop/src/settings/DesktopRendererState.test.ts @@ -0,0 +1,110 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopRendererState from "./DesktopRendererState.ts"; + +function makeLayer(baseDir: string) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopRendererState.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withRendererState = ( + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-renderer-state-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopRendererState", () => { + it.effect("returns none when a renderer state file does not exist", () => + withRendererState( + Effect.gen(function* () { + const rendererState = yield* DesktopRendererState.DesktopRendererState; + + assert.isTrue(Option.isNone(yield* rendererState.get("ui-state"))); + assert.isTrue(Option.isNone(yield* rendererState.get("composer-preferences"))); + }), + ), + ); + + it.effect("atomically persists independent raw renderer state documents", () => + withRendererState( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const rendererState = yield* DesktopRendererState.DesktopRendererState; + const uiState = '{"projectOrder":["project-b","project-a"]}'; + const composerPreferences = + '{"version":1,"stickyModelSelectionByProvider":{},"stickyActiveProvider":"codex"}'; + + yield* rendererState.set("ui-state", uiState); + yield* rendererState.set("composer-preferences", composerPreferences); + + assert.deepEqual(yield* rendererState.get("ui-state"), Option.some(uiState)); + assert.deepEqual( + yield* rendererState.get("composer-preferences"), + Option.some(composerPreferences), + ); + assert.equal( + yield* fileSystem.readFileString( + environment.path.join(environment.stateDir, "renderer-state", "ui-state.json"), + ), + uiState, + ); + assert.equal( + yield* fileSystem.readFileString( + environment.path.join( + environment.stateDir, + "renderer-state", + "composer-preferences.json", + ), + ), + composerPreferences, + ); + }), + ), + ); + + it.effect("removes only the requested renderer state document", () => + withRendererState( + Effect.gen(function* () { + const rendererState = yield* DesktopRendererState.DesktopRendererState; + yield* rendererState.set("ui-state", '{"projectOrder":[]}'); + yield* rendererState.set("composer-preferences", '{"state":{},"version":8}'); + + yield* rendererState.set("ui-state", null); + + assert.isTrue(Option.isNone(yield* rendererState.get("ui-state"))); + assert.isTrue(Option.isSome(yield* rendererState.get("composer-preferences"))); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopRendererState.ts b/apps/desktop/src/settings/DesktopRendererState.ts new file mode 100644 index 00000000000..d975d13ae90 --- /dev/null +++ b/apps/desktop/src/settings/DesktopRendererState.ts @@ -0,0 +1,195 @@ +import type { DesktopRendererStateKey } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const rendererStateFileNames = { + "ui-state": "ui-state.json", + "composer-preferences": "composer-preferences.json", +} as const satisfies Record; + +const DesktopRendererStateWriteOperation = Schema.Literals([ + "create-temporary-file-name", + "create-directory", + "write-temporary-file", + "replace-state-file", + "remove-state-file", +]); + +export class DesktopRendererStateReadError extends Schema.TaggedErrorClass()( + "DesktopRendererStateReadError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read desktop renderer state at ${this.path}.`; + } +} + +export class DesktopRendererStateWriteError extends Schema.TaggedErrorClass()( + "DesktopRendererStateWriteError", + { + operation: DesktopRendererStateWriteOperation, + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop renderer state write failed during ${this.operation} at ${this.path}.`; + } +} + +export class DesktopRendererState extends Context.Service< + DesktopRendererState, + { + readonly get: ( + key: DesktopRendererStateKey, + ) => Effect.Effect, DesktopRendererStateReadError>; + readonly set: ( + key: DesktopRendererStateKey, + value: string | null, + ) => Effect.Effect; + } +>()("@t3tools/desktop/settings/DesktopRendererState") {} + +function statePath( + environment: DesktopEnvironment.DesktopEnvironment["Service"], + key: DesktopRendererStateKey, +): string { + return environment.path.join(environment.stateDir, "renderer-state", rendererStateFileNames[key]); +} + +const readState = ( + fileSystem: FileSystem.FileSystem, + path: string, +): Effect.Effect, DesktopRendererStateReadError> => + fileSystem.readFileString(path).pipe( + Effect.map(Option.some), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail(new DesktopRendererStateReadError({ path, cause })), + ), + ); + +const writeState = Effect.fn("desktop.rendererState.writeState")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly pathService: Path.Path; + readonly destinationPath: string; + readonly value: string; + readonly suffix: string; +}): Effect.fn.Return { + const directory = input.pathService.dirname(input.destinationPath); + const temporaryPath = `${input.destinationPath}.${process.pid}.${input.suffix}.tmp`; + + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopRendererStateWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* Effect.gen(function* () { + yield* input.fileSystem.writeFileString(temporaryPath, input.value).pipe( + Effect.mapError( + (cause) => + new DesktopRendererStateWriteError({ + operation: "write-temporary-file", + path: temporaryPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(temporaryPath, input.destinationPath).pipe( + Effect.mapError( + (cause) => + new DesktopRendererStateWriteError({ + operation: "replace-state-file", + path: input.destinationPath, + cause, + }), + ), + ); + }).pipe( + Effect.ensuring( + input.fileSystem.remove(temporaryPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove a temporary renderer state file.", { + temporaryPath, + error, + }), + ), + ), + ), + ); +}); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const writeLock = yield* Semaphore.make(1); + + return DesktopRendererState.of({ + get: (key) => + readState(fileSystem, statePath(environment, key)).pipe( + Effect.withSpan("desktop.rendererState.get", { attributes: { key } }), + ), + set: (key, value) => + writeLock + .withPermit( + Effect.gen(function* () { + const destinationPath = statePath(environment, key); + if (value === null) { + yield* fileSystem.remove(destinationPath, { force: true }).pipe( + Effect.mapError( + (cause) => + new DesktopRendererStateWriteError({ + operation: "remove-state-file", + path: destinationPath, + cause, + }), + ), + ); + return; + } + + const suffix = yield* crypto.randomUUIDv4.pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.mapError( + (cause) => + new DesktopRendererStateWriteError({ + operation: "create-temporary-file-name", + path: destinationPath, + cause, + }), + ), + ); + yield* writeState({ + fileSystem, + pathService, + destinationPath, + value, + suffix, + }); + }), + ) + .pipe(Effect.withSpan("desktop.rendererState.set", { attributes: { key } })), + }); +}); + +export const layer = Layer.effect(DesktopRendererState, make); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..ee91ed6da1e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -20,6 +20,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; interface UpdatesHarnessOptions { @@ -30,6 +31,7 @@ interface UpdatesHarnessOptions { readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; readonly stopBackend?: Effect.Effect; + readonly installEvents?: string[]; readonly env?: Record; } @@ -83,7 +85,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, + quitAndInstall: () => + Effect.sync(() => { + options.installEvents?.push("quit-and-install"); + }), on: (eventName, listener) => Effect.acquireRelease( Effect.sync(() => { @@ -108,15 +113,39 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { sentStates.push(state as DesktopUpdateState); }), - destroyAll: Effect.void, + destroyAll: Effect.sync(() => { + options.installEvents?.push("destroy-windows"); + }), syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]); + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.sync(() => { + options.installEvents?.push("flush-bounds"); + }), + flushRendererState: Effect.sync(() => { + options.installEvents?.push("flush-renderer"); + }), + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindow["Service"]); + const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { id: DesktopBackendPool.PRIMARY_INSTANCE_ID, label: Effect.succeed("Windows"), start: Effect.void, - stop: () => options.stopBackend ?? Effect.void, + stop: () => + Effect.sync(() => { + options.installEvents?.push("stop-backend"); + }).pipe(Effect.andThen(options.stopBackend ?? Effect.void)), currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ desiredRunning: false, @@ -173,6 +202,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), + Layer.provideMerge(desktopWindowLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), Layer.provideMerge(settingsLayer), @@ -509,6 +539,32 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("flushes renderer persistence before installing an update", () => { + const installEvents: string[] = []; + const harness = makeHarness({ installEvents }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + assert.deepEqual(installEvents, [ + "flush-renderer", + "flush-bounds", + "stop-backend", + "destroy-windows", + "quit-and-install", + ]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 7357907e178..7dd5ac198a4 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { @@ -250,6 +251,7 @@ export const make = Effect.gen(function* () { const desktopState = yield* DesktopState.DesktopState; const electronUpdater = yield* ElectronUpdater.ElectronUpdater; const electronWindow = yield* ElectronWindow.ElectronWindow; + const desktopWindow = yield* DesktopWindow.DesktopWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; @@ -455,16 +457,20 @@ export const make = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); if ( (yield* Ref.get(desktopState.quitting)) || + (yield* Ref.get(updateInstallInFlightRef)) || !(yield* Ref.get(updaterConfiguredRef)) || state.status !== "downloaded" ) { return { accepted: false, completed: false }; } - yield* Ref.set(desktopState.quitting, true); yield* Ref.set(updateInstallInFlightRef, true); return yield* Effect.gen(function* () { + yield* desktopWindow.flushRendererState; + yield* desktopWindow.flushMainWindowBounds; + yield* Ref.set(desktopState.quitting, true); + // Stop every backend in the pool, not just the primary. With // parallel WSL + Windows backends, leaving the WSL instance up // means quitAndInstall's app.quit() exits before the pool's diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..92f4cadce02 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -77,6 +77,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, + flushRendererState: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..31e5e07ff9d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -16,6 +16,26 @@ import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ ...(await importOriginal()), + ipcMain: (() => { + const listeners = new Map void>>(); + return { + emit: (eventName: string, ...args: readonly unknown[]) => { + for (const listener of listeners.get(eventName) ?? []) { + listener(...args); + } + }, + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName) ?? new Set(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }), + removeListener: vi.fn( + (eventName: string, listener: (...args: readonly unknown[]) => void) => { + listeners.get(eventName)?.delete(listener); + }, + ), + }; + })(), session: { fromPartition: vi.fn(() => ({ getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), @@ -41,7 +61,12 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, + REQUEST_RENDERER_STATE_FLUSH_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -64,6 +89,7 @@ function makeFakeBrowserWindow() { const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), + isDestroyed: vi.fn(() => false), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -105,6 +131,7 @@ function makeFakeBrowserWindow() { return { window: window as unknown as Electron.BrowserWindow, + close: window.close, getBounds: window.getBounds, getNormalBounds: window.getNormalBounds, isDestroyed: window.isDestroyed, @@ -117,6 +144,7 @@ function makeFakeBrowserWindow() { reload: webContents.reload, send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, + webContents, webContentsListeners, windowListeners, }; @@ -158,17 +186,20 @@ const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronTheme["Service"]); -const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_PORT: "3773", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", - }), +const makeDesktopEnvironmentLayer = (platform: NodeJS.Platform = environmentInput.platform) => + DesktopEnvironment.layer({ ...environmentInput, platform }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), ), - ), -); + ); + +const desktopEnvironmentLayer = makeDesktopEnvironmentLayer(); const desktopWindowBoundsEquivalence = Schema.toEquivalence( DesktopAppSettings.DesktopWindowBoundsSchema, @@ -182,6 +213,8 @@ function makeTestLayer(input: { readonly desktopSettings?: DesktopAppSettings.DesktopSettings; readonly mainWindowBoundsUpdates?: DesktopAppSettings.DesktopWindowBounds[]; readonly mainWindowMaximizedUpdates?: boolean[]; + readonly platform?: NodeJS.Platform; + readonly quitting?: boolean; readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; @@ -244,10 +277,15 @@ function makeTestLayer(input: { Layer.provide( Layer.mergeAll( desktopAssetsLayer, - desktopEnvironmentLayer, + input.platform === undefined + ? desktopEnvironmentLayer + : makeDesktopEnvironmentLayer(input.platform), desktopAppSettingsLayer, desktopServerExposureLayer, - DesktopState.layer, + Layer.succeed(DesktopState.DesktopState, { + backendReady: Ref.makeUnsafe(false), + quitting: Ref.makeUnsafe(input.quitting ?? false), + }), electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -345,6 +383,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopEnvironmentLayer, DesktopAppSettings.layerTest(), desktopServerExposureLayer, + DesktopState.layer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), @@ -561,7 +600,19 @@ describe("DesktopWindow", () => { if (!close) { return yield* Effect.die("window close listener was not registered"); } - close(); + close({ preventDefault: vi.fn() }); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(fakeWindow.send.mock.calls.length, 1); + }), + ); + const [, requestId] = fakeWindow.send.mock.calls[0] ?? []; + Electron.ipcMain.emit( + RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, + { sender: fakeWindow.webContents }, + requestId, + true, + ); yield* Effect.promise(() => Promise.resolve()); assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); @@ -667,7 +718,19 @@ describe("DesktopWindow", () => { return yield* Effect.die("window lifecycle listeners were not registered"); } - close(); + close({ preventDefault: vi.fn() }); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(fakeWindow.send.mock.calls.length, 1); + }), + ); + const [, requestId] = fakeWindow.send.mock.calls[0] ?? []; + Electron.ipcMain.emit( + RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, + { sender: fakeWindow.webContents }, + requestId, + true, + ); yield* Effect.promise(() => Promise.resolve()); assert.deepEqual(mainWindowBoundsUpdates, []); @@ -800,6 +863,84 @@ describe("DesktopWindow", () => { }), ); + it.effect("flushes renderer state before a macOS main window is destroyed", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + platform: "darwin", + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + const preventDefault = vi.fn(); + close({ preventDefault }); + + assert.equal(preventDefault.mock.calls.length, 1); + assert.equal(fakeWindow.close.mock.calls.length, 0); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(fakeWindow.send.mock.calls.length, 1); + }), + ); + + const [channel, requestId] = fakeWindow.send.mock.calls[0] ?? []; + assert.equal(channel, REQUEST_RENDERER_STATE_FLUSH_CHANNEL); + Electron.ipcMain.emit( + RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, + { sender: fakeWindow.webContents }, + requestId, + true, + ); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(fakeWindow.close.mock.calls.length, 1); + }), + ); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("does not intercept window destruction after shutdown has started", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + platform: "linux", + quitting: true, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const close = fakeWindow.windowListeners.get("close"); + if (!close) { + return yield* Effect.die("window close listener was not registered"); + } + const preventDefault = vi.fn(); + close({ preventDefault }); + + assert.equal(preventDefault.mock.calls.length, 0); + assert.equal(fakeWindow.send.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -830,8 +971,20 @@ describe("DesktopWindow", () => { if (!close) { return yield* Effect.die("window close listener was not registered"); } - close(); + close({ preventDefault: vi.fn() }); yield* Deferred.await(writeStarted); + yield* Effect.promise(() => + vi.waitFor(() => { + assert.equal(fakeWindow.send.mock.calls.length, 1); + }), + ); + const [, requestId] = fakeWindow.send.mock.calls[0] ?? []; + Electron.ipcMain.emit( + RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, + { sender: fakeWindow.webContents }, + requestId, + true, + ); fakeWindow.isDestroyed.mockReturnValue(true); const flushFiber = yield* desktopWindow.flushMainWindowBounds.pipe( diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..e878107c307 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -6,24 +6,33 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import * as NodeCrypto from "node:crypto"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, + REQUEST_RENDERER_STATE_FLUSH_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { requestRendererStateFlush } from "./RendererStateFlush.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; +const RENDERER_STATE_FLUSH_TIMEOUT_MS = 10_000; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED @@ -44,6 +53,7 @@ type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings + | DesktopState.DesktopState | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -79,6 +89,7 @@ export class DesktopWindow extends Context.Service< // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; + readonly flushRendererState: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -181,6 +192,48 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { ); } +function flushRendererStateForWindow(window: Electron.BrowserWindow): Effect.Effect { + return Effect.gen(function* () { + if (window.isDestroyed() || window.webContents.isDestroyed()) { + return; + } + const target = window.webContents; + const result = yield* Effect.promise((signal) => + requestRendererStateFlush({ + requestId: NodeCrypto.randomUUID(), + target, + signal, + send: (requestId) => { + target.send(REQUEST_RENDERER_STATE_FLUSH_CHANNEL, requestId); + }, + subscribe: (notify) => { + const listener = ( + event: Electron.IpcMainEvent, + requestId: unknown, + succeeded: unknown, + ) => { + notify({ sender: event.sender, requestId, succeeded }); + }; + Electron.ipcMain.on(RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, listener); + return () => { + Electron.ipcMain.removeListener(RENDERER_STATE_FLUSH_COMPLETE_CHANNEL, listener); + }; + }, + }), + ).pipe( + Effect.timeoutOrElse({ + duration: RENDERER_STATE_FLUSH_TIMEOUT_MS, + orElse: () => Effect.succeed("timed-out" as const), + }), + ); + if (result !== "flushed") { + yield* logWindowWarning("renderer state flush did not complete before shutdown", { + result, + }); + } + }); +} + function getWindowTitleBarOptions( shouldUseDarkColors: boolean, platform: NodeJS.Platform, @@ -246,6 +299,7 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const desktopState = yield* DesktopState.DesktopState; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -521,8 +575,29 @@ export const make = Effect.gen(function* () { window.on("move", scheduleBoundsPersist); window.on("maximize", scheduleBoundsPersist); window.on("unmaximize", scheduleBoundsPersist); - window.on("close", () => { - runFork(flushBoundsPersist); + let closeAllowed = false; + let closeFlushPending = false; + window.on("close", (event) => { + if (closeAllowed || Ref.getUnsafe(desktopState.quitting)) { + runFork(flushBoundsPersist); + return; + } + event.preventDefault(); + if (closeFlushPending) { + return; + } + closeFlushPending = true; + void runPromise( + Effect.all([flushRendererStateForWindow(window), flushBoundsPersist], { + concurrency: "unbounded", + discard: true, + }), + ).finally(() => { + closeAllowed = true; + if (!window.isDestroyed()) { + window.close(); + } + }); }); if (environment.platform === "darwin") { @@ -681,6 +756,14 @@ export const make = Effect.gen(function* () { yield* createMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); + const flushRendererState = Effect.gen(function* () { + const existingWindow = yield* currentMainWindow; + if (Option.isNone(existingWindow)) { + return; + } + yield* flushRendererStateForWindow(existingWindow.value); + }).pipe(Effect.withSpan("desktop.window.flushRendererState")); + const showConnectingSplash = Effect.gen(function* () { // Only when nothing is shown yet: no real window, no existing splash. const existingSplash = yield* Ref.get(splashWindowRef); @@ -765,6 +848,7 @@ export const make = Effect.gen(function* () { flushMainWindowBounds: Effect.suspend(() => flushMainWindowBounds).pipe( Effect.withSpan("desktop.window.flushMainWindowBounds"), ), + flushRendererState, dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { yield* Effect.annotateCurrentSpan({ action }); const existingWindow = yield* focusedMainWindow; diff --git a/apps/desktop/src/window/RendererStateFlush.test.ts b/apps/desktop/src/window/RendererStateFlush.test.ts new file mode 100644 index 00000000000..13112fd8556 --- /dev/null +++ b/apps/desktop/src/window/RendererStateFlush.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + requestRendererStateFlush, + type RendererStateFlushAcknowledgement, +} from "./RendererStateFlush.ts"; + +describe("requestRendererStateFlush", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("waits for the matching renderer acknowledgement", async () => { + const target = {}; + const send = vi.fn(); + const unsubscribe = vi.fn(); + let notify: ((acknowledgement: RendererStateFlushAcknowledgement) => void) | undefined; + const pending = requestRendererStateFlush({ + requestId: "request-1", + target, + send, + subscribe: (listener) => { + notify = listener; + return unsubscribe; + }, + }); + + expect(send).toHaveBeenCalledWith("request-1"); + notify?.({ sender: {}, requestId: "request-1", succeeded: true }); + notify?.({ sender: target, requestId: "other-request", succeeded: true }); + + let settled = false; + void pending.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + notify?.({ sender: target, requestId: "request-1", succeeded: true }); + + await expect(pending).resolves.toBe("flushed"); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("removes its listener when the Effect timeout aborts the request", async () => { + const unsubscribe = vi.fn(); + const controller = new AbortController(); + const pending = requestRendererStateFlush({ + requestId: "request-2", + target: {}, + signal: controller.signal, + send: vi.fn(), + subscribe: () => unsubscribe, + }); + + controller.abort(); + + await expect(pending).resolves.toBe("timed-out"); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/window/RendererStateFlush.ts b/apps/desktop/src/window/RendererStateFlush.ts new file mode 100644 index 00000000000..ca80748b6df --- /dev/null +++ b/apps/desktop/src/window/RendererStateFlush.ts @@ -0,0 +1,43 @@ +export type RendererStateFlushResult = "flushed" | "failed" | "timed-out"; + +export interface RendererStateFlushAcknowledgement { + readonly sender: unknown; + readonly requestId: unknown; + readonly succeeded: unknown; +} + +export function requestRendererStateFlush(input: { + readonly requestId: string; + readonly target: unknown; + readonly signal?: AbortSignal; + readonly send: (requestId: string) => void; + readonly subscribe: ( + listener: (acknowledgement: RendererStateFlushAcknowledgement) => void, + ) => () => void; +}): Promise { + return new Promise((resolve) => { + let settled = false; + let unsubscribe: () => void = () => undefined; + const handleAbort = () => finish("timed-out"); + const finish = (result: RendererStateFlushResult) => { + if (settled) return; + settled = true; + input.signal?.removeEventListener("abort", handleAbort); + unsubscribe(); + resolve(result); + }; + + try { + unsubscribe = input.subscribe(({ sender, requestId, succeeded }) => { + if (sender !== input.target || requestId !== input.requestId) { + return; + } + finish(succeeded === true ? "flushed" : "failed"); + }); + input.signal?.addEventListener("abort", handleAbort, { once: true }); + input.send(input.requestId); + } catch { + finish("failed"); + } + }); +} diff --git a/apps/web/src/clientPersistenceBootstrap.test.ts b/apps/web/src/clientPersistenceBootstrap.test.ts new file mode 100644 index 00000000000..7217e47c681 --- /dev/null +++ b/apps/web/src/clientPersistenceBootstrap.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + waitForClientPersistenceFlushes, + waitForClientPersistenceHydration, +} from "./clientPersistenceBootstrap"; + +describe("waitForClientPersistenceHydration", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("continues as soon as every hydration settles", async () => { + await expect( + waitForClientPersistenceHydration( + [Promise.resolve(), Promise.reject(new Error("read failed"))], + 100, + ), + ).resolves.toBe("hydrated"); + }); + + it("fails open when a desktop IPC hydration never settles", async () => { + vi.useFakeTimers(); + const hydration = waitForClientPersistenceHydration([new Promise(() => undefined)], 100); + + await vi.advanceTimersByTimeAsync(100); + + await expect(hydration).resolves.toBe("timed-out"); + }); + + it("waits for every renderer persistence flush before reporting a failure", async () => { + let finishSlowFlush!: () => void; + const slowFlush = new Promise((resolve) => { + finishSlowFlush = resolve; + }); + const flush = waitForClientPersistenceFlushes([ + Promise.reject(new Error("client settings failed")), + slowFlush, + ]); + let settled = false; + void flush.catch(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + finishSlowFlush(); + + await expect(flush).rejects.toThrow("One or more client persistence flushes failed."); + }); +}); diff --git a/apps/web/src/clientPersistenceBootstrap.ts b/apps/web/src/clientPersistenceBootstrap.ts new file mode 100644 index 00000000000..9c665a52757 --- /dev/null +++ b/apps/web/src/clientPersistenceBootstrap.ts @@ -0,0 +1,87 @@ +import { + continueComposerPreferencesHydrationInBackground, + flushComposerPreferencesPersistence, + hydrateComposerPreferences, +} from "./composerDraftStore"; +import { + continueClientSettingsHydrationInBackground, + flushClientSettingsPersistence, + hydrateClientSettings, +} from "./hooks/useSettings"; +import { + continueUiStatePersistenceHydrationInBackground, + flushUiStatePersistence, + hydrateUiStateStore, +} from "./uiStateStore"; + +export const CLIENT_PERSISTENCE_HYDRATION_TIMEOUT_MS = 3_000; +let disposeRendererStateFlushHandler: (() => void) | null = null; + +export async function waitForClientPersistenceFlushes( + flushes: ReadonlyArray>, +): Promise { + const results = await Promise.allSettled(flushes); + const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); + if (errors.length > 0) { + throw new AggregateError(errors, "One or more client persistence flushes failed."); + } +} + +export function flushClientRendererPersistence(): Promise { + return waitForClientPersistenceFlushes([ + flushClientSettingsPersistence(), + flushUiStatePersistence(), + flushComposerPreferencesPersistence(), + ]); +} + +export function installRendererStateFlushHandler(): void { + if ( + disposeRendererStateFlushHandler !== null || + typeof window === "undefined" || + !window.desktopBridge?.onRendererStateFlush + ) { + return; + } + disposeRendererStateFlushHandler = window.desktopBridge.onRendererStateFlush( + flushClientRendererPersistence, + ); +} + +export async function waitForClientPersistenceHydration( + hydrations: ReadonlyArray>, + timeoutMs = CLIENT_PERSISTENCE_HYDRATION_TIMEOUT_MS, +): Promise<"hydrated" | "timed-out"> { + let timeoutId: ReturnType | undefined; + const timeout = new Promise<"timed-out">((resolve) => { + timeoutId = setTimeout(() => resolve("timed-out"), timeoutMs); + }); + const hydrated = Promise.allSettled(hydrations).then(() => "hydrated" as const); + const result = await Promise.race([hydrated, timeout]); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + return result; +} + +export async function hydrateClientPersistence(): Promise { + installRendererStateFlushHandler(); + const result = await waitForClientPersistenceHydration([ + hydrateClientSettings(), + hydrateUiStateStore(), + hydrateComposerPreferences(), + ]); + if (result === "timed-out") { + continueClientSettingsHydrationInBackground(); + continueUiStatePersistenceHydrationInBackground(); + continueComposerPreferencesHydrationInBackground(); + console.error( + `[CLIENT_PERSISTENCE] Initial hydration exceeded ${CLIENT_PERSISTENCE_HYDRATION_TIMEOUT_MS}ms; durable renderer writes remain guarded while the in-flight hydration completes in the background.`, + ); + } +} + +export function __resetClientPersistenceBootstrapForTests(): void { + disposeRendererStateFlushHandler?.(); + disposeRendererStateFlushHandler = null; +} diff --git a/apps/web/src/clientPersistenceRaces.test.ts b/apps/web/src/clientPersistenceRaces.test.ts new file mode 100644 index 00000000000..438a416871d --- /dev/null +++ b/apps/web/src/clientPersistenceRaces.test.ts @@ -0,0 +1,668 @@ +import { + DEFAULT_CLIENT_SETTINGS, + ProviderInstanceId, + type ClientSettings, + type DesktopBridge, +} from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function createLocalStorageStub(): Storage { + const values = new Map(); + return { + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + removeItem: (key) => { + values.delete(key); + }, + setItem: (key, value) => { + values.set(key, value); + }, + }; +} + +function testWindow(): Window & typeof globalThis { + return globalThis.window ?? (globalThis as unknown as Window & typeof globalThis); +} + +function installDesktopPersistenceBridge(input: { + readonly getRendererState: DesktopBridge["getRendererState"]; + readonly setRendererState: DesktopBridge["setRendererState"]; +}): void { + testWindow().desktopBridge = input as DesktopBridge; +} + +function installClientSettingsBridge(input: { + readonly getClientSettings: DesktopBridge["getClientSettings"]; + readonly setClientSettings: DesktopBridge["setClientSettings"]; +}): void { + testWindow().desktopBridge = input as DesktopBridge; +} + +describe("desktop client persistence races", () => { + beforeEach(() => { + vi.resetModules(); + if (globalThis.window === undefined) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + } + const localStorage = createLocalStorageStub(); + Object.defineProperty(testWindow(), "localStorage", { + configurable: true, + value: localStorage, + }); + Object.defineProperty(testWindow(), "addEventListener", { + configurable: true, + value: vi.fn(), + }); + vi.stubGlobal("localStorage", localStorage); + }); + + afterEach(() => { + Reflect.deleteProperty(testWindow(), "desktopBridge"); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("keeps one UI hydration read in flight and reconciles repeated local mutations", async () => { + const initialRead = deferred(); + const setRendererState = vi.fn().mockResolvedValue(); + const getRendererState = vi.fn(() => initialRead.promise); + installDesktopPersistenceBridge({ + getRendererState, + setRendererState, + }); + const uiState = await import("./uiStateStore"); + uiState.useUiStateStore.setState({ + threadChangedFilesExpandedById: { + "thread-1": { + "turn-baseline": false, + }, + }, + }); + const hydration = uiState.hydrateUiStateStore(); + + uiState.continueUiStatePersistenceHydrationInBackground(); + uiState.useUiStateStore.setState({ + projectOrder: ["project-new"], + threadChangedFilesExpandedById: { + "thread-1": { + "turn-baseline": false, + "turn-local": false, + }, + }, + }); + uiState.continueUiStatePersistenceHydrationInBackground(); + + expect(getRendererState).toHaveBeenCalledOnce(); + initialRead.resolve( + JSON.stringify({ + projectOrder: ["project-old"], + threadChangedFilesExpandedById: { + "thread-1": { + "turn-baseline": false, + "turn-durable": false, + }, + }, + }), + ); + await hydration; + + expect(getRendererState).toHaveBeenCalledOnce(); + expect(uiState.useUiStateStore.getState().projectOrder).toEqual(["project-new"]); + expect(uiState.useUiStateStore.getState().threadChangedFilesExpandedById).toEqual({ + "thread-1": { + "turn-baseline": false, + "turn-durable": false, + "turn-local": false, + }, + }); + expect(setRendererState).toHaveBeenCalledOnce(); + expect(JSON.parse(setRendererState.mock.calls[0]?.[1] ?? "{}")).toMatchObject({ + projectOrder: ["project-new"], + threadChangedFilesExpandedById: { + "thread-1": { + "turn-baseline": false, + "turn-durable": false, + "turn-local": false, + }, + }, + }); + }); + + it("re-enables UI persistence after a transient hydration read failure", async () => { + const setRendererState = vi.fn().mockResolvedValue(undefined); + const getRendererState = vi + .fn() + .mockRejectedValueOnce(new Error("IPC read failed")) + .mockResolvedValue('{"projectOrder":["project-from-disk"]}'); + installDesktopPersistenceBridge({ + getRendererState, + setRendererState, + }); + const uiState = await import("./uiStateStore"); + + await uiState.hydrateUiStateStore(); + expect(getRendererState).toHaveBeenCalledTimes(2); + expect(uiState.useUiStateStore.getState().projectOrder).toEqual(["project-from-disk"]); + uiState.useUiStateStore.setState({ projectOrder: ["project-after-error"] }); + await uiState.flushUiStatePersistence(); + + expect(setRendererState).toHaveBeenCalledOnce(); + expect(JSON.parse(setRendererState.mock.calls[0]?.[1] ?? "{}")).toMatchObject({ + projectOrder: ["project-after-error"], + }); + }); + + it("keeps malformed durable UI state protected from later writes", async () => { + const setRendererState = vi.fn().mockResolvedValue(undefined); + installDesktopPersistenceBridge({ + getRendererState: vi.fn().mockResolvedValue('{"projectOrder":'), + setRendererState, + }); + const uiState = await import("./uiStateStore"); + + await uiState.hydrateUiStateStore(); + uiState.useUiStateStore.setState({ projectOrder: ["must-not-overwrite"] }); + await uiState.flushUiStatePersistence(); + + expect(setRendererState).not.toHaveBeenCalled(); + }); + + it("keeps one composer hydration read in flight and preserves a local provider change", async () => { + const initialRead = deferred(); + const setRendererState = vi.fn().mockResolvedValue(); + const getRendererState = vi.fn(() => initialRead.promise); + installDesktopPersistenceBridge({ + getRendererState, + setRendererState, + }); + const composer = await import("./composerDraftStore"); + const hydration = composer.hydrateComposerPreferences(); + + composer.continueComposerPreferencesHydrationInBackground(); + composer.useComposerDraftStore.setState({ + stickyActiveProvider: ProviderInstanceId.make("codex"), + }); + composer.continueComposerPreferencesHydrationInBackground(); + expect(getRendererState).toHaveBeenCalledOnce(); + initialRead.resolve( + '{"version":1,"stickyModelSelectionByProvider":{},"stickyActiveProvider":"claudeAgent"}', + ); + await hydration; + + expect(getRendererState).toHaveBeenCalledOnce(); + expect(setRendererState).toHaveBeenCalledOnce(); + expect(setRendererState.mock.calls[0]?.[0]).toBe("composer-preferences"); + expect(JSON.parse(setRendererState.mock.calls[0]?.[1] ?? "{}")).toMatchObject({ + stickyActiveProvider: "codex", + }); + expect(composer.useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); + }); + + it("merges a post-timeout client edit into every untouched durable sidebar preference", async () => { + const initialRead = deferred(); + const setClientSettings = vi.fn().mockResolvedValue(); + const getClientSettings = vi.fn(() => initialRead.promise); + installClientSettingsBridge({ getClientSettings, setClientSettings }); + const settings = await import("./hooks/useSettings"); + const hydration = settings.hydrateClientSettings(); + const savedSettings = { + ...DEFAULT_CLIENT_SETTINGS, + sidebarProjectSortOrder: "manual", + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: { + "/repo/special": "separate", + }, + sidebarThreadSortOrder: "created_at", + sidebarThreadFilters: { + groupByProject: true, + statuses: ["unread"], + }, + } as ClientSettings; + + settings.continueClientSettingsHydrationInBackground(); + settings.updateClientSettings({ timestampFormat: "24-hour" }); + expect(getClientSettings).toHaveBeenCalledOnce(); + initialRead.resolve(savedSettings); + await vi.waitFor(() => { + expect(setClientSettings).toHaveBeenCalledOnce(); + }); + + expect(setClientSettings).toHaveBeenCalledWith( + expect.objectContaining({ + sidebarProjectSortOrder: "manual", + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: { + "/repo/special": "separate", + }, + sidebarThreadSortOrder: "created_at", + sidebarThreadFilters: { + groupByProject: true, + statuses: ["unread"], + }, + timestampFormat: "24-hour", + }), + ); + + const immediateWrite = deferred(); + const shutdownWrite = deferred(); + setClientSettings + .mockImplementationOnce(() => immediateWrite.promise) + .mockImplementationOnce(() => shutdownWrite.promise); + settings.updateClientSettings({ wordWrap: false }); + const shutdownFlush = settings.flushClientSettingsPersistence(); + let shutdownFlushSettled = false; + void shutdownFlush.then(() => { + shutdownFlushSettled = true; + }); + await vi.waitFor(() => { + expect(setClientSettings).toHaveBeenCalledTimes(2); + }); + expect(shutdownFlushSettled).toBe(false); + + immediateWrite.resolve(); + await vi.waitFor(() => { + expect(setClientSettings).toHaveBeenCalledTimes(3); + }); + expect(setClientSettings).toHaveBeenLastCalledWith( + expect.objectContaining({ + sidebarProjectSortOrder: "manual", + sidebarThreadFilters: { + groupByProject: true, + statuses: ["unread"], + }, + timestampFormat: "24-hour", + wordWrap: false, + }), + ); + expect(shutdownFlushSettled).toBe(false); + + shutdownWrite.resolve(); + await shutdownFlush; + await hydration; + }); + + it("waits for slow client-settings hydration before flushing a guarded edit", async () => { + const read = deferred(); + const setClientSettings = vi.fn().mockResolvedValue(); + installClientSettingsBridge({ + getClientSettings: vi.fn(() => read.promise), + setClientSettings, + }); + const settings = await import("./hooks/useSettings"); + void settings.hydrateClientSettings(); + settings.updateClientSettings({ timestampFormat: "24-hour" }); + + const flush = settings.flushClientSettingsPersistence(); + let flushSettled = false; + void flush.then(() => { + flushSettled = true; + }); + await Promise.resolve(); + expect(flushSettled).toBe(false); + expect(setClientSettings).not.toHaveBeenCalled(); + + read.resolve(DEFAULT_CLIENT_SETTINGS); + await flush; + + expect(setClientSettings).toHaveBeenLastCalledWith( + expect.objectContaining({ timestampFormat: "24-hour" }), + ); + }); + + it("waits for slow UI-state hydration before flushing a guarded edit", async () => { + const read = deferred(); + const setRendererState = vi.fn().mockResolvedValue(); + installDesktopPersistenceBridge({ + getRendererState: vi.fn(() => read.promise), + setRendererState, + }); + const uiState = await import("./uiStateStore"); + void uiState.hydrateUiStateStore(); + uiState.useUiStateStore.setState({ projectOrder: ["project-local"] }); + + const flush = uiState.flushUiStatePersistence(); + let flushSettled = false; + void flush.then(() => { + flushSettled = true; + }); + await Promise.resolve(); + expect(flushSettled).toBe(false); + expect(setRendererState).not.toHaveBeenCalled(); + + read.resolve('{"projectOrder":["project-durable"]}'); + await flush; + + expect(JSON.parse(setRendererState.mock.calls.at(-1)?.[1] ?? "{}")).toMatchObject({ + projectOrder: ["project-local"], + }); + }); + + it("waits for slow composer hydration before flushing a guarded edit", async () => { + const read = deferred(); + const setRendererState = vi.fn().mockResolvedValue(); + installDesktopPersistenceBridge({ + getRendererState: vi.fn(() => read.promise), + setRendererState, + }); + const composer = await import("./composerDraftStore"); + void composer.hydrateComposerPreferences(); + composer.useComposerDraftStore.setState({ + stickyActiveProvider: ProviderInstanceId.make("codex"), + }); + + const flush = composer.flushComposerPreferencesPersistence(); + let flushSettled = false; + void flush.then(() => { + flushSettled = true; + }); + await Promise.resolve(); + expect(flushSettled).toBe(false); + expect(setRendererState).not.toHaveBeenCalled(); + + read.resolve( + '{"version":1,"stickyModelSelectionByProvider":{},"stickyActiveProvider":"claudeAgent"}', + ); + await flush; + + expect(JSON.parse(setRendererState.mock.calls.at(-1)?.[1] ?? "{}")).toMatchObject({ + stickyActiveProvider: "codex", + }); + }); + + it("lets durable composer preferences win over sticky fields in the draft store", async () => { + testWindow().localStorage.setItem( + "t3code:composer-drafts:v1", + JSON.stringify({ + version: 8, + state: { + stickyModelSelectionByProvider: { + codex: { instanceId: "codex", model: "gpt-legacy" }, + }, + stickyActiveProvider: "codex", + }, + }), + ); + const setRendererState = vi.fn().mockResolvedValue(); + installDesktopPersistenceBridge({ + getRendererState: vi.fn().mockResolvedValue( + JSON.stringify({ + version: 1, + stickyModelSelectionByProvider: { + claudeAgent: { + instanceId: "claudeAgent", + model: "claude-durable", + }, + }, + stickyActiveProvider: "claudeAgent", + }), + ), + setRendererState, + }); + + const composer = await import("./composerDraftStore"); + await composer.hydrateComposerPreferences(); + + expect(composer.useComposerDraftStore.getState()).toMatchObject({ + stickyModelSelectionByProvider: { + claudeAgent: { + instanceId: "claudeAgent", + model: "claude-durable", + }, + }, + stickyActiveProvider: "claudeAgent", + }); + expect(setRendererState).not.toHaveBeenCalled(); + }); + + it("migrates pre-v3 sticky composer preferences out of the legacy draft document", async () => { + testWindow().localStorage.setItem( + "t3code:composer-drafts:v1", + JSON.stringify({ + version: 2, + state: { + stickyProvider: "codex", + stickyModelSelection: { + provider: "codex", + model: "gpt-legacy", + }, + stickyModelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }, + }), + ); + const setRendererState = vi.fn().mockResolvedValue(); + installDesktopPersistenceBridge({ + getRendererState: vi.fn().mockResolvedValue(null), + setRendererState, + }); + + const composer = await import("./composerDraftStore"); + await composer.hydrateComposerPreferences(); + + expect(composer.useComposerDraftStore.getState()).toMatchObject({ + stickyModelSelectionByProvider: { + codex: { + instanceId: "codex", + model: "gpt-legacy", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }, + }, + stickyActiveProvider: "codex", + }); + expect(setRendererState).toHaveBeenCalledOnce(); + expect(setRendererState.mock.calls[0]?.[0]).toBe("composer-preferences"); + expect(JSON.parse(setRendererState.mock.calls[0]?.[1] ?? "{}")).toEqual({ + version: 1, + stickyModelSelectionByProvider: { + codex: { + instanceId: "codex", + model: "gpt-legacy", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }, + }, + stickyActiveProvider: "codex", + }); + expect(testWindow().localStorage.getItem("t3code:composer-drafts:v1")).not.toBeNull(); + }); + + it("keeps the active provider when its model changes during hydration", async () => { + const read = deferred(); + const setRendererState = vi.fn().mockResolvedValue(); + installDesktopPersistenceBridge({ + getRendererState: vi.fn(() => read.promise), + setRendererState, + }); + const composer = await import("./composerDraftStore"); + composer.useComposerDraftStore.setState({ + stickyModelSelectionByProvider: { + [ProviderInstanceId.make("codex")]: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-baseline", + }, + }, + stickyActiveProvider: ProviderInstanceId.make("codex"), + }); + const hydration = composer.hydrateComposerPreferences(); + composer.useComposerDraftStore.setState({ + stickyModelSelectionByProvider: { + [ProviderInstanceId.make("codex")]: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-local", + }, + }, + }); + + read.resolve( + JSON.stringify({ + version: 1, + stickyModelSelectionByProvider: { + claudeAgent: { instanceId: "claudeAgent", model: "claude-durable" }, + codex: { instanceId: "codex", model: "gpt-durable" }, + }, + stickyActiveProvider: "claudeAgent", + }), + ); + await hydration; + + expect(composer.useComposerDraftStore.getState()).toMatchObject({ + stickyModelSelectionByProvider: { + claudeAgent: { instanceId: "claudeAgent", model: "claude-durable" }, + codex: { instanceId: "codex", model: "gpt-local" }, + }, + stickyActiveProvider: "codex", + }); + }); + + it("retries a transient reconciled UI-state write failure", async () => { + vi.useFakeTimers(); + const setRendererState = vi + .fn() + .mockRejectedValueOnce(new Error("write failed")) + .mockResolvedValue(undefined); + installDesktopPersistenceBridge({ + getRendererState: vi.fn().mockResolvedValue('{"projectOrder":["project-durable"]}'), + setRendererState, + }); + const uiState = await import("./uiStateStore"); + const hydration = uiState.hydrateUiStateStore(); + uiState.useUiStateStore.setState({ projectOrder: ["project-local"] }); + await hydration; + + expect(setRendererState).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(500); + await vi.waitFor(() => { + expect(setRendererState).toHaveBeenCalledTimes(2); + }); + expect(JSON.parse(setRendererState.mock.calls[1]?.[1] ?? "{}")).toMatchObject({ + projectOrder: ["project-local"], + }); + }); + + it("keeps legacy UI state until a durable retry is confirmed", async () => { + vi.useFakeTimers(); + const legacyKey = "t3code:renderer-state:v8"; + const legacyState = '{"projectOrder":["project-legacy"]}'; + testWindow().localStorage.setItem(legacyKey, legacyState); + const setRendererState = vi + .fn() + .mockRejectedValueOnce(new Error("migration write failed")) + .mockRejectedValueOnce(new Error("retry write failed")) + .mockResolvedValue(undefined); + installDesktopPersistenceBridge({ + getRendererState: vi.fn().mockResolvedValue(null), + setRendererState, + }); + const uiState = await import("./uiStateStore"); + + await uiState.hydrateUiStateStore(); + expect(testWindow().localStorage.getItem(legacyKey)).toBe(legacyState); + + await vi.advanceTimersByTimeAsync(500); + expect(setRendererState).toHaveBeenCalledTimes(2); + expect(testWindow().localStorage.getItem(legacyKey)).toBe(legacyState); + + uiState.useUiStateStore.setState({ projectOrder: ["project-after-retry"] }); + await vi.advanceTimersByTimeAsync(500); + expect(setRendererState).toHaveBeenCalledTimes(3); + expect(testWindow().localStorage.getItem(legacyKey)).toBeNull(); + }); + + it("retries failed debounced composer preference writes", async () => { + vi.useFakeTimers(); + const setRendererState = vi + .fn() + .mockRejectedValueOnce(new Error("write failed")) + .mockResolvedValue(undefined); + installDesktopPersistenceBridge({ + getRendererState: vi.fn().mockResolvedValue(null), + setRendererState, + }); + const composer = await import("./composerDraftStore"); + await composer.hydrateComposerPreferences(); + composer.useComposerDraftStore.setState({ + stickyActiveProvider: ProviderInstanceId.make("codex"), + }); + + await vi.advanceTimersByTimeAsync(300); + expect(setRendererState).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(300); + await vi.waitFor(() => { + expect(setRendererState).toHaveBeenCalledTimes(2); + }); + }); + + it("retries the current composer snapshot during flush after an identical write fails", async () => { + vi.useFakeTimers(); + const firstWrite = deferred(); + const setRendererState = vi + .fn() + .mockImplementationOnce(() => firstWrite.promise) + .mockResolvedValue(undefined); + installDesktopPersistenceBridge({ + getRendererState: vi.fn().mockResolvedValue(null), + setRendererState, + }); + const composer = await import("./composerDraftStore"); + await composer.hydrateComposerPreferences(); + composer.useComposerDraftStore.setState({ + stickyActiveProvider: ProviderInstanceId.make("codex"), + }); + await vi.advanceTimersByTimeAsync(300); + expect(setRendererState).toHaveBeenCalledOnce(); + + const flush = composer.flushComposerPreferencesPersistence(); + firstWrite.reject(new Error("in-flight write failed")); + await flush; + + expect(setRendererState).toHaveBeenCalledTimes(2); + expect(JSON.parse(setRendererState.mock.calls[1]?.[1] ?? "{}")).toMatchObject({ + stickyActiveProvider: "codex", + }); + }); + + it("falls back to initial UI state when browser localStorage reads throw", async () => { + Reflect.deleteProperty(testWindow(), "desktopBridge"); + const blockedStorage = { + ...createLocalStorageStub(), + getItem: () => { + throw new DOMException("blocked", "SecurityError"); + }, + } as Storage; + Object.defineProperty(testWindow(), "localStorage", { + configurable: true, + value: blockedStorage, + }); + vi.stubGlobal("localStorage", blockedStorage); + + const uiState = await import("./uiStateStore"); + + expect(uiState.useUiStateStore.getState().projectOrder).toEqual([]); + }); +}); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 5c0ba7c6ecc..513db9a3ac5 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -28,3 +28,11 @@ export function writeBrowserClientSettings(settings: ClientSettings): void { setLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, settings, ClientSettingsSchema); } + +export function removeBrowserClientSettings(): void { + if (!hasWindow()) { + return; + } + + window.localStorage.removeItem(CLIENT_SETTINGS_STORAGE_KEY); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..8bdbec340b1 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -14,7 +14,9 @@ import { ThreadId, type ModelSelection, type ProviderOptionSelection, + type ServerProvider, } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; // The composer draft's `modelSelectionByProvider` and @@ -60,11 +62,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, + deriveEffectiveComposerModelState, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, markPromotedDraftThreads, markPromotedDraftThreadsByRef, + parsePersistedComposerPreferences, type ComposerImageAttachment, useComposerDraftStore, DraftId, @@ -142,6 +146,31 @@ function modelSelection( return createModelSelection(defaultInstanceIdForDriver(provider), model, toSelections(options)); } +function providerWithModels( + provider: ProviderDriverKind, + models: ReadonlyArray<{ slug: string; isDefault?: boolean }>, +): ServerProvider { + return { + instanceId: defaultInstanceIdForDriver(provider), + driver: provider, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: models.map(({ slug, isDefault }) => ({ + slug, + name: slug, + isCustom: false, + ...(isDefault === undefined ? {} : { isDefault }), + capabilities: {}, + })), + slashCommands: [], + skills: [], + }; +} + function providerModelOptions( options: Partial>>, ): ProviderOptionSelectionsByProvider { @@ -1533,6 +1562,89 @@ describe("composerDraftStore sticky composer settings", () => { expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); }); + it("decodes the narrow durable composer preference document", () => { + const selection = modelSelection(CODEX_DRIVER, "gpt-5.6-sol", { + reasoningEffort: "high", + }); + + expect( + parsePersistedComposerPreferences( + JSON.stringify({ + version: 1, + stickyModelSelectionByProvider: { + codex: selection, + }, + stickyActiveProvider: "codex", + }), + ), + ).toEqual({ + version: 1, + stickyModelSelectionByProvider: { + codex: selection, + }, + stickyActiveProvider: "codex", + }); + expect(parsePersistedComposerPreferences("{not-json")).toBeNull(); + expect( + parsePersistedComposerPreferences( + JSON.stringify({ + version: 1, + stickyModelSelectionByProvider: { + codex: { instanceId: "codex", model: "" }, + }, + stickyActiveProvider: "codex", + }), + ), + ).toBeNull(); + }); + + it("falls back to authoritative project and server defaults when renderer preferences are empty", () => { + const providers = [ + providerWithModels(CODEX_DRIVER, [ + { slug: "gpt-server-default", isDefault: true }, + { slug: "gpt-thread-default" }, + { slug: "gpt-project-default" }, + ]), + ]; + const emptyRendererPreferences = { + modelSelectionByProvider: {}, + activeProvider: null, + }; + + expect( + deriveEffectiveComposerModelState({ + draft: emptyRendererPreferences, + providers, + selectedProvider: CODEX_DRIVER, + threadModelSelection: modelSelection(CODEX_DRIVER, "gpt-thread-default"), + projectModelSelection: modelSelection(CODEX_DRIVER, "gpt-project-default"), + settings: DEFAULT_UNIFIED_SETTINGS, + }).selectedModel, + ).toBe("gpt-thread-default"); + + expect( + deriveEffectiveComposerModelState({ + draft: emptyRendererPreferences, + providers, + selectedProvider: CODEX_DRIVER, + threadModelSelection: null, + projectModelSelection: modelSelection(CODEX_DRIVER, "gpt-project-default"), + settings: DEFAULT_UNIFIED_SETTINGS, + }).selectedModel, + ).toBe("gpt-project-default"); + + expect( + deriveEffectiveComposerModelState({ + draft: emptyRendererPreferences, + providers, + selectedProvider: CODEX_DRIVER, + threadModelSelection: null, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }).selectedModel, + ).toBe("gpt-server-default"); + }); + it("normalizes empty sticky model options by dropping selection options", () => { const store = useComposerDraftStore.getState(); @@ -1755,6 +1867,18 @@ describe("createDebouncedStorage", () => { expect(base.setItem).not.toHaveBeenCalled(); }); + it("cancel drops a pending write without removing persisted state", () => { + const base = createMockStorage(); + const storage = createDebouncedStorage(base); + + storage.setItem("key", "v1"); + storage.cancel(); + vi.advanceTimersByTime(300); + + expect(base.setItem).not.toHaveBeenCalled(); + expect(base.removeItem).not.toHaveBeenCalled(); + }); + it("setItem works normally after removeItem cancels a pending write", () => { const base = createMockStorage(); const storage = createDebouncedStorage(base); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..ee94380138b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -48,7 +48,19 @@ import { import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; +import { Debouncer } from "@tanstack/react-pacer"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; +import { + createAppRendererStateStorage, + readRendererStateWithRetries, +} from "./rendererStateStorage"; +import { + COMPOSER_DRAFT_STORAGE_KEY, + COMPOSER_PREFERENCES_STORAGE_KEY, + parsePersistedComposerPreferences, + readLegacyComposerPreferences, + type PersistedComposerPreferences, +} from "./composerPreferencesStorage"; import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext"; @@ -56,7 +68,7 @@ const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); -export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; +export { COMPOSER_DRAFT_STORAGE_KEY, COMPOSER_PREFERENCES_STORAGE_KEY }; const COMPOSER_DRAFT_STORAGE_VERSION = 8; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -70,11 +82,29 @@ const composerDebouncedStorage = createDebouncedStorage( typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), COMPOSER_PERSIST_DEBOUNCE_MS, ); +const composerPreferencesStorage = createAppRendererStateStorage("composer-preferences"); +const legacyComposerPreferencesSnapshot = readLegacyComposerPreferences( + typeof localStorage === "undefined" ? undefined : localStorage, +); +let composerPreferencesHydrated = false; +let composerPreferencesHydrationPromise: Promise | null = null; +let composerPreferencesHydrationGeneration = 0; +let composerPreferencesHydrationBaseline: PersistedComposerPreferences | null = null; +let composerPreferencesDocumentInvalid = false; +let applyingComposerPreferencesHydration = false; +let lastPersistedComposerPreferences: string | null = null; +let queuedComposerPreferences: string | null = null; +let composerPreferencesWriteQueue: Promise = Promise.resolve(); +let lastEnqueuedComposerPreferences: string | null = null; +let lastEnqueuedComposerPreferencesWrite: Promise | null = null; // Flush pending composer draft writes before page unload to prevent data loss. if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); + void flushComposerPreferencesPersistence().catch((error) => { + console.error("[RENDERER_STATE] Composer preference shutdown flush failed.", error); + }); }); } @@ -1903,10 +1933,6 @@ function partializeComposerDraftStoreState( draftThreadsByThreadKey: state.draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey: state.logicalProjectDraftThreadKeyByLogicalProjectKey, - stickyModelSelectionByProvider: compactModelSelectionByProvider( - state.stickyModelSelectionByProvider, - ), - stickyActiveProvider: state.stickyActiveProvider, }; } @@ -3364,8 +3390,6 @@ const composerDraftStore = create()( draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey: normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, - stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, - stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; }, }, @@ -3374,6 +3398,303 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +function serializeComposerPreferences( + state: Pick, +): string { + return JSON.stringify({ + version: 1, + stickyModelSelectionByProvider: compactModelSelectionByProvider( + state.stickyModelSelectionByProvider, + ), + stickyActiveProvider: state.stickyActiveProvider, + } satisfies PersistedComposerPreferences); +} + +export { parsePersistedComposerPreferences }; + +function hasComposerPreferences(preferences: PersistedComposerPreferences): boolean { + return ( + preferences.stickyActiveProvider !== null || + Object.keys(preferences.stickyModelSelectionByProvider).length > 0 + ); +} + +function snapshotComposerPreferences( + state: Pick, +): PersistedComposerPreferences { + return { + version: 1, + stickyModelSelectionByProvider: compactModelSelectionByProvider( + state.stickyModelSelectionByProvider, + ), + stickyActiveProvider: state.stickyActiveProvider, + }; +} + +function reconcileComposerPreferenceModels( + persisted: PersistedComposerPreferences["stickyModelSelectionByProvider"], + current: PersistedComposerPreferences["stickyModelSelectionByProvider"], + baseline: PersistedComposerPreferences["stickyModelSelectionByProvider"], +): PersistedComposerPreferences["stickyModelSelectionByProvider"] { + const reconciled: Record = {}; + const providers = new Set([ + ...Object.keys(persisted), + ...Object.keys(current), + ...Object.keys(baseline), + ]); + for (const provider of providers) { + const instanceId = ProviderInstanceId.make(provider); + const currentHasProvider = Object.hasOwn(current, instanceId); + const baselineHasProvider = Object.hasOwn(baseline, instanceId); + const changedLocally = + currentHasProvider !== baselineHasProvider || + (currentHasProvider && + JSON.stringify(current[instanceId]) !== JSON.stringify(baseline[instanceId])); + if (changedLocally) { + if (currentHasProvider) { + reconciled[instanceId] = current[instanceId] as ModelSelection; + } + continue; + } + if (Object.hasOwn(persisted, instanceId)) { + reconciled[instanceId] = persisted[instanceId] as ModelSelection; + } + } + return reconciled; +} + +function reconcileHydratedComposerPreferences( + persisted: PersistedComposerPreferences, + current: PersistedComposerPreferences, + baseline: PersistedComposerPreferences, +): PersistedComposerPreferences { + const baselineActiveProvider = baseline.stickyActiveProvider; + const activeProviderModelChanged = + baselineActiveProvider !== null && + JSON.stringify(current.stickyModelSelectionByProvider[baselineActiveProvider]) !== + JSON.stringify(baseline.stickyModelSelectionByProvider[baselineActiveProvider]); + return { + version: 1, + stickyModelSelectionByProvider: reconcileComposerPreferenceModels( + persisted.stickyModelSelectionByProvider, + current.stickyModelSelectionByProvider, + baseline.stickyModelSelectionByProvider, + ), + stickyActiveProvider: + current.stickyActiveProvider === baseline.stickyActiveProvider && !activeProviderModelChanged + ? persisted.stickyActiveProvider + : current.stickyActiveProvider, + }; +} + +function logComposerPreferencesPersistenceError(error: unknown): void { + console.error("[RENDERER_STATE] Composer preference persistence failed.", error); +} + +function enqueueComposerPreferencesPersistence( + serialized: string, + options?: { readonly force?: boolean }, +): Promise { + if ( + options?.force !== true && + serialized === lastEnqueuedComposerPreferences && + lastEnqueuedComposerPreferencesWrite !== null + ) { + return lastEnqueuedComposerPreferencesWrite; + } + + const write = composerPreferencesWriteQueue + .catch(() => undefined) + .then(() => + composerPreferencesStorage.writeHydratedValue(COMPOSER_PREFERENCES_STORAGE_KEY, serialized), + ) + .then(() => { + lastPersistedComposerPreferences = serialized; + }); + const trackedWrite = write.finally(() => { + if (lastEnqueuedComposerPreferencesWrite === trackedWrite) { + lastEnqueuedComposerPreferences = null; + lastEnqueuedComposerPreferencesWrite = null; + } + }); + composerPreferencesWriteQueue = trackedWrite; + lastEnqueuedComposerPreferences = serialized; + lastEnqueuedComposerPreferencesWrite = trackedWrite; + return trackedWrite; +} + +function scheduleComposerPreferencesPersistence(serialized: string): void { + if ( + serialized === lastPersistedComposerPreferences || + serialized === queuedComposerPreferences || + (serialized === lastEnqueuedComposerPreferences && + lastEnqueuedComposerPreferencesWrite !== null) + ) { + return; + } + queuedComposerPreferences = serialized; + debouncedPersistComposerPreferences.maybeExecute(serialized); +} + +const debouncedPersistComposerPreferences = new Debouncer( + (serialized: string) => { + if (queuedComposerPreferences === serialized) { + queuedComposerPreferences = null; + } + void enqueueComposerPreferencesPersistence(serialized).catch((error) => { + logComposerPreferencesPersistenceError(error); + if ( + composerPreferencesHydrated && + serializeComposerPreferences(useComposerDraftStore.getState()) === serialized + ) { + scheduleComposerPreferencesPersistence(serialized); + } + }); + }, + { wait: COMPOSER_PERSIST_DEBOUNCE_MS }, +); + +export function continueComposerPreferencesHydrationInBackground(): void { + if ( + composerPreferencesHydrated || + composerPreferencesDocumentInvalid || + !composerPreferencesStorage.requiresExplicitHydration || + composerPreferencesHydrationPromise !== null + ) { + return; + } + void hydrateComposerPreferences(); +} + +export async function hydrateComposerPreferences(): Promise { + if (composerPreferencesHydrated || composerPreferencesDocumentInvalid) { + return; + } + if (composerPreferencesHydrationPromise) { + return composerPreferencesHydrationPromise; + } + + const hydrationGeneration = composerPreferencesHydrationGeneration; + const isCurrentHydration = () => hydrationGeneration === composerPreferencesHydrationGeneration; + composerPreferencesHydrationBaseline ??= snapshotComposerPreferences( + useComposerDraftStore.getState(), + ); + const hydrationPromise = (async () => { + try { + const raw = await readRendererStateWithRetries(() => + composerPreferencesStorage.storage.getItem(COMPOSER_PREFERENCES_STORAGE_KEY), + ); + if (!isCurrentHydration()) { + return; + } + let persistedPreferences: PersistedComposerPreferences; + let shouldSeedPreferences = false; + if (raw !== null) { + const preferences = parsePersistedComposerPreferences(raw); + if (!preferences) { + composerPreferencesDocumentInvalid = true; + console.error( + "[RENDERER_STATE] Desktop composer preferences document is not valid JSON state; preserving it without enabling writes.", + ); + return; + } + persistedPreferences = preferences; + } else { + persistedPreferences = + legacyComposerPreferencesSnapshot ?? + snapshotComposerPreferences(useComposerDraftStore.getState()); + shouldSeedPreferences = hasComposerPreferences(persistedPreferences); + } + + const baseline = + composerPreferencesHydrationBaseline ?? + snapshotComposerPreferences(useComposerDraftStore.getState()); + const current = snapshotComposerPreferences(useComposerDraftStore.getState()); + const hadLocalChanges = JSON.stringify(current) !== JSON.stringify(baseline); + const reconciledPreferences = reconcileHydratedComposerPreferences( + persistedPreferences, + current, + baseline, + ); + applyingComposerPreferencesHydration = true; + try { + useComposerDraftStore.setState({ + stickyModelSelectionByProvider: reconciledPreferences.stickyModelSelectionByProvider, + stickyActiveProvider: reconciledPreferences.stickyActiveProvider, + }); + } finally { + applyingComposerPreferencesHydration = false; + } + if (!isCurrentHydration()) { + return; + } + const serializedPreferences = serializeComposerPreferences(reconciledPreferences); + composerPreferencesStorage.enableWrites(); + composerPreferencesHydrated = true; + composerPreferencesHydrationBaseline = null; + if (shouldSeedPreferences || hadLocalChanges) { + try { + await enqueueComposerPreferencesPersistence(serializedPreferences); + } catch (error) { + console.error( + "[RENDERER_STATE] Reconciled composer preference persistence failed.", + error, + ); + scheduleComposerPreferencesPersistence(serializedPreferences); + } + } else { + lastPersistedComposerPreferences = serializedPreferences; + } + } catch (error) { + if (isCurrentHydration()) { + console.error( + "[RENDERER_STATE] Composer preference hydration failed after bounded retries; writes remain guarded until a later read succeeds.", + error, + ); + } + } + })().finally(() => { + if (composerPreferencesHydrationPromise === hydrationPromise) { + composerPreferencesHydrationPromise = null; + } + }); + composerPreferencesHydrationPromise = hydrationPromise; + return hydrationPromise; +} + +export async function flushComposerPreferencesPersistence(): Promise { + debouncedPersistComposerPreferences.cancel(); + queuedComposerPreferences = null; + if (!composerPreferencesHydrated && !composerPreferencesDocumentInvalid) { + await hydrateComposerPreferences(); + } + if (!composerPreferencesHydrated || !composerPreferencesStorage.writesEnabled()) { + return; + } + await composerPreferencesWriteQueue.catch(() => undefined); + debouncedPersistComposerPreferences.cancel(); + queuedComposerPreferences = null; + const serialized = serializeComposerPreferences(useComposerDraftStore.getState()); + await enqueueComposerPreferencesPersistence(serialized, { force: true }); +} + +useComposerDraftStore.subscribe((state) => { + if (applyingComposerPreferencesHydration) { + return; + } + if (!composerPreferencesHydrated) { + if (!composerPreferencesDocumentInvalid) { + continueComposerPreferencesHydrationInBackground(); + } + return; + } + const serialized = serializeComposerPreferences(state); + if (serialized === lastPersistedComposerPreferences) { + return; + } + scheduleComposerPreferencesPersistence(serialized); +}); + export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { useComposerDraftStore.setState((state) => { const removedThreadKeys = new Set(); diff --git a/apps/web/src/composerPreferencesStorage.test.ts b/apps/web/src/composerPreferencesStorage.test.ts new file mode 100644 index 00000000000..bdda37661d0 --- /dev/null +++ b/apps/web/src/composerPreferencesStorage.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + COMPOSER_DRAFT_STORAGE_KEY, + readLegacyComposerPreferences, +} from "./composerPreferencesStorage"; + +function storageWithLegacyState(state: Record) { + const raw = JSON.stringify({ version: 2, state }); + return { + getItem: (key: string) => (key === COMPOSER_DRAFT_STORAGE_KEY ? raw : null), + }; +} + +describe("readLegacyComposerPreferences", () => { + it("falls back past invalid selection identifiers", () => { + expect( + readLegacyComposerPreferences( + storageWithLegacyState({ + stickyProvider: "cursor", + stickyModelSelection: { + instanceId: "invalid instance", + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + }), + ), + ).toMatchObject({ + stickyModelSelectionByProvider: { + claudeAgent: { + instanceId: "claudeAgent", + model: "claude-opus-4-6", + }, + }, + stickyActiveProvider: "cursor", + }); + + expect( + readLegacyComposerPreferences( + storageWithLegacyState({ + stickyProvider: "cursor", + stickyModelSelection: { + instanceId: "invalid instance", + provider: "invalid provider", + model: "gpt-5.4", + }, + }), + ), + ).toMatchObject({ + stickyModelSelectionByProvider: { + cursor: { + instanceId: "cursor", + model: "gpt-5.4", + }, + }, + stickyActiveProvider: "cursor", + }); + }); + + it("does not reinterpret invalid modern fields as pre-v3 preferences", () => { + expect( + readLegacyComposerPreferences( + storageWithLegacyState({ + stickyModelSelectionByProvider: { + codex: { + instanceId: "codex", + model: "", + }, + }, + stickyActiveProvider: "codex", + stickyProvider: "codex", + stickyModel: "gpt-5.6-sol", + }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/composerPreferencesStorage.ts b/apps/web/src/composerPreferencesStorage.ts new file mode 100644 index 00000000000..a70400c4d7e --- /dev/null +++ b/apps/web/src/composerPreferencesStorage.ts @@ -0,0 +1,210 @@ +import { + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + defaultInstanceIdForDriver, + ModelSelection, + ProviderDriverKind, + ProviderInstanceId, + type ProviderOptionSelection, +} from "@t3tools/contracts"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import * as Schema from "effect/Schema"; + +export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; +export const COMPOSER_PREFERENCES_STORAGE_KEY = "t3code:composer-preferences:v1"; + +export const PersistedComposerPreferencesSchema = Schema.Struct({ + version: Schema.Literal(1), + stickyModelSelectionByProvider: Schema.Record(ProviderInstanceId, ModelSelection), + stickyActiveProvider: Schema.NullOr(ProviderInstanceId), +}); +export type PersistedComposerPreferences = typeof PersistedComposerPreferencesSchema.Type; + +const isPersistedComposerPreferences = Schema.is(PersistedComposerPreferencesSchema); +const isProviderDriverKind = Schema.is(ProviderDriverKind); +const PROVIDER_INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; +const LEGACY_PROVIDER_KINDS = ["codex", "claudeAgent", "cursor", "opencode"] as const; +type LegacyProviderKind = (typeof LEGACY_PROVIDER_KINDS)[number]; + +function normalizeProviderInstanceId(value: unknown): ProviderInstanceId | null { + return typeof value === "string" && PROVIDER_INSTANCE_ID_PATTERN.test(value) + ? ProviderInstanceId.make(value) + : null; +} + +function normalizeProviderDriverKind(value: unknown): ProviderDriverKind | null { + return isProviderDriverKind(value) ? value : null; +} + +function coerceProviderOptionSelections( + value: unknown, +): ReadonlyArray | undefined { + if (Array.isArray(value)) { + const selections = value.flatMap((entry) => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + return []; + } + const { id, value: optionValue } = entry as Record; + return typeof id === "string" && + id.length > 0 && + (typeof optionValue === "string" || typeof optionValue === "boolean") + ? [{ id, value: optionValue }] + : []; + }); + return selections.length > 0 ? selections : undefined; + } + if (typeof value !== "object" || value === null) { + return undefined; + } + const selections = Object.entries(value).flatMap(([id, optionValue]) => + typeof optionValue === "string" || typeof optionValue === "boolean" + ? [{ id, value: optionValue }] + : [], + ); + return selections.length > 0 ? selections : undefined; +} + +function migratePreV3ComposerPreferences( + legacyState: Record, +): PersistedComposerPreferences | null { + if ( + legacyState.stickyModelSelection === undefined && + legacyState.stickyModelOptions === undefined && + legacyState.stickyProvider === undefined && + legacyState.stickyModel === undefined + ) { + return null; + } + + const legacyOptions = + typeof legacyState.stickyModelOptions === "object" && + legacyState.stickyModelOptions !== null && + !Array.isArray(legacyState.stickyModelOptions) + ? (legacyState.stickyModelOptions as Record) + : {}; + const optionsByProvider = Object.fromEntries( + LEGACY_PROVIDER_KINDS.flatMap((provider) => { + const options = coerceProviderOptionSelections(legacyOptions[provider]); + return options ? [[provider, options]] : []; + }), + ) as Partial>>; + + const selectionCandidate = + typeof legacyState.stickyModelSelection === "object" && + legacyState.stickyModelSelection !== null && + !Array.isArray(legacyState.stickyModelSelection) + ? (legacyState.stickyModelSelection as Record) + : {}; + const selectionInstanceId = + normalizeProviderInstanceId(selectionCandidate.instanceId) ?? + normalizeProviderInstanceId(selectionCandidate.provider) ?? + normalizeProviderInstanceId(legacyState.stickyProvider) ?? + ProviderInstanceId.make("codex"); + const driverHint = + normalizeProviderDriverKind(selectionCandidate.provider) ?? + normalizeProviderDriverKind(legacyState.stickyProvider) ?? + normalizeProviderDriverKind(selectionInstanceId) ?? + ProviderDriverKind.make("codex"); + const rawModel = selectionCandidate.model ?? legacyState.stickyModel; + const model = typeof rawModel === "string" ? normalizeModelSlug(rawModel, driverHint) : undefined; + const selectionProviderKind = + LEGACY_PROVIDER_KINDS.find((provider) => provider === selectionInstanceId) ?? null; + const selectionOptions = + coerceProviderOptionSelections(selectionCandidate.options) ?? + (selectionProviderKind === null ? undefined : optionsByProvider[selectionProviderKind]); + const selectedModel = + selectionInstanceId !== null && model + ? createModelSelection(selectionInstanceId, model, selectionOptions) + : null; + + const stickyModelSelectionByProvider = Object.fromEntries( + LEGACY_PROVIDER_KINDS.flatMap((provider) => { + const options = optionsByProvider[provider]; + if (!options) { + return []; + } + const driver = ProviderDriverKind.make(provider); + const instanceId = defaultInstanceIdForDriver(driver); + return [ + [ + instanceId, + selectedModel?.instanceId === instanceId + ? selectedModel + : createModelSelection( + instanceId, + DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL, + options, + ), + ], + ]; + }), + ) as Record; + if (selectedModel !== null) { + stickyModelSelectionByProvider[selectedModel.instanceId] = selectedModel; + } + + const preferences = { + version: 1, + stickyModelSelectionByProvider, + stickyActiveProvider: normalizeProviderInstanceId(legacyState.stickyProvider), + }; + return isPersistedComposerPreferences(preferences) ? preferences : null; +} + +export function parsePersistedComposerPreferences( + raw: string, +): PersistedComposerPreferences | null { + try { + const parsed: unknown = JSON.parse(raw); + return isPersistedComposerPreferences(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function readLegacyComposerPreferences( + storage: { readonly getItem: (key: string) => string | null } | undefined, +): PersistedComposerPreferences | null { + if (storage === undefined) { + return null; + } + + let raw: string | null; + try { + raw = storage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + } catch { + return null; + } + if (raw === null) { + return null; + } + + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + const state = (parsed as { readonly state?: unknown }).state; + if (typeof state !== "object" || state === null || Array.isArray(state)) { + return null; + } + const legacyState = state as Record; + if ( + legacyState.stickyModelSelectionByProvider !== undefined || + legacyState.stickyActiveProvider !== undefined + ) { + const preferences = { + version: 1, + stickyModelSelectionByProvider: legacyState.stickyModelSelectionByProvider ?? {}, + stickyActiveProvider: legacyState.stickyActiveProvider ?? null, + }; + if (isPersistedComposerPreferences(preferences)) { + return preferences; + } + return null; + } + return migratePreV3ComposerPreferences(legacyState); + } catch { + return null; + } +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 514484d896a..b555bb1e751 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -25,6 +25,7 @@ import { } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; +import { readRendererStateWithRetries } from "~/rendererStateStorage"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; @@ -36,8 +37,11 @@ const clientSettingsListeners = new Set<() => void>(); const clientSettingsHydrationListeners = new Set<() => void>(); let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; let clientSettingsHydrated = false; +let clientSettingsPersistenceReady = false; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; +let clientSettingsHydrationBaseline: ClientSettings | null = null; +let clientSettingsWriteQueue: Promise = Promise.resolve(); function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -88,8 +92,81 @@ function subscribeClientSettingsHydration(listener: () => void): () => void { }; } -async function hydrateClientSettings(): Promise { - if (clientSettingsHydrated) { +function persistedSettingsValuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function isPlainSettingsObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function reconcilePersistedSettingsValue( + persisted: unknown, + current: unknown, + baseline: unknown, +): unknown { + if (persistedSettingsValuesEqual(current, baseline)) { + return persisted; + } + if ( + isPlainSettingsObject(persisted) && + isPlainSettingsObject(current) && + isPlainSettingsObject(baseline) + ) { + const reconciled: Record = {}; + const keys = new Set([ + ...Object.keys(persisted), + ...Object.keys(current), + ...Object.keys(baseline), + ]); + for (const key of keys) { + const currentHasKey = Object.hasOwn(current, key); + const baselineHasKey = Object.hasOwn(baseline, key); + if (currentHasKey !== baselineHasKey) { + if (currentHasKey) { + reconciled[key] = current[key]; + } + continue; + } + if (!currentHasKey) { + if (Object.hasOwn(persisted, key)) { + reconciled[key] = persisted[key]; + } + continue; + } + reconciled[key] = reconcilePersistedSettingsValue( + persisted[key], + current[key], + baseline[key], + ); + } + return reconciled; + } + return current; +} + +function reconcileHydratedClientSettings( + persisted: ClientSettings, + current: ClientSettings, + baseline: ClientSettings, +): ClientSettings { + return reconcilePersistedSettingsValue(persisted, current, baseline) as ClientSettings; +} + +export function continueClientSettingsHydrationInBackground(): void { + if (clientSettingsPersistenceReady) { + return; + } + clientSettingsHydrationBaseline ??= clientSettingsSnapshot; + setClientSettingsHydrated(true); + if (clientSettingsHydrationPromise !== null) { + return; + } + void hydrateClientSettings(); +} + +export async function hydrateClientSettings(): Promise { + if (clientSettingsPersistenceReady) { return; } if (clientSettingsHydrationPromise) { @@ -97,14 +174,32 @@ async function hydrateClientSettings(): Promise { } const hydrationGeneration = clientSettingsHydrationGeneration; + clientSettingsHydrationBaseline ??= clientSettingsSnapshot; const nextHydration = (async () => { try { - const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); + const persistedSettings = await readRendererStateWithRetries(() => + ensureLocalApi().persistence.getClientSettings(), + ); if (hydrationGeneration !== clientSettingsHydrationGeneration) { return; } - if (persistedSettings) { - replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); + const baseline = clientSettingsHydrationBaseline ?? DEFAULT_CLIENT_SETTINGS; + const current = clientSettingsSnapshot; + const hadLocalChanges = !persistedSettingsValuesEqual(current, baseline); + const reconciledSettings = reconcileHydratedClientSettings( + persistedSettings + ? { ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings } + : DEFAULT_CLIENT_SETTINGS, + current, + baseline, + ); + replaceClientSettingsSnapshot(reconciledSettings); + clientSettingsPersistenceReady = true; + clientSettingsHydrationBaseline = null; + if (hadLocalChanges) { + await enqueueClientSettingsPersistence(reconciledSettings).catch( + logClientSettingsPersistenceError, + ); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { @@ -128,16 +223,38 @@ async function hydrateClientSettings(): Promise { return clientSettingsHydrationPromise; } +function enqueueClientSettingsPersistence(settings: ClientSettings): Promise { + const write = clientSettingsWriteQueue + .catch(() => undefined) + .then(() => ensureLocalApi().persistence.setClientSettings(settings)); + clientSettingsWriteQueue = write; + return write; +} + +function logClientSettingsPersistenceError(error: unknown): void { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); +} + function persistClientSettings(settings: ClientSettings): void { replaceClientSettingsSnapshot(settings); - void ensureLocalApi() - .persistence.setClientSettings(settings) - .catch((error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { - operation: "persist", - ...safeErrorLogAttributes(error), - }); - }); + if (!clientSettingsPersistenceReady) { + continueClientSettingsHydrationInBackground(); + return; + } + void enqueueClientSettingsPersistence(settings).catch(logClientSettingsPersistenceError); +} + +export async function flushClientSettingsPersistence(): Promise { + if (!clientSettingsPersistenceReady) { + await hydrateClientSettings(); + } + if (!clientSettingsPersistenceReady) { + return; + } + await enqueueClientSettingsPersistence(clientSettingsSnapshot); } // ── Key sets for routing patches ───────────────────────────────────── @@ -280,19 +397,24 @@ export function useUpdatePrimarySettings() { } export function useUpdateClientSettings() { - return useCallback((patch: ClientSettingsPatch) => { - persistClientSettings({ - ...getClientSettingsSnapshot(), - ...patch, - }); - }, []); + return useCallback(updateClientSettings, []); +} + +export function updateClientSettings(patch: ClientSettingsPatch): void { + persistClientSettings({ + ...getClientSettingsSnapshot(), + ...patch, + }); } export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; clientSettingsHydrated = false; + clientSettingsPersistenceReady = false; clientSettingsHydrationPromise = null; + clientSettingsHydrationBaseline = null; + clientSettingsWriteQueue = Promise.resolve(); clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } @@ -301,5 +423,8 @@ export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = settings; clientSettingsHydrated = true; + clientSettingsPersistenceReady = true; clientSettingsHydrationPromise = null; + clientSettingsHydrationBaseline = null; + clientSettingsWriteQueue = Promise.resolve(); } diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index a37c67064aa..2a9e3f8861f 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -7,6 +7,7 @@ export interface StateStorage { } export interface DebouncedStorage extends StateStorage { + cancel: () => void; flush: () => void; } @@ -60,6 +61,9 @@ export function createDebouncedStorage( debouncedSetItem.cancel(); resolvedStorage.removeItem(name); }, + cancel: () => { + debouncedSetItem.cancel(); + }, flush: () => { debouncedSetItem.flush(); }, diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 3379f5ed989..97b19a2c294 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -121,6 +121,166 @@ describe("LocalApi", () => { await expect(api.persistence.getClientSettings()).resolves.toEqual(settings); }); + it("migrates valid browser client settings when the desktop file is missing", async () => { + const settings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + }; + const getClientSettings = vi.fn().mockResolvedValue(null); + const setClientSettings = vi.fn().mockResolvedValue(undefined); + testWindow().localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + testWindow().desktopBridge = { + getClientSettings, + setClientSettings, + } as unknown as DesktopBridge; + + const { createLocalApi } = await import("./localApi"); + + await expect(createLocalApi().persistence.getClientSettings()).resolves.toEqual(settings); + expect(setClientSettings).toHaveBeenCalledWith(settings); + expect(testWindow().localStorage.getItem("t3code:client-settings:v1")).toBeNull(); + }); + + it("does not migrate malformed browser client settings into the desktop file", async () => { + const getClientSettings = vi.fn().mockResolvedValue(null); + const setClientSettings = vi.fn().mockResolvedValue(undefined); + testWindow().localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ timestampFormat: "sometimes" }), + ); + testWindow().desktopBridge = { + getClientSettings, + setClientSettings, + } as unknown as DesktopBridge; + + const { createLocalApi } = await import("./localApi"); + + await expect(createLocalApi().persistence.getClientSettings()).resolves.toBeNull(); + expect(setClientSettings).not.toHaveBeenCalled(); + }); + + it("migrates valid renderer state from localStorage when its desktop file is missing", async () => { + const getRendererState = vi.fn().mockResolvedValue(null); + const setRendererState = vi.fn().mockResolvedValue(undefined); + const rawState = '{"projectOrder":["project-b","project-a"]}'; + testWindow().localStorage.setItem("t3code:ui-state:v1", rawState); + testWindow().desktopBridge = { + getRendererState, + setRendererState, + } as unknown as DesktopBridge; + + const { createLocalApi } = await import("./localApi"); + + await expect(createLocalApi().persistence.getRendererState("ui-state")).resolves.toBe(rawState); + expect(setRendererState).toHaveBeenCalledWith("ui-state", rawState); + expect(testWindow().localStorage.getItem("t3code:ui-state:v1")).toBeNull(); + }); + + it("migrates legacy sticky preferences without deleting persisted composer drafts", async () => { + const getRendererState = vi.fn().mockResolvedValue(null); + const setRendererState = vi.fn().mockResolvedValue(undefined); + const legacyDrafts = JSON.stringify({ + version: 8, + state: { + draftsByThreadKey: { + "environment:thread": { + prompt: "keep this draft", + attachments: [], + }, + }, + stickyModelSelectionByProvider: { + codex: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }, + stickyActiveProvider: "codex", + }, + }); + testWindow().localStorage.setItem("t3code:composer-drafts:v1", legacyDrafts); + testWindow().desktopBridge = { + getRendererState, + setRendererState, + } as unknown as DesktopBridge; + + const { createLocalApi } = await import("./localApi"); + const migrated = await createLocalApi().persistence.getRendererState("composer-preferences"); + + expect(migrated === null ? null : JSON.parse(migrated)).toEqual({ + version: 1, + stickyModelSelectionByProvider: { + codex: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }, + stickyActiveProvider: "codex", + }); + expect(setRendererState).toHaveBeenCalledWith("composer-preferences", migrated); + expect(testWindow().localStorage.getItem("t3code:composer-drafts:v1")).toBe(legacyDrafts); + }); + + it("persists browser-only legacy composer conversion to the dedicated preference key", async () => { + const legacyDrafts = JSON.stringify({ + version: 8, + state: { + stickyModelSelectionByProvider: { + codex: { + instanceId: "codex", + model: "gpt-5.6-sol", + }, + }, + stickyActiveProvider: "codex", + }, + }); + testWindow().localStorage.setItem("t3code:composer-drafts:v1", legacyDrafts); + + const { createLocalApi } = await import("./localApi"); + const migrated = await createLocalApi().persistence.getRendererState("composer-preferences"); + + expect(migrated).not.toBeNull(); + expect(testWindow().localStorage.getItem("t3code:composer-preferences:v1")).toBe(migrated); + expect(testWindow().localStorage.getItem("t3code:composer-drafts:v1")).toBe(legacyDrafts); + }); + + it("rejects malformed dedicated composer preference objects", async () => { + const getRendererState = vi.fn().mockResolvedValue(null); + const setRendererState = vi.fn().mockResolvedValue(undefined); + const malformed = JSON.stringify({ + version: 1, + stickyModelSelectionByProvider: { + codex: { instanceId: "codex", model: "" }, + }, + stickyActiveProvider: "codex", + }); + testWindow().localStorage.setItem("t3code:composer-preferences:v1", malformed); + testWindow().desktopBridge = { + getRendererState, + setRendererState, + } as unknown as DesktopBridge; + + const { createLocalApi } = await import("./localApi"); + + await expect( + createLocalApi().persistence.getRendererState("composer-preferences"), + ).resolves.toBeNull(); + expect(setRendererState).not.toHaveBeenCalled(); + expect(testWindow().localStorage.getItem("t3code:composer-preferences:v1")).toBe(malformed); + }); + + it("keeps renderer state in localStorage when no desktop bridge is present", async () => { + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(); + const rawState = + '{"version":1,"stickyModelSelectionByProvider":{},"stickyActiveProvider":"codex"}'; + + await api.persistence.setRendererState("composer-preferences", rawState); + + await expect(api.persistence.getRendererState("composer-preferences")).resolves.toBe(rawState); + await api.persistence.setRendererState("composer-preferences", null); + await expect(api.persistence.getRendererState("composer-preferences")).resolves.toBeNull(); + }); + it("prefers the native LocalApi when one is injected", async () => { const nativeApi = { dialogs: {} }; testWindow().nativeApi = nativeApi as never; diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2fbf183f91b..6a0cb833dfd 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,8 +1,66 @@ -import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; +import type { ContextMenuItem, DesktopRendererStateKey, LocalApi } from "@t3tools/contracts"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; import { showContextMenuFallback } from "./contextMenuFallback"; -import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; +import { + readBrowserClientSettings, + removeBrowserClientSettings, + writeBrowserClientSettings, +} from "./clientPersistenceStorage"; +import { + COMPOSER_PREFERENCES_STORAGE_KEY, + parsePersistedComposerPreferences, + readLegacyComposerPreferences, +} from "./composerPreferencesStorage"; + +const rendererStateStorageKeys = { + "ui-state": "t3code:ui-state:v1", + "composer-preferences": COMPOSER_PREFERENCES_STORAGE_KEY, +} as const satisfies Record; + +interface BrowserRendererStateCandidate { + readonly raw: string; + readonly cleanupKey: string | null; +} + +function readValidBrowserRendererState( + key: DesktopRendererStateKey, +): BrowserRendererStateCandidate | null { + let raw: string | null; + try { + raw = window.localStorage.getItem(rendererStateStorageKeys[key]); + } catch { + return null; + } + if (raw === null) { + if (key !== "composer-preferences") { + return null; + } + const legacyPreferences = readLegacyComposerPreferences(window.localStorage); + return legacyPreferences === null + ? null + : { + raw: JSON.stringify(legacyPreferences), + cleanupKey: null, + }; + } + + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + if (key === "composer-preferences" && parsePersistedComposerPreferences(raw) === null) { + return null; + } + return { + raw, + cleanupKey: rendererStateStorageKeys[key], + }; + } catch { + return null; + } +} let cachedApi: LocalApi | undefined; @@ -52,7 +110,18 @@ function createBrowserLocalApi(): LocalApi { persistence: { getClientSettings: async () => { if (window.desktopBridge) { - return window.desktopBridge.getClientSettings(); + const persistedSettings = await window.desktopBridge.getClientSettings(); + if (persistedSettings) { + return persistedSettings; + } + + const legacySettings = readBrowserClientSettings(); + if (!legacySettings) { + return null; + } + await window.desktopBridge.setClientSettings(legacySettings); + removeBrowserClientSettings(); + return legacySettings; } return readBrowserClientSettings(); }, @@ -62,6 +131,51 @@ function createBrowserLocalApi(): LocalApi { } writeBrowserClientSettings(settings); }, + getRendererState: async (key) => { + if (window.desktopBridge) { + const persistedState = await window.desktopBridge.getRendererState(key); + if (persistedState !== null) { + return persistedState; + } + + const browserState = readValidBrowserRendererState(key); + if (browserState === null) { + return null; + } + await window.desktopBridge.setRendererState(key, browserState.raw); + if (browserState.cleanupKey !== null) { + try { + window.localStorage.removeItem(browserState.cleanupKey); + } catch { + // The durable copy succeeded; blocked browser cleanup is harmless. + } + } + return browserState.raw; + } + const browserState = readValidBrowserRendererState(key); + if (browserState === null) { + return null; + } + if (browserState.cleanupKey === null) { + try { + window.localStorage.setItem(rendererStateStorageKeys[key], browserState.raw); + } catch { + // Returning the converted state still keeps the current session usable. + } + } + return browserState.raw; + }, + setRendererState: async (key, value) => { + if (window.desktopBridge) { + return window.desktopBridge.setRendererState(key, value); + } + const storageKey = rendererStateStorageKeys[key]; + if (value === null) { + window.localStorage.removeItem(storageKey); + return; + } + window.localStorage.setItem(storageKey, value); + }, }, server: { getConfig: () => Promise.reject(unavailableLocalBackendError()), diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c03cc65f654..fa39896cce1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -20,6 +20,7 @@ import { syncDocumentWindowControlsOverlayClass, } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; +import { hydrateClientPersistence } from "./clientPersistenceBootstrap"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -33,22 +34,27 @@ if (isElectron) { const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -const app = ; - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - {clerkPublishableKey && hasCloudPublicConfig() ? ( - isElectron ? ( - - {app} - +async function renderApp(): Promise { + await hydrateClientPersistence(); + const app = ; + + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + {clerkPublishableKey && hasCloudPublicConfig() ? ( + isElectron ? ( + + {app} + + ) : ( + + {app} + + ) ) : ( - - {app} - - ) - ) : ( - app - )} - , -); + app + )} + , + ); +} + +void renderApp(); diff --git a/apps/web/src/rendererStateStorage.test.ts b/apps/web/src/rendererStateStorage.test.ts new file mode 100644 index 00000000000..14f58befa3b --- /dev/null +++ b/apps/web/src/rendererStateStorage.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createHydrationGuardedRendererStateStorage } from "./rendererStateStorage"; + +function createStorage() { + const values = new Map(); + return { + values, + storage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + }, + }; +} + +describe("createHydrationGuardedRendererStateStorage", () => { + it("keeps browser localStorage synchronous and writable", () => { + const browser = createStorage(); + const stateStorage = createHydrationGuardedRendererStateStorage({ + key: "ui-state", + browserStorage: browser.storage, + }); + + stateStorage.storage.setItem("ignored-by-browser", '{"projectOrder":[]}'); + + expect(stateStorage.requiresExplicitHydration).toBe(false); + expect(stateStorage.writesEnabled()).toBe(true); + expect(browser.values.get("ignored-by-browser")).toBe('{"projectOrder":[]}'); + }); + + it("blocks desktop writes until hydration completes", async () => { + const getRendererState = vi.fn().mockResolvedValue('{"projectOrder":["project-a"]}'); + const setRendererState = vi.fn().mockResolvedValue(undefined); + const stateStorage = createHydrationGuardedRendererStateStorage({ + key: "ui-state", + browserStorage: createStorage().storage, + desktopPersistence: { + getRendererState, + setRendererState, + }, + }); + + await expect(stateStorage.storage.getItem("t3code:ui-state:v1")).resolves.toBe( + '{"projectOrder":["project-a"]}', + ); + await stateStorage.storage.setItem("t3code:ui-state:v1", '{"projectOrder":[]}'); + expect(setRendererState).not.toHaveBeenCalled(); + + stateStorage.enableWrites(); + await stateStorage.storage.setItem("t3code:ui-state:v1", '{"projectOrder":["project-b"]}'); + await stateStorage.storage.removeItem("t3code:ui-state:v1"); + + expect(setRendererState).toHaveBeenNthCalledWith( + 1, + "ui-state", + '{"projectOrder":["project-b"]}', + ); + expect(setRendererState).toHaveBeenNthCalledWith(2, "ui-state", null); + }); + + it("keeps desktop writes blocked after a hydration read failure", async () => { + const getRendererState = vi.fn().mockRejectedValue(new Error("read failed")); + const setRendererState = vi.fn().mockResolvedValue(undefined); + const stateStorage = createHydrationGuardedRendererStateStorage({ + key: "ui-state", + browserStorage: createStorage().storage, + desktopPersistence: { + getRendererState, + setRendererState, + }, + }); + + await expect(stateStorage.storage.getItem("t3code:ui-state:v1")).rejects.toThrow("read failed"); + await stateStorage.storage.setItem("t3code:ui-state:v1", '{"projectOrder":[]}'); + + expect(stateStorage.writesEnabled()).toBe(false); + expect(setRendererState).not.toHaveBeenCalled(); + }); + + it("exposes enabled desktop write failures to persistence callers", async () => { + const writeError = new Error("write failed"); + const stateStorage = createHydrationGuardedRendererStateStorage({ + key: "ui-state", + browserStorage: createStorage().storage, + desktopPersistence: { + getRendererState: vi.fn().mockResolvedValue(null), + setRendererState: vi.fn().mockRejectedValue(writeError), + }, + }); + stateStorage.enableWrites(); + + await expect( + stateStorage.storage.setItem("t3code:ui-state:v1", '{"projectOrder":[]}'), + ).rejects.toBe(writeError); + await expect(stateStorage.storage.removeItem("t3code:ui-state:v1")).rejects.toBe(writeError); + }); +}); diff --git a/apps/web/src/rendererStateStorage.ts b/apps/web/src/rendererStateStorage.ts new file mode 100644 index 00000000000..5edb8bd8f56 --- /dev/null +++ b/apps/web/src/rendererStateStorage.ts @@ -0,0 +1,106 @@ +import type { DesktopRendererStateKey, LocalApi } from "@t3tools/contracts"; + +import { ensureLocalApi } from "./localApi"; +import { createMemoryStorage, resolveStorage, type StateStorage } from "./lib/storage"; + +type RendererStatePersistence = Pick< + LocalApi["persistence"], + "getRendererState" | "setRendererState" +>; + +export interface HydrationGuardedRendererStateStorage { + readonly storage: StateStorage; + readonly requiresExplicitHydration: boolean; + readonly enableWrites: () => void; + readonly writeHydratedValue: (name: string, value: string) => Promise; + readonly writesEnabled: () => boolean; +} + +export async function readRendererStateWithRetries( + read: () => T | Promise, + maxAttempts = 3, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await read(); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +export function createHydrationGuardedRendererStateStorage(input: { + readonly key: DesktopRendererStateKey; + readonly browserStorage: StateStorage; + readonly desktopPersistence?: RendererStatePersistence; +}): HydrationGuardedRendererStateStorage { + const browserStorage = resolveStorage(input.browserStorage); + const desktopPersistence = input.desktopPersistence; + let writesEnabled = desktopPersistence === undefined; + const writeHydratedValue = async (name: string, value: string): Promise => { + if (desktopPersistence === undefined) { + await browserStorage.setItem(name, value); + return; + } + await desktopPersistence.setRendererState(input.key, value); + }; + + return { + requiresExplicitHydration: desktopPersistence !== undefined, + enableWrites: () => { + writesEnabled = true; + }, + writeHydratedValue, + writesEnabled: () => writesEnabled, + storage: + desktopPersistence === undefined + ? browserStorage + : { + getItem: () => desktopPersistence.getRendererState(input.key), + setItem: (_name, value) => { + if (!writesEnabled) { + return Promise.resolve(); + } + return writeHydratedValue(_name, value); + }, + removeItem: () => { + if (!writesEnabled) { + return Promise.resolve(); + } + return desktopPersistence.setRendererState(input.key, null); + }, + }, + }; +} + +export function createAppRendererStateStorage( + key: DesktopRendererStateKey, +): HydrationGuardedRendererStateStorage { + const memoryStorage = createMemoryStorage(); + const browserStorage: StateStorage = { + getItem: (name) => + typeof localStorage === "undefined" + ? memoryStorage.getItem(name) + : localStorage.getItem(name), + setItem: (name, value) => + typeof localStorage === "undefined" + ? memoryStorage.setItem(name, value) + : localStorage.setItem(name, value), + removeItem: (name) => + typeof localStorage === "undefined" + ? memoryStorage.removeItem(name) + : localStorage.removeItem(name), + }; + const desktopPersistence = + typeof window !== "undefined" && window.desktopBridge + ? ensureLocalApi().persistence + : undefined; + + return createHydrationGuardedRendererStateStorage({ + key, + browserStorage, + ...(desktopPersistence ? { desktopPersistence } : {}), + }); +} diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..af6861571f8 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,6 +1,10 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; +import { + createAppRendererStateStorage, + readRendererStateWithRetries, +} from "./rendererStateStorage"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ @@ -15,6 +19,13 @@ const LEGACY_PERSISTED_STATE_KEYS = [ "codething:renderer-state:v2", "codething:renderer-state:v1", ] as const; +const uiStateStorage = createAppRendererStateStorage("ui-state"); +let uiStatePersistenceHydrated = !uiStateStorage.requiresExplicitHydration; +let uiStateHydrationPromise: Promise | null = null; +let uiStateHydrationGeneration = 0; +let uiStateHydrationBaseline: UiState | null = null; +let uiStateDocumentInvalid = false; +let applyingUiStateHydration = false; export interface PersistedUiState { projectExpandedById?: Record; @@ -51,6 +62,125 @@ const initialState: UiState = { defaultAdvertisedEndpointKey: null, }; +function snapshotUiState(state: UiState): UiState { + return { + projectExpandedById: { ...state.projectExpandedById }, + projectOrder: [...state.projectOrder], + threadLastVisitedAtById: { ...state.threadLastVisitedAtById }, + threadChangedFilesExpandedById: Object.fromEntries( + Object.entries(state.threadChangedFilesExpandedById).map(([threadId, turns]) => [ + threadId, + { ...turns }, + ]), + ), + defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, + }; +} + +function persistedValuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function reconcilePersistedRecord( + persisted: Readonly>, + current: Readonly>, + baseline: Readonly>, +): Record { + const reconciled: Record = {}; + const keys = new Set([ + ...Object.keys(persisted), + ...Object.keys(current), + ...Object.keys(baseline), + ]); + for (const key of keys) { + const currentHasKey = Object.hasOwn(current, key); + const baselineHasKey = Object.hasOwn(baseline, key); + const changedLocally = + currentHasKey !== baselineHasKey || + (currentHasKey && !persistedValuesEqual(current[key], baseline[key])); + if (changedLocally) { + if (currentHasKey) { + reconciled[key] = current[key] as T; + } + continue; + } + if (Object.hasOwn(persisted, key)) { + reconciled[key] = persisted[key] as T; + } + } + return reconciled; +} + +function reconcileNestedPersistedRecord( + persisted: Readonly>>>, + current: Readonly>>>, + baseline: Readonly>>>, +): Record> { + const reconciled: Record> = {}; + const keys = new Set([ + ...Object.keys(persisted), + ...Object.keys(current), + ...Object.keys(baseline), + ]); + for (const key of keys) { + const currentHasKey = Object.hasOwn(current, key); + const baselineHasKey = Object.hasOwn(baseline, key); + if (currentHasKey !== baselineHasKey) { + if (currentHasKey) { + reconciled[key] = { ...(current[key] as Readonly>) }; + } + continue; + } + if (!currentHasKey) { + if (Object.hasOwn(persisted, key)) { + reconciled[key] = { ...(persisted[key] as Readonly>) }; + } + continue; + } + + const reconciledNested = reconcilePersistedRecord( + persisted[key] ?? {}, + current[key] as Readonly>, + baseline[key] as Readonly>, + ); + if (Object.keys(reconciledNested).length > 0) { + reconciled[key] = reconciledNested; + } + } + return reconciled; +} + +function reconcileHydratedUiState( + persisted: UiState, + current: UiState, + baseline: UiState, +): UiState { + return { + projectExpandedById: reconcilePersistedRecord( + persisted.projectExpandedById, + current.projectExpandedById, + baseline.projectExpandedById, + ), + projectOrder: persistedValuesEqual(current.projectOrder, baseline.projectOrder) + ? persisted.projectOrder + : current.projectOrder, + threadLastVisitedAtById: reconcilePersistedRecord( + persisted.threadLastVisitedAtById, + current.threadLastVisitedAtById, + baseline.threadLastVisitedAtById, + ), + threadChangedFilesExpandedById: reconcileNestedPersistedRecord( + persisted.threadChangedFilesExpandedById, + current.threadChangedFilesExpandedById, + baseline.threadChangedFilesExpandedById, + ), + defaultAdvertisedEndpointKey: + current.defaultAdvertisedEndpointKey === baseline.defaultAdvertisedEndpointKey + ? persisted.defaultAdvertisedEndpointKey + : current.defaultAdvertisedEndpointKey, + }; +} + const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default"; let legacyKeysCleanedUp = false; @@ -135,26 +265,56 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { }; } -function readPersistedState(): UiState { +function parsePersistedStateJson(raw: string): UiState | null { + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsePersistedState(parsed as PersistedUiState); + } catch { + return null; + } +} + +function readLegacyPersistedState(): { readonly raw: string; readonly state: UiState } | null { if (typeof window === "undefined") { - return initialState; + return null; } - try { - const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); + for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { + let raw: string | null; + try { + raw = window.localStorage.getItem(legacyKey); + } catch { + return null; + } if (!raw) { - for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { - const legacyRaw = window.localStorage.getItem(legacyKey); - if (!legacyRaw) { - continue; - } - return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState); - } - return initialState; + continue; + } + const state = parsePersistedStateJson(raw); + if (state) { + return { raw, state }; } - return parsePersistedState(JSON.parse(raw) as PersistedUiState); + } + return null; +} + +function readPersistedState(): UiState { + if (uiStateStorage.requiresExplicitHydration) { + return initialState; + } + + let raw: string | null | Promise; + try { + raw = uiStateStorage.storage.getItem(PERSISTED_STATE_KEY); } catch { return initialState; } + if (typeof raw === "string") { + return parsePersistedStateJson(raw) ?? initialState; + } + + return readLegacyPersistedState()?.state ?? initialState; } function sanitizePersistedThreadChangedFilesExpanded( @@ -185,40 +345,50 @@ function sanitizePersistedThreadChangedFilesExpanded( return nextState; } -export function persistState(state: UiState): void { - if (typeof window === "undefined") { +function serializePersistedState(state: UiState): string { + const projectExpandedById = Object.fromEntries( + Object.entries(state.projectExpandedById).filter( + ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, + ), + ); + const threadChangedFilesExpandedById = Object.fromEntries( + Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { + const nextTurns = Object.fromEntries( + Object.entries(turns).filter(([, expanded]) => expanded === false), + ); + return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; + }), + ); + return JSON.stringify({ + projectExpandedById, + projectOrder: state.projectOrder, + threadLastVisitedAtById: state.threadLastVisitedAtById, + defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, + threadChangedFilesExpandedById, + } satisfies PersistedUiState); +} + +function cleanUpLegacyPersistedStateKeys(): void { + if (legacyKeysCleanedUp || typeof window === "undefined") { return; } + for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { + try { + window.localStorage.removeItem(legacyKey); + } catch { + return; + } + } + legacyKeysCleanedUp = true; +} + +export function persistState(state: UiState): void { try { - const projectExpandedById = Object.fromEntries( - Object.entries(state.projectExpandedById).filter( - ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, - ), - ); - const threadChangedFilesExpandedById = Object.fromEntries( - Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { - const nextTurns = Object.fromEntries( - Object.entries(turns).filter(([, expanded]) => expanded === false), - ); - return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; - }), - ); - window.localStorage.setItem( + const result = uiStateStorage.storage.setItem( PERSISTED_STATE_KEY, - JSON.stringify({ - projectExpandedById, - projectOrder: state.projectOrder, - threadLastVisitedAtById: state.threadLastVisitedAtById, - defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, - threadChangedFilesExpandedById, - } satisfies PersistedUiState), + serializePersistedState(state), ); - if (!legacyKeysCleanedUp) { - legacyKeysCleanedUp = true; - for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { - window.localStorage.removeItem(legacyKey); - } - } + void Promise.resolve(result).then(cleanUpLegacyPersistedStateKeys, () => undefined); } catch { // Ignore quota/storage errors to avoid breaking chat UX. } @@ -226,6 +396,25 @@ export function persistState(state: UiState): void { const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); +export async function flushUiStatePersistence(): Promise { + debouncedPersistState.cancel(); + if ( + !uiStatePersistenceHydrated && + !uiStateDocumentInvalid && + uiStateStorage.requiresExplicitHydration + ) { + await hydrateUiStateStore(); + } + if (!uiStatePersistenceHydrated || !uiStateStorage.writesEnabled()) { + return; + } + await uiStateStorage.writeHydratedValue( + PERSISTED_STATE_KEY, + serializePersistedState(useUiStateStore.getState()), + ); + cleanUpLegacyPersistedStateKeys(); +} + export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState { const visitedAtMs = Date.parse(visitedAt); if (!Number.isFinite(visitedAtMs)) { @@ -442,10 +631,122 @@ export const useUiStateStore = create((set) => ({ ), })); -useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); +export function continueUiStatePersistenceHydrationInBackground(): void { + if ( + uiStatePersistenceHydrated || + uiStateDocumentInvalid || + !uiStateStorage.requiresExplicitHydration || + uiStateHydrationPromise !== null + ) { + return; + } + void hydrateUiStateStore(); +} + +export async function hydrateUiStateStore(): Promise { + if ( + uiStatePersistenceHydrated || + uiStateDocumentInvalid || + !uiStateStorage.requiresExplicitHydration + ) { + return; + } + if (uiStateHydrationPromise) { + return uiStateHydrationPromise; + } + + const hydrationGeneration = uiStateHydrationGeneration; + const isCurrentHydration = () => hydrationGeneration === uiStateHydrationGeneration; + uiStateHydrationBaseline ??= snapshotUiState(useUiStateStore.getState()); + const hydrationPromise = (async () => { + try { + const raw = await readRendererStateWithRetries(() => + uiStateStorage.storage.getItem(PERSISTED_STATE_KEY), + ); + if (!isCurrentHydration()) { + return; + } + let persistedState: UiState; + let migratedLegacyState = false; + if (raw !== null) { + const parsedState = parsePersistedStateJson(raw); + if (!parsedState) { + uiStateDocumentInvalid = true; + console.error( + "[RENDERER_STATE] Desktop UI state document is not valid JSON state; preserving it without enabling writes.", + ); + return; + } + persistedState = parsedState; + } else { + const legacyState = readLegacyPersistedState(); + persistedState = legacyState?.state ?? initialState; + migratedLegacyState = legacyState !== null; + } + const baseline = uiStateHydrationBaseline ?? initialState; + const current = snapshotUiState(useUiStateStore.getState()); + const hadLocalChanges = !persistedValuesEqual(current, baseline); + const reconciledState = reconcileHydratedUiState(persistedState, current, baseline); + applyingUiStateHydration = true; + try { + useUiStateStore.setState(reconciledState); + } finally { + applyingUiStateHydration = false; + } + if (!isCurrentHydration()) { + return; + } + uiStateStorage.enableWrites(); + uiStatePersistenceHydrated = true; + uiStateHydrationBaseline = null; + if (migratedLegacyState || hadLocalChanges) { + try { + await uiStateStorage.writeHydratedValue( + PERSISTED_STATE_KEY, + serializePersistedState(reconciledState), + ); + if (migratedLegacyState) { + cleanUpLegacyPersistedStateKeys(); + } + } catch (error) { + console.error("[RENDERER_STATE] Reconciled UI state persistence failed.", error); + debouncedPersistState.maybeExecute(reconciledState); + } + } + } catch (error) { + if (isCurrentHydration()) { + console.error( + "[RENDERER_STATE] UI state hydration failed after bounded retries; writes remain guarded until a later read succeeds.", + error, + ); + } + } + })().finally(() => { + if (uiStateHydrationPromise === hydrationPromise) { + uiStateHydrationPromise = null; + } + }); + uiStateHydrationPromise = hydrationPromise; + return hydrationPromise; +} + +useUiStateStore.subscribe((state) => { + if (applyingUiStateHydration) { + return; + } + if (uiStatePersistenceHydrated) { + debouncedPersistState.maybeExecute(state); + return; + } + if (!uiStateDocumentInvalid) { + continueUiStatePersistenceHydrationInBackground(); + } +}); if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { - debouncedPersistState.flush(); + void flushUiStatePersistence().catch((error) => { + console.error("[RENDERER_STATE] UI state shutdown flush failed.", error); + }); }); } diff --git a/packages/contracts/src/ipc.test.ts b/packages/contracts/src/ipc.test.ts index 20db75368a9..4d91cf7f512 100644 --- a/packages/contracts/src/ipc.test.ts +++ b/packages/contracts/src/ipc.test.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { DesktopEnvironmentBootstrapSchema } from "./ipc.ts"; +import { DesktopEnvironmentBootstrapSchema, DesktopRendererStateWriteSchema } from "./ipc.ts"; describe("DesktopEnvironmentBootstrapSchema", () => { const decode = Schema.decodeUnknownSync(DesktopEnvironmentBootstrapSchema); @@ -36,3 +36,19 @@ describe("DesktopEnvironmentBootstrapSchema", () => { ).toBeNull(); }); }); + +describe("DesktopRendererStateWriteSchema", () => { + const decode = Schema.decodeUnknownSync(DesktopRendererStateWriteSchema); + + it("limits renderer persistence to the two declared state documents", () => { + expect(decode({ key: "ui-state", value: '{"projectOrder":[]}' })).toEqual({ + key: "ui-state", + value: '{"projectOrder":[]}', + }); + expect(decode({ key: "composer-preferences", value: null })).toEqual({ + key: "composer-preferences", + value: null, + }); + expect(() => decode({ key: "../settings", value: "{}" })).toThrow(); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d7926..6ea95b1b6e7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -178,6 +178,14 @@ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other" export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopRendererStateKeySchema = Schema.Literals(["ui-state", "composer-preferences"]); +export type DesktopRendererStateKey = typeof DesktopRendererStateKeySchema.Type; + +export const DesktopRendererStateWriteSchema = Schema.Struct({ + key: DesktopRendererStateKeySchema, + value: Schema.NullOr(Schema.String), +}); +export type DesktopRendererStateWrite = typeof DesktopRendererStateWriteSchema.Type; export interface DesktopAppBranding { baseName: string; @@ -981,6 +989,9 @@ export interface DesktopBridge { getLocalEnvironmentBearerToken: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; + getRendererState: (key: DesktopRendererStateKey) => Promise; + setRendererState: (key: DesktopRendererStateKey, value: string | null) => Promise; + onRendererStateFlush?: (listener: () => Promise) => () => void; getConnectionCatalog?: () => Promise; setConnectionCatalog?: (catalog: string) => Promise; clearConnectionCatalog?: () => Promise; @@ -1132,6 +1143,8 @@ export interface LocalApi { persistence: { getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; + getRendererState: (key: DesktopRendererStateKey) => Promise; + setRendererState: (key: DesktopRendererStateKey, value: string | null) => Promise; }; server: { getConfig: () => Promise;