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 fe11e19f0e0..df8f2fb8a07 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -594,6 +594,7 @@ const api = { throw new Error('Failed to stage renderer state before unload.') } }, + awaitBeforeUnloadCheckpoint, awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), prepareTerminalStartupRestoration: (): Promise => @@ -5328,6 +5329,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..2b7b09551e0 --- /dev/null +++ b/src/preload/preload-api-implementation-completeness.ts @@ -0,0 +1,47 @@ +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 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< + UnimplementedMembers +>