Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
decodeLocalProfileId,
decodeDesktopUpdateRendererReadyResult,
decodeDesktopUpdateState,
decodeProjectionCacheLoadResult,
decodeProjectionCacheSaveResult,
type BootstrapResult,
type CommandRequest,
type CommandResult,
Expand All @@ -29,6 +31,9 @@ import {
type PairRequest,
type PairResult,
type PairLinksDrainResult,
type ProjectionCacheLoadResult,
type ProjectionCacheSaveRequest,
type ProjectionCacheSaveResult,
type RuntimeErrorEvent,
type ServiceActionResult,
type ServiceAvailabilityIssue,
Expand Down Expand Up @@ -72,6 +77,10 @@ export interface IpcRuntime {
readonly restartToUpdate: () => DesktopUpdateState;
readonly subscribe: (listener: (state: DesktopUpdateState) => void) => () => void;
};
readonly projectionCache?: {
readonly load: () => ProjectionCacheLoadResult | Promise<ProjectionCacheLoadResult>;
readonly save: (value: string) => ProjectionCacheSaveResult | Promise<ProjectionCacheSaveResult>;
};
}
export class RemotePairingUnavailableError extends Error {
readonly code = "remote_pairing_unavailable" as const;
Expand Down Expand Up @@ -271,6 +280,18 @@ export class DesktopIpcRegistry {
openSettings: this.runtime.drainPendingUpdateOpen?.() ?? false,
});
});
this.ipc.handle("app:projection-cache:load", async (event, payload: unknown): Promise<ProjectionCacheLoadResult> => {
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<ProjectionCacheSaveResult> => {
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);
});
Expand All @@ -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;
}
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<IpcRuntime["projectionCache"]>;

export function appserverLogsDirectory(
homeDirectory: string,
platform: NodeJS.Platform = process.platform,
Expand Down Expand Up @@ -48,6 +50,7 @@ export interface DesktopLifecycleOptions {
readonly createRemoteRegistry?: () => RemoteTargetRegistry;
readonly createCredentials?: () => DeviceCredentialStore | undefined;
readonly createLocalProfileRegistry?: () => LocalProfileRegistry;
readonly createProjectionCache?: () => ProjectionCacheRuntime;
readonly discoverExecutable?: () => Promise<string | undefined>;
readonly probeAppserver?: (executable: string) => Promise<boolean>;
readonly createServiceManager?: (options: Parameters<typeof createAppserverServiceManager>[0]) => ServiceManager;
Expand Down Expand Up @@ -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<string | undefined>;
private readonly serviceFactory: (options: Parameters<typeof createAppserverServiceManager>[0]) => ServiceManager;
private readonly speechServiceFactory: (options: { readonly discoverExecutable: () => Promise<string | undefined> }) => DesktopSpeechService;
Expand All @@ -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<string, ServiceManager>();
private serviceAvailabilityIssue: ServiceAvailabilityIssue | undefined;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() });
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }),
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
decodeDesktopEvent,
decodeDesktopUpdateRendererReadyResult,
decodeDesktopUpdateState,
decodeProjectionCacheLoadResult,
decodeProjectionCacheSaveResult,
type BootstrapResult,
type CommandRequest,
type CommandResult,
Expand Down Expand Up @@ -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 {
Expand All @@ -62,6 +67,8 @@ export interface OmpShellBridge {
readonly serviceStop: () => Promise<ServiceActionResult>;
readonly serviceRestart: () => Promise<ServiceActionResult>;
readonly serviceUninstall: () => Promise<ServiceActionResult>;
readonly loadProjectionCache: () => Promise<ProjectionCacheLoadResult>;
readonly saveProjectionCache: (request: ProjectionCacheSaveRequest) => Promise<ProjectionCacheSaveResult>;
readonly getUpdateState: () => Promise<DesktopUpdateState>;
readonly checkForUpdate: () => Promise<DesktopUpdateState>;
readonly downloadUpdate: () => Promise<DesktopUpdateState>;
Expand All @@ -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;
}
Expand All @@ -92,6 +100,13 @@ function invoke<C extends "omp:bootstrap" | "omp:connect" | "omp:disconnect" | "
return ipcRenderer.invoke(channel, { channel, payload }) as Promise<R>;
}

function invokeProjectionCache<R>(
channel: "app:projection-cache:load" | "app:projection-cache:save",
payload: unknown,
): Promise<R> {
return ipcRenderer.invoke(channel, { channel, payload }) as Promise<R>;
}

type SubscriptionPayload<C> = C extends "omp:server-frame"
? RendererServerFrameEvent
: C extends "omp:connection-state"
Expand Down Expand Up @@ -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<unknown>("app:projection-cache:load", {}).then(decodeProjectionCacheLoadResult),
saveProjectionCache: (request) =>
invokeProjectionCache<unknown>("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),
Expand Down
97 changes: 97 additions & 0 deletions apps/desktop/src/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +27,10 @@ interface RegistryState { readonly targets: Record<string, PairedHostRecord>; }
interface VaultState { readonly ciphertext: Record<string, string>; }
interface RemoteRegistryState { readonly version: 1; readonly records: readonly RemoteTargetRecord[]; }
interface CredentialCiphertextState { readonly version: 1; readonly ciphertexts: Record<string, string>; }
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 {
Expand All @@ -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<string, unknown>;
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<void> }, operation: () => void): Promise<void> {
const result = queue.tail.then(operation, operation);
queue.tail = result.then(() => undefined, () => undefined);
Expand Down Expand Up @@ -130,6 +162,71 @@ export class ElectronCredentialCiphertextStore implements CredentialCiphertextSt
}
}

export class ElectronProjectionCacheStore {
private readonly store: ElectronStore<ProjectionCacheCiphertextState>;
private readonly writeQueue = { tail: Promise.resolve() };
private readonly safeStorage: SafeStorageAdapter;
constructor(
store = new ElectronStore<ProjectionCacheCiphertextState>({
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<ProjectionCacheSaveResult> {
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<LocalProfileRegistryState>;
private readonly writeQueue = { tail: Promise.resolve() };
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/test/ipc-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading
Loading