diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 76ba6a57..1d9cc014 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -5,6 +5,8 @@ import { decodeLocalProfileId, decodeDesktopUpdateRendererReadyResult, decodeDesktopUpdateState, + decodeProjectionCacheLoadResult, + decodeProjectionCacheSaveResult, type BootstrapResult, type CommandRequest, type CommandResult, @@ -29,6 +31,9 @@ import { type PairRequest, type PairResult, type PairLinksDrainResult, + type ProjectionCacheLoadResult, + type ProjectionCacheSaveRequest, + type ProjectionCacheSaveResult, type RuntimeErrorEvent, type ServiceActionResult, type ServiceAvailabilityIssue, @@ -72,6 +77,10 @@ export interface IpcRuntime { readonly restartToUpdate: () => DesktopUpdateState; readonly subscribe: (listener: (state: DesktopUpdateState) => void) => () => void; }; + readonly projectionCache?: { + readonly load: () => ProjectionCacheLoadResult | Promise; + readonly save: (value: string) => ProjectionCacheSaveResult | Promise; + }; } export class RemotePairingUnavailableError extends Error { readonly code = "remote_pairing_unavailable" as const; @@ -271,6 +280,18 @@ export class DesktopIpcRegistry { openSettings: this.runtime.drainPendingUpdateOpen?.() ?? false, }); }); + this.ipc.handle("app:projection-cache:load", async (event, payload: unknown): Promise => { + this.assertSender(event); + decodeRequest("app:projection-cache:load", payload); + const result = await (this.runtime.projectionCache?.load() ?? { available: false, value: null }); + return decodeProjectionCacheLoadResult(result); + }); + this.ipc.handle("app:projection-cache:save", async (event, payload: unknown): Promise => { + this.assertSender(event); + const input = decodeRequest("app:projection-cache:save", payload).payload as ProjectionCacheSaveRequest; + const result = await (this.runtime.projectionCache?.save(input.value) ?? { saved: false }); + return decodeProjectionCacheSaveResult(result); + }); this.updateUnsubscribe = this.runtime.updateController?.subscribe((state) => { this.emit("app:update:state", state); }); @@ -289,6 +310,7 @@ export class DesktopIpcRegistry { "omp:service:restart", "omp:service:uninstall", "app:update:get-state", "app:update:check", "app:update:download", "app:update:restart", "app:update:renderer-ready", + "app:projection-cache:load", "app:projection-cache:save", ] as const) this.ipc.removeHandler(channel); this.installed = false; } diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index 47d1cf73..f705072e 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -8,7 +8,7 @@ import type { ServiceManager } from "@t4-code/service-manager"; import type { RemoteTargetRegistry } from "./remote-runtime/registry.ts"; import { createDesktopWindow, type DesktopWindowHandle } from "./window.ts"; import { DesktopIpcRegistry, runtimeError, type IpcRuntime } from "./ipc.ts"; -import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts"; +import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, ElectronProjectionCacheStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts"; import { VersionedRemoteTargetRegistry, DeviceCredentialStore } from "./remote-runtime/index.ts"; import { LocalTargetManager, type TargetManagerOptions } from "./target-manager.ts"; import { createAppserverServiceManager, discoverOmpExecutable, OmpAppserverCompatibilityError, probeOmpAppserver, NodeServiceFileSystem } from "./service.ts"; @@ -19,6 +19,8 @@ import { DesktopUpdateController } from "./update-controller.ts"; import { LocalProfileRegistry } from "./local-profiles.ts"; import { LocalProfileRuntime } from "./profile-runtime.ts"; +type ProjectionCacheRuntime = NonNullable; + export function appserverLogsDirectory( homeDirectory: string, platform: NodeJS.Platform = process.platform, @@ -48,6 +50,7 @@ export interface DesktopLifecycleOptions { readonly createRemoteRegistry?: () => RemoteTargetRegistry; readonly createCredentials?: () => DeviceCredentialStore | undefined; readonly createLocalProfileRegistry?: () => LocalProfileRegistry; + readonly createProjectionCache?: () => ProjectionCacheRuntime; readonly discoverExecutable?: () => Promise; readonly probeAppserver?: (executable: string) => Promise; readonly createServiceManager?: (options: Parameters[0]) => ServiceManager; @@ -75,6 +78,7 @@ export class DesktopLifecycle { private readonly remoteRegistryFactory: () => RemoteTargetRegistry; private readonly credentialsFactory: () => DeviceCredentialStore | undefined; private readonly localProfileRegistryFactory: () => LocalProfileRegistry; + private readonly projectionCacheFactory: () => ProjectionCacheRuntime; private readonly executableFactory: () => Promise; private readonly serviceFactory: (options: Parameters[0]) => ServiceManager; private readonly speechServiceFactory: (options: { readonly discoverExecutable: () => Promise }) => DesktopSpeechService; @@ -88,6 +92,7 @@ export class DesktopLifecycle { private localProfileRegistry: LocalProfileRegistry | undefined; private speechService: DesktopSpeechService | undefined; private profileRuntime: LocalProfileRuntime | undefined; + private projectionCache: ProjectionCacheRuntime | undefined; private serviceManager: ServiceManager | undefined; private readonly serviceManagers = new Map(); private serviceAvailabilityIssue: ServiceAvailabilityIssue | undefined; @@ -121,6 +126,7 @@ export class DesktopLifecycle { this.localProfileRegistryFactory = options.createLocalProfileRegistry ?? ( () => new LocalProfileRegistry(new ElectronLocalProfileStore()) ); + this.projectionCacheFactory = options.createProjectionCache ?? (() => new ElectronProjectionCacheStore()); this.executableFactory = options.discoverExecutable ?? (() => discoverOmpExecutable()); this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable)); this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager; @@ -165,6 +171,7 @@ export class DesktopLifecycle { ingest(value); }); await this.electronApp.whenReady(); + this.projectionCache = this.projectionCacheFactory(); if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code"); this.updateController = this.updateControllerFactory(); this.menuInstaller({ onOpenUpdates: () => this.openUpdatesFromMenu() }); @@ -227,6 +234,7 @@ export class DesktopLifecycle { this.mainWindow = undefined; this.updateController?.dispose(); this.updateController = undefined; + this.projectionCache = undefined; const manager = this.manager; this.manager = undefined; const recovery = this.serviceRecoveryPromise; @@ -423,6 +431,7 @@ export class DesktopLifecycle { getServiceAvailabilityIssue: () => this.serviceAvailabilityIssue, ...(this.speechService === undefined ? {} : { speech: this.speechService }), ...(this.profileRuntime === undefined ? {} : { profileRuntime: this.profileRuntime }), + ...(this.projectionCache === undefined ? {} : { projectionCache: this.projectionCache }), drainPairLinks: () => this.pendingPairs.drain(), drainPendingUpdateOpen: () => this.markUpdateRendererReady(), ...(this.updateController === undefined ? {} : { updateController: this.updateController }), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 7c922904..c80b35d2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,6 +3,8 @@ import { decodeDesktopEvent, decodeDesktopUpdateRendererReadyResult, decodeDesktopUpdateState, + decodeProjectionCacheLoadResult, + decodeProjectionCacheSaveResult, type BootstrapResult, type CommandRequest, type CommandResult, @@ -39,6 +41,9 @@ import { type TerminalResult, type SpeechRequest, type SpeechResult, + type ProjectionCacheLoadResult, + type ProjectionCacheSaveRequest, + type ProjectionCacheSaveResult, } from "@t4-code/protocol/desktop-ipc"; export interface OmpShellBridge { @@ -62,6 +67,8 @@ export interface OmpShellBridge { readonly serviceStop: () => Promise; readonly serviceRestart: () => Promise; readonly serviceUninstall: () => Promise; + readonly loadProjectionCache: () => Promise; + readonly saveProjectionCache: (request: ProjectionCacheSaveRequest) => Promise; readonly getUpdateState: () => Promise; readonly checkForUpdate: () => Promise; readonly downloadUpdate: () => Promise; @@ -84,6 +91,7 @@ export interface OmpShellBridge { readonly onConnectionState: (listener: (event: ConnectionStateEvent) => void) => () => void; readonly onRuntimeError: (listener: (event: RuntimeErrorEvent) => void) => () => void; readonly onPairLink: (listener: (event: PairLinkEvent) => void) => () => void; + readonly onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; readonly onOpenUpdateSettings: (listener: (event: DesktopUpdateOpenEvent) => void) => () => void; } @@ -92,6 +100,13 @@ function invoke; } +function invokeProjectionCache( + channel: "app:projection-cache:load" | "app:projection-cache:save", + payload: unknown, +): Promise { + return ipcRenderer.invoke(channel, { channel, payload }) as Promise; +} + type SubscriptionPayload = C extends "omp:server-frame" ? RendererServerFrameEvent : C extends "omp:connection-state" @@ -146,6 +161,10 @@ const bridge: OmpShellBridge = { downloadUpdate: () => invoke<"app:update:download", unknown>("app:update:download", {}).then(decodeDesktopUpdateState), restartToUpdate: () => invoke<"app:update:restart", unknown>("app:update:restart", {}).then(decodeDesktopUpdateState), updateRendererReady: () => invoke<"app:update:renderer-ready", unknown>("app:update:renderer-ready", {}).then(decodeDesktopUpdateRendererReadyResult), + loadProjectionCache: () => + invokeProjectionCache("app:projection-cache:load", {}).then(decodeProjectionCacheLoadResult), + saveProjectionCache: (request) => + invokeProjectionCache("app:projection-cache:save", request).then(decodeProjectionCacheSaveResult), listTargets: () => invoke("omp:targets:list", {}), addTarget: (request) => invoke("omp:targets:add", request), removeTarget: (request) => invoke("omp:targets:remove", request), diff --git a/apps/desktop/src/stores.ts b/apps/desktop/src/stores.ts index 048e105c..ba30eaa6 100644 --- a/apps/desktop/src/stores.ts +++ b/apps/desktop/src/stores.ts @@ -2,6 +2,12 @@ import { randomBytes } from "node:crypto"; import { hostname } from "node:os"; import ElectronStore from "electron-store"; import { safeStorage } from "electron"; +import { + decodeProjectionCacheSaveRequestValue, + MAX_PROJECTION_CACHE_BYTES, + type ProjectionCacheLoadResult, + type ProjectionCacheSaveResult, +} from "@t4-code/protocol/desktop-ipc"; import type { CursorRecord, CursorStore } from "@t4-code/client"; import type { CredentialEntry, CredentialVault, PairedHostRecord, TargetRegistry } from "@t4-code/remote"; import type { CredentialCiphertextStore, RemoteTargetRecord, RemoteTargetStore, SafeStorageAdapter } from "./remote-runtime/index.ts"; @@ -21,6 +27,10 @@ interface RegistryState { readonly targets: Record; } interface VaultState { readonly ciphertext: Record; } interface RemoteRegistryState { readonly version: 1; readonly records: readonly RemoteTargetRecord[]; } interface CredentialCiphertextState { readonly version: 1; readonly ciphertexts: Record; } +interface ProjectionCacheCiphertextState { + readonly version: 1; + readonly ciphertext: string | null; +} function recordKey(hostId: string, sessionId: string): string { return `${hostId}\u0000${sessionId}`; } function decodeRemoteState(value: unknown): RemoteRegistryState { @@ -40,6 +50,28 @@ function decodeCredentialState(value: unknown): CredentialCiphertextState { } return { version: 1, ciphertexts }; } +const MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH = + 4 * Math.ceil((MAX_PROJECTION_CACHE_BYTES + 64) / 3); +function decodeProjectionCacheState(value: unknown): ProjectionCacheCiphertextState { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("invalid projection cache state"); + const root = value as Record; + if (Object.keys(root).some((key) => key !== "version" && key !== "ciphertext")) + throw new Error("invalid projection cache state"); + if (root.version !== 1) throw new Error("invalid projection cache state"); + const ciphertext = root.ciphertext; + if (ciphertext === null) return { version: 1, ciphertext: null }; + if ( + typeof ciphertext !== "string" || + ciphertext.length === 0 || + ciphertext.length > MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH || + ciphertext.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/u.test(ciphertext) || + Buffer.from(ciphertext, "base64").toString("base64") !== ciphertext + ) + throw new Error("invalid projection cache state"); + return { version: 1, ciphertext }; +} function enqueueWrite(queue: { tail: Promise }, operation: () => void): Promise { const result = queue.tail.then(operation, operation); queue.tail = result.then(() => undefined, () => undefined); @@ -130,6 +162,71 @@ export class ElectronCredentialCiphertextStore implements CredentialCiphertextSt } } +export class ElectronProjectionCacheStore { + private readonly store: ElectronStore; + private readonly writeQueue = { tail: Promise.resolve() }; + private readonly safeStorage: SafeStorageAdapter; + constructor( + store = new ElectronStore({ + name: "projection-cache", + defaults: { version: 1, ciphertext: null }, + }), + safeStorage: SafeStorageAdapter = electronSafeStorage, + ) { + this.store = store; + this.safeStorage = safeStorage; + } + load(): ProjectionCacheLoadResult { + if (!this.safeStorage.isEncryptionAvailable()) return { available: false, value: null }; + let state: ProjectionCacheCiphertextState; + try { + state = decodeProjectionCacheState(this.store.store); + } catch { + this.clear(); + return { available: true, value: null }; + } + if (state.ciphertext === null) return { available: true, value: null }; + try { + const value = this.safeStorage.decryptString(Buffer.from(state.ciphertext, "base64")); + decodeProjectionCacheSaveRequestValue(value); + return { available: true, value }; + } catch { + this.clear(); + return { available: true, value: null }; + } + } + save(value: string): Promise { + try { + decodeProjectionCacheSaveRequestValue(value); + } catch { + return Promise.resolve({ saved: false }); + } + return enqueueWrite(this.writeQueue, () => { + if (!this.safeStorage.isEncryptionAvailable()) throw new Error("projection cache encryption unavailable"); + const ciphertext = this.safeStorage.encryptString(value).toString("base64"); + if ( + ciphertext.length === 0 || + ciphertext.length > MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH || + ciphertext.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/u.test(ciphertext) || + Buffer.from(ciphertext, "base64").toString("base64") !== ciphertext + ) + throw new Error("projection cache ciphertext is invalid"); + this.store.store = { version: 1, ciphertext }; + }).then( + () => ({ saved: true }), + () => ({ saved: false }), + ); + } + private clear(): void { + try { + this.store.store = { version: 1, ciphertext: null }; + } catch { + /* best-effort removal of corrupt state */ + } + } +} + export class ElectronLocalProfileStore implements LocalProfileStore { private readonly store: ElectronStore; private readonly writeQueue = { tail: Promise.resolve() }; diff --git a/apps/desktop/test/ipc-lifecycle.test.ts b/apps/desktop/test/ipc-lifecycle.test.ts index dc1a2515..46e33de1 100644 --- a/apps/desktop/test/ipc-lifecycle.test.ts +++ b/apps/desktop/test/ipc-lifecycle.test.ts @@ -107,6 +107,42 @@ describe("desktop IPC lifecycle proof", () => { await expect(handler(event, request("omp:targets:list", { extra: true }))).rejects.toThrow(); await expect(handler(event, request("omp:connect", {}))).rejects.toThrow(); }); + it("routes projection cache load/save through the trusted shell service", async () => { + const ipc = new FakeIpc(); + const { runtime: baseRuntime } = makeRuntime(); + const value = JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" }, + }); + const saves: string[] = []; + const runtime: IpcRuntime = { + ...baseRuntime, + projectionCache: { + load: () => ({ available: true, value }), + save: (serialized) => { + saves.push(serialized); + return { saved: true }; + }, + }, + }; + new DesktopIpcRegistry(runtime, ipc).install(); + const event = { sender: runtime.window.webContents, senderFrame: runtime.window.webContents.mainFrame }; + expect(await ipc.handlers.get("app:projection-cache:load")!( + event, + request("app:projection-cache:load"), + )).toEqual({ available: true, value }); + expect(await ipc.handlers.get("app:projection-cache:save")!( + event, + request("app:projection-cache:save", { value }), + )).toEqual({ saved: true }); + expect(saves).toEqual([value]); + await expect(ipc.handlers.get("app:projection-cache:save")!( + event, + request("app:projection-cache:save", { value, storageKey: "renderer-selected-file" }), + )).rejects.toThrow(); + expect(saves).toEqual([value]); + }); it("serializes concurrent service actions", async () => { const ipc = new FakeIpc(); const order: string[] = []; diff --git a/apps/desktop/test/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts index b5abe362..d20d406b 100644 --- a/apps/desktop/test/lifecycle-runtime.test.ts +++ b/apps/desktop/test/lifecycle-runtime.test.ts @@ -87,6 +87,7 @@ function setup( overrides: { readonly discoverExecutable?: () => Promise; readonly createServiceManager?: NonNullable; + readonly createProjectionCache?: NonNullable; } = {}, ) { const app = new FakeApp(); @@ -132,6 +133,7 @@ function setup( createCursorStore: () => ({ load: () => [], save: () => {} }), createCredentials: () => undefined, createLocalProfileRegistry: () => localProfileRegistry, + ...(overrides.createProjectionCache === undefined ? {} : { createProjectionCache: overrides.createProjectionCache }), discoverExecutable: overrides.discoverExecutable ?? (serviceManager === undefined ? async () => undefined : async () => "/opt/omp/bin/omp"), ...( overrides.createServiceManager === undefined && serviceManager === undefined @@ -181,6 +183,29 @@ describe("desktop Electron lifecycle", () => { expect(fixture.managerOptions).toBeDefined(); await fixture.lifecycle.stop(); }); + it("creates one projection cache after readiness and injects it into every IPC binding", async () => { + const cache = { + load: () => ({ available: false, value: null }), + save: (_value: string) => ({ saved: false }), + }; + let cacheCreates = 0; + const fixture = setup(undefined, async () => true, { + createProjectionCache: () => { + cacheCreates += 1; + return cache; + }, + }); + expect(cacheCreates).toBe(0); + await fixture.lifecycle.start(); + expect(cacheCreates).toBe(1); + expect(fixture.runtimes[0]).toMatchObject({ projectionCache: cache }); + fixture.windows[0]!.close(); + fixture.app.listeners.get("activate")?.(); + expect(fixture.runtimes).toHaveLength(2); + expect(fixture.runtimes[1]).toMatchObject({ projectionCache: cache }); + expect(cacheCreates).toBe(1); + await fixture.lifecycle.stop(); + }); it("routes the native update menu to the trusted renderer and schedules one passive check", async () => { const fixture = setup(); await fixture.lifecycle.start(); diff --git a/apps/desktop/test/stores-lifecycle.test.ts b/apps/desktop/test/stores-lifecycle.test.ts index a405b2d7..d09e078b 100644 --- a/apps/desktop/test/stores-lifecycle.test.ts +++ b/apps/desktop/test/stores-lifecycle.test.ts @@ -3,7 +3,7 @@ import type { CursorStore, OmpTransport } from "@t4-code/client"; import { hostId, type WelcomeFrame } from "@t4-code/protocol"; import { DeviceCredentialStore, type CredentialCiphertextStore, type RemoteTargetRecord, type RemoteTargetRegistry } from "../src/remote-runtime/index.ts"; import { DesktopTargetManager } from "../src/target-manager.ts"; -import { ElectronCredentialCiphertextStore, ElectronRemoteTargetStore, loadDeviceIdentity } from "../src/stores.ts"; +import { ElectronCredentialCiphertextStore, ElectronProjectionCacheStore, ElectronRemoteTargetStore, loadDeviceIdentity } from "../src/stores.ts"; type State = Record; class MemoryStore { @@ -99,6 +99,81 @@ describe("desktop persisted lifecycle stores", () => { expect(() => new DeviceCredentialStore(store, { isEncryptionAvailable: () => false, encryptString: (_value: string) => Buffer.alloc(0), decryptString: (_value: Buffer) => "" })).toThrow(); expect(() => new DeviceCredentialStore(store, { isEncryptionAvailable: () => true, selectedStorageBackend: () => "basic_text", encryptString: (_value: string) => Buffer.alloc(0), decryptString: (_value: Buffer) => "" })).toThrow(); }); + it("stores only safeStorage ciphertext and restores valid projection cache", async () => { + const backing = new MemoryStore({ version: 1 as const, ciphertext: null as string | null }); + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`ciphertext:${value}`), + decryptString: (value: Buffer) => { + const encoded = value.toString(); + if (!encoded.startsWith("ciphertext:")) throw new Error("corrupt ciphertext"); + return encoded.slice("ciphertext:".length); + }, + }; + const store = new ElectronProjectionCacheStore(backing as never, safeStorage); + const value = JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" }, + }); + expect(await store.save(value)).toEqual({ saved: true }); + expect(backing.store.ciphertext).not.toContain(value); + expect(store.load()).toEqual({ available: true, value }); + }); + it("degrades when safeStorage is unavailable and clears incompatible state", async () => { + const unavailableBacking = new MemoryStore({ version: 1 as const, ciphertext: null as string | null }); + const unavailable = new ElectronProjectionCacheStore(unavailableBacking as never, { + isEncryptionAvailable: () => false, + encryptString: () => Buffer.from("unused"), + decryptString: () => "unused", + }); + expect(unavailable.load()).toEqual({ available: false, value: null }); + expect(await unavailable.save(JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: {}, + }))).toEqual({ saved: false }); + + const corruptBacking = new MemoryStore({ version: 2 as never, ciphertext: "not-valid" }); + const corrupt = new ElectronProjectionCacheStore(corruptBacking as never, { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString(), + }); + expect(corrupt.load()).toEqual({ available: true, value: null }); + expect(corruptBacking.store).toEqual({ version: 1, ciphertext: null }); + const payloadBacking = new MemoryStore({ + version: 1 as const, + ciphertext: Buffer.from("encrypted").toString("base64"), + }); + const payloadCorrupt = new ElectronProjectionCacheStore(payloadBacking as never, { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: () => JSON.stringify({ kind: "t4-code-projection", version: 2, data: {} }), + }); + expect(payloadCorrupt.load()).toEqual({ available: true, value: null }); + expect(payloadBacking.store).toEqual({ version: 1, ciphertext: null }); + }); + it("serializes concurrent projection cache writes", async () => { + const backing = new MemoryStore({ version: 1 as const, ciphertext: null as string | null }); + const store = new ElectronProjectionCacheStore(backing as never, { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString(), + }); + const makeValue = (marker: string) => JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { marker }, + }); + const first = makeValue("first"); + const second = makeValue("second"); + expect(await Promise.all([store.save(first), store.save(second)])).toEqual([ + { saved: true }, + { saved: true }, + ]); + expect(store.load()).toEqual({ available: true, value: second }); + }); }); describe("pairing credential sink boundaries", () => { diff --git a/apps/web/src/features/session-runtime/live-runtime.ts b/apps/web/src/features/session-runtime/live-runtime.ts index 818deff3..9df38b1d 100644 --- a/apps/web/src/features/session-runtime/live-runtime.ts +++ b/apps/web/src/features/session-runtime/live-runtime.ts @@ -624,7 +624,9 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu let attaching = false; let retryAfterAttach = false; let connectionGeneration = 0; - let previousConnected = controller.getSnapshot().connections.get(targetId) === "connected"; + let previousAttachAuthority = + controller.getSnapshot().connections.get(targetId) === "connected" && + controller.getSnapshot().targetHosts.get(targetId) === options.hostId; const initialAuthoritativeWorking = authoritativeWorkingState( controller.getSnapshot(), targetId, @@ -646,19 +648,21 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu // turn/compaction whose active ref delta has not arrived yet. transcript = settleTranscriptTurn(transcript); } - const attachIfConnected = (runtime: DesktopRuntimeSnapshot) => { + const attachIfAuthoritative = (runtime: DesktopRuntimeSnapshot) => { if (disposed) return; - const connected = runtime.connections.get(targetId) === "connected"; - if (connected !== previousConnected) { - previousConnected = connected; + const hasAttachAuthority = + runtime.connections.get(targetId) === "connected" && + runtime.targetHosts.get(targetId) === options.hostId; + if (hasAttachAuthority !== previousAttachAuthority) { + previousAttachAuthority = hasAttachAuthority; connectionGeneration += 1; - if (!connected) { + if (!hasAttachAuthority) { attached = false; transcriptImagesAttached = false; syncTranscriptImageAvailability(runtime); } } - if (!connected) return; + if (!hasAttachAuthority) return; if (attached) return; if (attaching) { retryAfterAttach = true; @@ -688,7 +692,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu if (disposed) return; if (retryAfterAttach && generation !== connectionGeneration) { retryAfterAttach = false; - attachIfConnected(controller.getSnapshot()); + attachIfAuthoritative(controller.getSnapshot()); } else { retryAfterAttach = false; } @@ -715,7 +719,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu const unsubscribeRuntime = controller.subscribe((runtime) => { const retainedTranscript = withWarmHistoryTruncation(transcript, runtime); if (retainedTranscript !== transcript) transcript = retainedTranscript; - attachIfConnected(runtime); + attachIfAuthoritative(runtime); const authoritativeWorking = authoritativeWorkingState( runtime, targetId, @@ -750,7 +754,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu // part of the attach round-trip; no replay frame may land in the gap between // runtime construction and listener registration. syncTranscriptImageAvailability(controller.getSnapshot()); - attachIfConnected(controller.getSnapshot()); + attachIfAuthoritative(controller.getSnapshot()); const pendingChallenge = (runtime: DesktopRuntimeSnapshot): PendingChallenge | null => { const confirmations = warmSession(runtime)?.confirmations; diff --git a/apps/web/src/platform/desktop-runtime.ts b/apps/web/src/platform/desktop-runtime.ts index 8dcbd391..6f058063 100644 --- a/apps/web/src/platform/desktop-runtime.ts +++ b/apps/web/src/platform/desktop-runtime.ts @@ -5,9 +5,11 @@ // mode has no shell and keeps the fixture path. import { createDesktopRuntimeController, + createProjectionStore, type DesktopRuntimeController, type DesktopRuntimeSnapshot, type DesktopShellPort, + type ProjectionCacheStore, } from "@t4-code/client"; import { useSyncExternalStore } from "react"; @@ -31,6 +33,22 @@ export interface RuntimePageLifecycleTarget { /** Anything that can carry the window's runtime slot (globalThis in prod). */ export type RuntimeSlotHolder = object & { [RUNTIME_SLOT]?: RuntimeSlot }; +function projectionCacheStore(shell: DesktopShellPort): ProjectionCacheStore | undefined { + const load = shell.loadProjectionCache?.bind(shell); + const save = shell.saveProjectionCache?.bind(shell); + if (load === undefined || save === undefined) return undefined; + return { + load: async () => { + const result = await load(); + return result.available ? result.value : null; + }, + save: async (value) => { + const result = await save({ value }); + if (!result.saved) throw new Error("projection cache save failed"); + }, + }; +} + /** * The holder's controller, created on first acquisition and reused for * every later call — including StrictMode's doubled render pass and HMR @@ -42,7 +60,13 @@ export function acquireRuntimeController( ): DesktopRuntimeController { let slot = holder[RUNTIME_SLOT]; if (slot === undefined) { - const controller = createDesktopRuntimeController({ shell }); + const cacheStore = projectionCacheStore(shell); + const controller = createDesktopRuntimeController({ + shell, + ...(cacheStore === undefined + ? {} + : { projection: createProjectionStore({ cacheStore }) }), + }); slot = { controller, disposeComposerCleanup: bindAuthoritativeComposerCleanup(controller), diff --git a/apps/web/src/platform/live-workspace.ts b/apps/web/src/platform/live-workspace.ts index f3a600db..f46368de 100644 --- a/apps/web/src/platform/live-workspace.ts +++ b/apps/web/src/platform/live-workspace.ts @@ -182,7 +182,25 @@ export function deriveWorkspaceData(snapshot: DesktopRuntimeSnapshot): Workspace } const hosts: WorkspaceHost[] = []; - for (const [hostId, meta] of snapshot.hosts) { + const hostIds = new Set(snapshot.hosts.keys()); + for (const ref of snapshot.projection.sessionIndex.values()) { + hostIds.add(String(ref.hostId)); + } + for (const hostId of hostIds) { + const meta = snapshot.hosts.get(hostId); + if (meta === undefined) { + // Projection caches retain stable host ids but intentionally do not + // persist target bindings or welcome capabilities. This display-only + // placeholder keeps cached rows grouped without inventing authority; + // the first live welcome replaces it with authenticated metadata. + hosts.push({ + id: hostId, + name: hostId, + kind: "remote", + sessionInventoryTruncated: true, + }); + continue; + } const target = snapshot.targets.get(meta.targetId); const inventoryMetadata = snapshot.projection.sessionIndexMetadata.get(hostId); hosts.push({ diff --git a/apps/web/test/live-composer.test.ts b/apps/web/test/live-composer.test.ts index 6d8e2ea4..8640a9d6 100644 --- a/apps/web/test/live-composer.test.ts +++ b/apps/web/test/live-composer.test.ts @@ -11,6 +11,7 @@ import { applyPublicFrame, createDesktopRuntimeController, createProjectionSnapshot, + decodeProjectionCache, encodeProjectionCache, type DesktopRuntimeController, type DesktopRuntimeSnapshot, @@ -2934,6 +2935,93 @@ describe("window runtime slot", () => { expect(shell.connectCalls).toBe(1); await first.stop(); }); + it("hydrates the renderer projection from shell cache and persists later mutations", async () => { + let warm = applyPublicFrame( + createProjectionSnapshot(), + snapshotFrame(1, [entry("cached", "from shell cache")]), + ); + warm = applyPublicFrame(warm, pendingPromptsSessionsFrame([], 1, "idle")); + const cachedValue = encodeProjectionCache(warm); + const saves: string[] = []; + let loads = 0; + const cacheLoad = deferred<{ available: boolean; value: string | null }>(); + type CacheShell = FakeShell & { + loadProjectionCache: () => Promise<{ available: boolean; value: string | null }>; + saveProjectionCache: (request: { value: string }) => Promise<{ saved: boolean }>; + }; + const shell = new FakeShell() as CacheShell; + shell.loadProjectionCache = () => { + loads += 1; + return cacheLoad.promise; + }; + shell.saveProjectionCache = async ({ value }) => { + saves.push(value); + return { saved: true }; + }; + const holder: RuntimeSlotHolder = {}; + const controller = acquireRuntimeController(shell, holder); + const starting = controller.start(); + await settle(); + expect(loads).toBe(1); + expect(shell.bootstrapCalls).toBe(0); + cacheLoad.resolve({ available: true, value: cachedValue }); + await starting; + expect(shell.bootstrapCalls).toBe(1); + const cachedSession = controller.getSnapshot().projection.sessions.get(`${HOST}\u0000${SESSION}`); + expect(cachedSession?.entries.map((item) => item.data)).toContainEqual({ + role: "assistant", + text: "from shell cache", + }); + const cachedGroups = buildProjectGroups( + deriveWorkspaceData(controller.getSnapshot()), + {}, + {}, + ); + expect(cachedGroups).toHaveLength(1); + expect(cachedGroups[0]?.sessions.map((row) => row.session.id)).toContain( + sessionViewId(HOST, SESSION), + ); + expect(cachedGroups[0]?.host).toMatchObject({ + id: HOST, + name: HOST, + kind: "remote", + }); + const cachedRow = cachedGroups[0]?.sessions[0]; + expect(cachedRow?.session.freshness).toBe("offline"); + const cachedRuntime = createLiveSessionRuntime({ + controller, + targetId: "local", + hostId: HOST, + sessionId: SESSION, + }); + expect(cachedRuntime.getSnapshot()).toMatchObject({ + link: "cached", + canPrompt: false, + canCancel: false, + }); + expect(shell.commandCount("session.attach")).toBe(0); + + expect(shell.connectCalls).toBe(1); + shell.emitFrame({ + targetId: "local", + frame: makeWelcome(HOST, ["sessions.prompt"]), + }); + shell.emitFrame({ + targetId: "local", + frame: durableEntryFrame(2, entry("live", "persist this mutation")), + }); + await settle(); + expect(shell.commandCount("session.attach")).toBe(1); + cachedRuntime.dispose(); + expect(saves.length).toBeGreaterThan(0); + const persisted = decodeProjectionCache(saves.at(-1)!); + const persistedSession = persisted.sessions.get(`${HOST}\u0000${SESSION}`); + expect(persistedSession?.entries.map((item) => item.data)).toContainEqual({ + role: "assistant", + text: "persist this mutation", + }); + await controller.stop(); + }); it("retains one live controller across a persisted pagehide/pageshow", async () => { class FakePageLifecycleTarget { diff --git a/packages/client/src/desktop-runtime-contracts.ts b/packages/client/src/desktop-runtime-contracts.ts index b070607a..469c0cf1 100644 --- a/packages/client/src/desktop-runtime-contracts.ts +++ b/packages/client/src/desktop-runtime-contracts.ts @@ -37,6 +37,9 @@ import type { TerminalResult, SpeechRequest, SpeechResult, + ProjectionCacheLoadResult, + ProjectionCacheSaveRequest, + ProjectionCacheSaveResult, } from "@t4-code/protocol/desktop-ipc"; import type { CatalogFrame, SettingsFrame, WelcomeFrame } from "@t4-code/protocol"; import { ImmutableMap } from "./immutable-map.ts"; @@ -76,6 +79,8 @@ export interface DesktopShellPort { readonly downloadUpdate?: () => Promise; readonly restartToUpdate?: () => Promise; readonly updateRendererReady?: () => Promise; + readonly loadProjectionCache?: () => Promise; + readonly saveProjectionCache?: (request: ProjectionCacheSaveRequest) => Promise; readonly listTargets: () => Promise; readonly addTarget: (request: TargetAddRequest) => Promise; readonly removeTarget: (request: TargetRequest) => Promise; diff --git a/packages/client/src/desktop-runtime.ts b/packages/client/src/desktop-runtime.ts index 3edfa1c2..3e702c1f 100644 --- a/packages/client/src/desktop-runtime.ts +++ b/packages/client/src/desktop-runtime.ts @@ -626,6 +626,8 @@ export class DesktopRuntimeController { let bootstrap: BootstrapResult; let listed: TargetListResult; try { + await this.projection.ready(); + if (this.stopped) throw new DesktopRuntimeError("stopped", "desktop runtime is stopped"); bootstrap = await this.shell.bootstrap(); if (this.stopped) throw new DesktopRuntimeError("stopped", "desktop runtime is stopped"); this.replace({ platform: bootstrap.platform, desktopVersion: bootstrap.version }); diff --git a/packages/client/src/projection-cache.ts b/packages/client/src/projection-cache.ts index cca9dc8a..119d7e33 100644 --- a/packages/client/src/projection-cache.ts +++ b/packages/client/src/projection-cache.ts @@ -8,6 +8,7 @@ import type { ReviewFrame, SessionRef, } from "@t4-code/protocol"; +import { MAX_PROJECTION_CACHE_BYTES } from "@t4-code/protocol/desktop-ipc"; import { MAX_INDEXED_SESSION_REFS } from "./projection.ts"; import type { AgentTranscriptProjection, @@ -23,7 +24,7 @@ import { ImmutableMap } from "./immutable-map.ts"; import { retainedJsonBytes } from "./transcript-retention.ts"; export const PROJECTION_CACHE_VERSION = 1 as const; -export const MAX_PROJECTION_CACHE_BYTES = 2 * 1024 * 1024; +export { MAX_PROJECTION_CACHE_BYTES }; export const MAX_PROJECTION_CACHE_SESSIONS = 8; const MAX_PROJECTION_CACHE_TRANSCRIPT_BYTES = Math.floor(MAX_PROJECTION_CACHE_BYTES * 0.75); diff --git a/packages/client/src/projection.ts b/packages/client/src/projection.ts index 7b2511ce..0f1a44f8 100644 --- a/packages/client/src/projection.ts +++ b/packages/client/src/projection.ts @@ -1438,7 +1438,8 @@ export class ProjectionStore { } private async drainCacheSaves(): Promise { try { - while (this.pendingSnapshot !== undefined && !this.disposed) { + // Disposal blocks new mutations, but already-coalesced snapshots must still drain. + while (this.pendingSnapshot !== undefined) { const snapshot = this.pendingSnapshot; this.pendingSnapshot = undefined; let serialized: string; diff --git a/packages/client/test/projection.test.ts b/packages/client/test/projection.test.ts index b5a09e45..ca02e6f9 100644 --- a/packages/client/test/projection.test.ts +++ b/packages/client/test/projection.test.ts @@ -1603,10 +1603,15 @@ describe("client projections", () => { store.applyPublicFrame(frame("snapshot")); store.applyPublicFrame(frame("event")); expect(saves).toHaveLength(1); + const shutdown = store.dispose(); + expect(saves).toHaveLength(1); release?.(); await Promise.resolve(); await Promise.resolve(); expect(saves).toHaveLength(2); + expect(saves[1]).not.toBe(saves[0]); + release?.(); + await shutdown; dispose(); store.applyPublicFrame({ ...frame("event"), cursor: { epoch: "e1", seq: 3 } }); expect(calls).toBe(2); diff --git a/packages/protocol/src/desktop-ipc.ts b/packages/protocol/src/desktop-ipc.ts index fedd06a4..dffc4628 100644 --- a/packages/protocol/src/desktop-ipc.ts +++ b/packages/protocol/src/desktop-ipc.ts @@ -65,6 +65,8 @@ export const DESKTOP_IPC_CHANNELS = [ "omp:speech:stop", "app:update:restart", "app:update:renderer-ready", + "app:projection-cache:load", + "app:projection-cache:save", ] as const; export type DesktopInvokeChannel = (typeof DESKTOP_IPC_CHANNELS)[number]; export const DESKTOP_IPC_EVENTS = [ @@ -276,6 +278,18 @@ export interface SpeechResult { readonly error?: string; } +export const MAX_PROJECTION_CACHE_BYTES = 2 * 1024 * 1024; +export interface ProjectionCacheLoadRequest {} +export interface ProjectionCacheLoadResult { + readonly available: boolean; + readonly value: string | null; +} +export interface ProjectionCacheSaveRequest { + readonly value: string; +} +export interface ProjectionCacheSaveResult { + readonly saved: boolean; +} export interface DesktopUpdateRequest {} export interface DesktopUpdateRendererReadyResult { readonly openSettings: boolean; @@ -453,6 +467,8 @@ export interface DesktopInvokeRequestMap { "app:update:download": DesktopUpdateRequest; "app:update:restart": DesktopUpdateRequest; "app:update:renderer-ready": DesktopUpdateRequest; + "app:projection-cache:load": ProjectionCacheLoadRequest; + "app:projection-cache:save": ProjectionCacheSaveRequest; } export interface DesktopInvokeResponseMap { "omp:targets:list": TargetListResult; @@ -489,6 +505,8 @@ export interface DesktopInvokeResponseMap { "app:update:download": DesktopUpdateState; "app:update:restart": DesktopUpdateState; "app:update:renderer-ready": DesktopUpdateRendererReadyResult; + "app:projection-cache:load": ProjectionCacheLoadResult; + "app:projection-cache:save": ProjectionCacheSaveResult; } export interface RendererServerFrameEvent { targetId: string; @@ -746,6 +764,50 @@ export function decodeSpeechText(value: unknown): string { } return value; } +function projectionCacheSerialized(value: unknown): string { + if (typeof value !== "string" || value.length === 0 || utf8ByteLength(value) > MAX_PROJECTION_CACHE_BYTES) + throw new Error("invalid projection cache value"); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("invalid projection cache value"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new Error("invalid projection cache value"); + const root = object(parsed, "projection cache"); + const data = root.data; + if ( + root.kind !== "t4-code-projection" || + root.version !== 1 || + !data || + typeof data !== "object" || + Array.isArray(data) + ) + throw new Error("invalid projection cache value"); + return value; +} +export function decodeProjectionCacheLoadResult(value: unknown): ProjectionCacheLoadResult { + const result = object(value, "projection cache load result"); + exact(result, ["available", "value"]); + const available = result.available; + const cacheValue = result.value; + if (typeof available !== "boolean" || (cacheValue !== null && typeof cacheValue !== "string")) + throw new Error("invalid projection cache load result"); + if (cacheValue !== null) projectionCacheSerialized(cacheValue); + if (!available && cacheValue !== null) throw new Error("invalid projection cache load result"); + return Object.freeze({ available, value: cacheValue }); +} +export function decodeProjectionCacheSaveResult(value: unknown): ProjectionCacheSaveResult { + const result = object(value, "projection cache save result"); + exact(result, ["saved"]); + const saved = result.saved; + if (typeof saved !== "boolean") throw new Error("invalid projection cache save result"); + return Object.freeze({ saved }); +} +export function decodeProjectionCacheSaveRequestValue(value: unknown): string { + return projectionCacheSerialized(value); +} export function decodeDesktopInvokeRequest(input: unknown): DesktopInvokeRequest { const frame = inputObject(input); exact(frame, ["channel", "payload"]); @@ -813,6 +875,12 @@ export function decodeDesktopInvokeRequest(input: unknown): DesktopInvokeRequest case "app:update:renderer-ready": exact(payload, []); return { channel, payload: {} }; + case "app:projection-cache:load": + exact(payload, []); + return { channel, payload: {} }; + case "app:projection-cache:save": + exact(payload, ["value"]); + return { channel, payload: { value: projectionCacheSerialized(payload.value) } }; case "omp:command": exact(payload, ["targetId", "intent"]); { diff --git a/packages/protocol/test/desktop-ipc.test.ts b/packages/protocol/test/desktop-ipc.test.ts index 029110de..af0915d7 100644 --- a/packages/protocol/test/desktop-ipc.test.ts +++ b/packages/protocol/test/desktop-ipc.test.ts @@ -5,7 +5,11 @@ import { decodeDesktopInvokeRequest, decodeDesktopUpdateRendererReadyResult, decodeDesktopUpdateState, + decodeProjectionCacheLoadResult, + decodeProjectionCacheSaveRequestValue, + decodeProjectionCacheSaveResult, decodeSpeechText, + MAX_PROJECTION_CACHE_BYTES, MAX_SPEECH_TEXT_BYTES, isDesktopInvokeRequest, } from "../src/desktop-ipc.ts"; @@ -421,4 +425,39 @@ describe("desktop IPC boundary", () => { expect(() => decodeSpeechText("bad\0text")).toThrow(); expect(() => decodeDesktopInvokeRequest({ channel: "omp:speech:stop", payload: {} })).not.toThrow(); }); + it("strictly bounds and versions projection cache IPC payloads", () => { + const value = JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" }, + }); + expect(decodeDesktopInvokeRequest({ + channel: "app:projection-cache:load", + payload: {}, + })).toEqual({ channel: "app:projection-cache:load", payload: {} }); + expect(decodeDesktopInvokeRequest({ + channel: "app:projection-cache:save", + payload: { value }, + })).toEqual({ channel: "app:projection-cache:save", payload: { value } }); + expect(decodeProjectionCacheSaveRequestValue(value)).toBe(value); + expect(decodeProjectionCacheLoadResult({ available: true, value })).toEqual({ + available: true, + value, + }); + expect(decodeProjectionCacheSaveResult({ saved: true })).toEqual({ saved: true }); + + for (const invalid of [ + { channel: "app:projection-cache:load", payload: { storageKey: "renderer-choice" } }, + { channel: "app:projection-cache:save", payload: { value: JSON.stringify({ kind: "t4-code-projection", version: 2, data: {} }) } }, + { channel: "app:projection-cache:save", payload: { value: "not-json" } }, + ]) expect(() => decodeDesktopInvokeRequest(invalid)).toThrow(); + expect(() => decodeProjectionCacheLoadResult({ available: false, value })).toThrow(); + expect(() => decodeProjectionCacheSaveResult({ saved: "yes" })).toThrow(); + const oversized = JSON.stringify({ + kind: "t4-code-projection", + version: 1, + data: { padding: "x".repeat(MAX_PROJECTION_CACHE_BYTES) }, + }); + expect(() => decodeProjectionCacheSaveRequestValue(oversized)).toThrow(); + }); });