From 3910fc181c2834fdfeefe84bd100c6339329aae2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:16:09 -0700 Subject: [PATCH 1/2] fix(preload): enforce the bridge contract the renderer already trusts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api` is handed to `contextBridge.exposeInMainWorld`, which takes `any`, while the renderer types every call as `PreloadApi`. Nothing compared the two, so a member declared in the contract and never implemented reached the renderer as `undefined`. `app.awaitBeforeUnloadCheckpoint` was one: declared required on `AppApi`, implemented by the web shim, and joined by the lazy-chunk recovery reload through `?.()`. On desktop the optional chain fell through to `Promise.resolve()`, so the reload never waited for the durable write and proceeded over buffers whose backup may have failed — the one thing that path's own comment says it must never do. Expose the member so a failed checkpoint refuses the reload, as it already does for relaunch and restart. Add a compile-time completeness check so the next omission is a build error rather than a silent default. Names only: `ipcRenderer.invoke` returns `Promise`, so full assignability reports pre-existing return-type widenings the call sites already cast; presence is the half that fails silently. --- .../app-restart-checkpoint-routing.test.ts | 14 +++++++++ src/preload/index.ts | 4 +++ ...preload-api-implementation-completeness.ts | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 src/preload/preload-api-implementation-completeness.ts diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index 19eb6948c9a..e809d421b93 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -92,6 +92,20 @@ describe('native preload destructive app actions', () => { }) } + // Why: the lazy-chunk recovery reload joins this member through `?.()`, so leaving + // it off the bridge reads as "checkpoint fine" and reloads over unsaved buffers. + it('exposes the durable checkpoint join the recovery reload refuses on', async () => { + const api = await loadApi() + invoke.mockImplementation(async (channel: string) => + channel === 'app:await-before-unload-checkpoint' ? { ok: false } : undefined + ) + + expect(typeof api.app.awaitBeforeUnloadCheckpoint).toBe('function') + await expect(api.app.awaitBeforeUnloadCheckpoint()).rejects.toThrow( + 'Failed to persist renderer state before unload.' + ) + }) + it('preserves both macOS keyboard preload adapters', async () => { const api = await loadApi() invoke.mockResolvedValue(undefined) diff --git a/src/preload/index.ts b/src/preload/index.ts index a474eb7560e..bb3c8854c19 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -589,6 +589,7 @@ const api = { throw new Error('Failed to stage renderer state before unload.') } }, + awaitBeforeUnloadCheckpoint, awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), prepareTerminalStartupRestoration: (): Promise => @@ -5309,6 +5310,9 @@ const api = { } } +/** The bridge object the renderer receives; checked against PreloadApi in preload-api-implementation-completeness.ts. */ +export type PreloadApiImplementation = typeof api + // Expose Electron APIs via contextBridge when context-isolated, otherwise attach to the DOM global. if (process.contextIsolated) { try { diff --git a/src/preload/preload-api-implementation-completeness.ts b/src/preload/preload-api-implementation-completeness.ts new file mode 100644 index 00000000000..b7dcc8256ca --- /dev/null +++ b/src/preload/preload-api-implementation-completeness.ts @@ -0,0 +1,31 @@ +import type { PreloadApi } from './api-types' +import type { PreloadApiImplementation } from './index' + +/** + * Compile-time proof that the preload bridge implements every member the + * renderer is allowed to call. + * + * Why this is not redundant with the annotations in index.ts: `api` is handed to + * `contextBridge.exposeInMainWorld`, which takes `any`, and the renderer types + * every call as `PreloadApi` (api-types.ts). Nothing compared the two, so a + * member declared in `PreloadApi` and never implemented reached the renderer as + * `undefined` — and callers guard with `?.()`, which turns the absence into a + * confident default rather than a failure. + * + * Names only, deliberately: `ipcRenderer.invoke` returns `Promise`, so + * full assignability reports pre-existing return-type widenings that the call + * sites already cast. Presence is the half that fails silently. + */ +type AssertNoMissingMembers = Missing + +type UnimplementedGroups = Exclude + +type UnimplementedMembers = { + [Group in keyof PreloadApi & keyof PreloadApiImplementation]: Exclude< + keyof PreloadApi[Group], + keyof PreloadApiImplementation[Group] + > +}[keyof PreloadApi & keyof PreloadApiImplementation] + +export type PreloadApiGroupsAreImplemented = AssertNoMissingMembers +export type PreloadApiMembersAreImplemented = AssertNoMissingMembers From 9bf21f1be87ab02421cfb168a9b8793d734b4338 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 29 Aug 2026 14:45:52 -0700 Subject: [PATCH 2/2] fix(preload): recurse the bridge-contract guard through nested API records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completeness check compared two levels — groups, then members within a group — so any member that is itself a record of calls went unchecked below its own key. `pty.management` is the one such container today, and its five leaves (listSessions, killAll, killOne, restart, macTccAttribution) were unguarded: deleting one from the implementation still typechecked clean, and the renderer's optional-call guards turn that absence into a confident default. Recurse instead, treating callable members as terminals so overload objects like `worktrees.listDetected` stay leaves. Violations now report the dotted path. --- ...preload-api-implementation-completeness.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/preload/preload-api-implementation-completeness.ts b/src/preload/preload-api-implementation-completeness.ts index b7dcc8256ca..2b7b09551e0 100644 --- a/src/preload/preload-api-implementation-completeness.ts +++ b/src/preload/preload-api-implementation-completeness.ts @@ -20,12 +20,28 @@ type AssertNoMissingMembers = Missing type UnimplementedGroups = Exclude -type UnimplementedMembers = { - [Group in keyof PreloadApi & keyof PreloadApiImplementation]: Exclude< - keyof PreloadApi[Group], - keyof PreloadApiImplementation[Group] - > -}[keyof PreloadApi & keyof PreloadApiImplementation] +type ObjectMembers = T extends object + ? T extends (...args: never[]) => unknown + ? never + : T + : never + +/** Recurses through nested API records while treating callable leaves as terminals. */ +type UnimplementedMembers = + | `${Prefix}${Extract, string>}` + | { + [Key in keyof Expected & keyof Actual]: ObjectMembers extends never + ? never + : ObjectMembers extends never + ? `${Prefix}${Extract}` + : UnimplementedMembers< + ObjectMembers, + ObjectMembers, + `${Prefix}${Extract}.` + > + }[keyof Expected & keyof Actual] export type PreloadApiGroupsAreImplemented = AssertNoMissingMembers -export type PreloadApiMembersAreImplemented = AssertNoMissingMembers +export type PreloadApiMembersAreImplemented = AssertNoMissingMembers< + UnimplementedMembers +>