From bc80bc59fb962534f6ff6bea70d4e4624ca95fd4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 08:42:02 -0700 Subject: [PATCH] fix: retire extension iframe event listeners with native frame (#35011) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../browser-extension-api-host.test.ts | 224 ++++++++++++++++-- .../browser-extension-api-namespaces.test.ts | 7 +- .../extensions/browser-extension-contexts.ts | 62 +++-- .../browser-extension-frame-lifetime.ts | 90 +++++++ src/preload/browser-extension-api-surface.ts | 1 + .../browser-extension-frame-lifetime.test.ts | 31 +++ .../browser-extension-frame-lifetime.ts | 18 ++ src/preload/browser-extension-preload.ts | 2 + src/shared/browser-extension-channels.ts | 3 + 9 files changed, 384 insertions(+), 54 deletions(-) create mode 100644 src/main/browser/extensions/browser-extension-frame-lifetime.ts create mode 100644 src/preload/browser-extension-frame-lifetime.test.ts create mode 100644 src/preload/browser-extension-frame-lifetime.ts diff --git a/src/main/browser/extensions/browser-extension-api-host.test.ts b/src/main/browser/extensions/browser-extension-api-host.test.ts index cc05e2c56d1..8546b9e99dc 100644 --- a/src/main/browser/extensions/browser-extension-api-host.test.ts +++ b/src/main/browser/extensions/browser-extension-api-host.test.ts @@ -5,7 +5,10 @@ import { join } from 'node:path' import { beforeAll, describe, expect, it, vi } from 'vitest' import { z } from 'zod' +type PortStub = EventEmitter & { close: ReturnType; start: ReturnType } + const mocks = vi.hoisted(() => ({ + channels: new Array<{ port1: PortStub; port2: PortStub }>(), userData: '', prompt: vi.fn<() => Promise>(), handlers: new Map< @@ -18,22 +21,37 @@ const mocks = vi.hoisted(() => ({ >() })) -vi.mock('electron', () => ({ - app: { on: vi.fn(), getPath: () => mocks.userData }, - BrowserWindow: vi.fn(), - session: { fromPartition: vi.fn() }, - webContents: { fromId: vi.fn() }, - ipcMain: { - removeHandler: (channel: string) => mocks.handlers.delete(channel), - handle: ( - channel: string, - handler: (event: unknown, request: unknown, userGesture?: boolean) => Promise - ) => mocks.handlers.set(channel, handler) - }, - webFrameMain: { - fromId: (processId: number, routingId: number) => mocks.frames.get(`${processId}:${routingId}`) +vi.mock('electron', async () => { + const { EventEmitter } = await import('node:events') + class Port extends EventEmitter { + close = vi.fn(() => this.emit('close')) + start = vi.fn() } -})) + return { + MessageChannelMain: class { + port1 = new Port() + port2 = new Port() + constructor() { + mocks.channels.push(this) + } + }, + app: { on: vi.fn(), getPath: () => mocks.userData }, + BrowserWindow: vi.fn(), + session: { fromPartition: vi.fn() }, + webContents: { fromId: vi.fn() }, + ipcMain: { + removeHandler: (channel: string) => mocks.handlers.delete(channel), + handle: ( + channel: string, + handler: (event: unknown, request: unknown, userGesture?: boolean) => Promise + ) => mocks.handlers.set(channel, handler) + }, + webFrameMain: { + fromId: (processId: number, routingId: number) => + mocks.frames.get(`${processId}:${routingId}`) + } + } +}) vi.mock('../browser-manager', () => ({ browserManager: {} })) vi.mock('../../ipc/browser-tab-registration-wait', () => ({ waitForTabRegistration: vi.fn() })) vi.mock('../../runtime/browser/browser-tab-renderer-create', () => ({ @@ -48,8 +66,11 @@ vi.mock('./browser-extension-permission-prompt', () => ({ })) mocks.userData = mkdtempSync(join(tmpdir(), 'orca-extension-api-host-')) -const { BROWSER_EXTENSION_EVENT_CHANNEL, BROWSER_EXTENSION_INVOKE_CHANNEL } = - await import('../../../shared/browser-extension-channels') +const { + BROWSER_EXTENSION_EVENT_CHANNEL, + BROWSER_EXTENSION_INVOKE_CHANNEL, + BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL +} = await import('../../../shared/browser-extension-channels') const surface = await import('../../../preload/browser-extension-api-surface') const registry = await import('./browser-extension-namespace-registry') const host = await import('./browser-extension-api-host') @@ -87,7 +108,15 @@ function frameEvent( // Node reports "null" for chrome-extension: URLs; Chromium reports the real extension origin. origin = url.split('/').slice(0, 3).join('/') ) { - const frame = { url, origin, processId, routingId, isDestroyed: () => false, send: vi.fn() } + const frame = { + url, + origin, + processId, + routingId, + isDestroyed: () => false, + send: vi.fn(), + postMessage: vi.fn() + } mocks.frames.set(`${processId}:${routingId}`, frame) return { frame, @@ -133,6 +162,9 @@ describe('preload API surface', () => { it('pins the preload channel copies to the ones main listens on', () => { expect(surface.PRELOAD_BROWSER_EXTENSION_INVOKE_CHANNEL).toBe(BROWSER_EXTENSION_INVOKE_CHANNEL) expect(surface.PRELOAD_BROWSER_EXTENSION_EVENT_CHANNEL).toBe(BROWSER_EXTENSION_EVENT_CHANNEL) + expect(surface.PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL).toBe( + BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL + ) }) it('installs exactly what main registers, under the same permission', () => { @@ -394,3 +426,159 @@ it('retires the current host after extension support restarts in the same docume expect(contexts.browserExtensionEventHasListener(isolated, 'probe', 'onPing')).toBe(false) host.forgetBrowserExtensionContexts(isolated) }) + +describe('extension subframe listener retirement', () => { + const listen = { type: 'listen', namespace: 'probe', event: 'onPing', active: true } + + function isolatedSession() { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: host calls use the copied session members already exercised by the host fixtures. + const sess = { ...extensionSession } as unknown as Electron.Session + host.attachBrowserExtensionApiHostToSession(sess, 'subframes') + const state = contexts.getHostSessionState(sess) + if (!state) { + throw new Error('Missing session host') + } + return { sess, state } + } + + function channel() { + const current = mocks.channels.at(-1) + if (!current) { + throw new Error('Listening frame did not acquire a native port') + } + return current + } + + it('retires a detached filtered frame without dispatch or document navigation', async () => { + const { sess, state } = isolatedSession() + const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 20, 1) + await invoke(page.event, { ...listen, filters: [{ urls: ['https://never.invalid/*'] }] }) + expect(contexts.browserExtensionEventHasListener(sess, 'probe', 'onPing')).toBe(true) + channel().port1.emit('close') + expect(state.frames.size).toBe(0) + expect(contexts.browserExtensionEventHasListener(sess, 'probe', 'onPing')).toBe(false) + host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', ['removed']) + expect(page.frame.send).not.toHaveBeenCalled() + host.forgetBrowserExtensionContexts(sess) + }) + + it('shares one port across listeners and replaces it after final removal', async () => { + const { sess, state } = isolatedSession() + const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 21, 1) + await invoke(page.event, listen) + const first = channel() + const lateClose = first.port1.listeners('close')[0] + await invoke(page.event, listen) + await invoke(page.event, { ...listen, namespace: 'tabs', event: 'onCreated' }) + expect(page.frame.postMessage).toHaveBeenCalledTimes(1) + await invoke(page.event, { ...listen, active: false }) + expect(state.frames.size).toBe(1) + expect(first.port1.close).not.toHaveBeenCalled() + await invoke(page.event, { ...listen, namespace: 'tabs', event: 'onCreated', active: false }) + expect(state.frames.size).toBe(0) + expect(first.port1.close).toHaveBeenCalledTimes(1) + expect(first.port1.listenerCount('close')).toBe(0) + await invoke(page.event, listen) + expect(channel()).not.toBe(first) + lateClose?.() + host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', ['successor']) + expect(page.frame.send).toHaveBeenCalledTimes(1) + expect(state.frames.size).toBe(1) + host.forgetBrowserExtensionContexts(sess) + }) + + it('preserves sibling frames, windows, sessions and worker listeners', async () => { + const { sess, state } = isolatedSession() + const elsewhere = isolatedSession() + const removed = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 22, 1) + const sibling = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 22, 2) + sibling.event.sender = removed.event.sender + const window = frameEvent(`chrome-extension://${EXTENSION_ID}/page.html`, sess, 22, 3) + window.event.sender.id = 78 + const other = frameEvent(`chrome-extension://${EXTENSION_ID}/page.html`, elsewhere.sess, 23, 1) + await invoke(removed.event, listen) + const owner = channel() + await invoke(sibling.event, listen) + await invoke(window.event, listen) + await invoke(other.event, listen) + const worker = contexts.ensureWorkerContext( + state, + EXTENSION_ID, + `chrome-extension://${EXTENSION_ID}/` + ) + worker.events.add('probe.onPing') + worker.stream = { send: vi.fn(), close: vi.fn() } + owner.port1.emit('close') + host.emitBrowserExtensionEvent({}, 'probe', 'onPing', ['survivors']) + expect(removed.frame.send).not.toHaveBeenCalled() + for (const frame of [sibling.frame, window.frame, other.frame]) { + expect(frame.send).toHaveBeenCalledTimes(1) + } + expect(worker.stream.send).toHaveBeenCalledTimes(1) + expect(worker.stream.close).not.toHaveBeenCalled() + expect(state.frames.size).toBe(2) + expect(elsewhere.state.frames.size).toBe(1) + host.forgetBrowserExtensionContexts(sess) + host.forgetBrowserExtensionContexts(elsewhere.sess) + }) + + it.each([ + 'did-navigate', + 'render-process-gone', + 'destroyed', + 'extension-unloaded', + 'host-forgotten', + 'invalid-frame', + 'failed-send' + ])('closes a frame port exactly once on %s', async (retirement) => { + const { sess, state } = isolatedSession() + const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 24, 1) + await invoke(page.event, listen) + const owner = channel() + if (retirement === 'extension-unloaded') { + contexts.forgetExtensionContexts(sess, EXTENSION_ID) + } else if (retirement === 'host-forgotten') { + host.forgetBrowserExtensionContexts(sess) + } else if (retirement === 'invalid-frame') { + mocks.frames.delete('24:1') + host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', []) + } else if (retirement === 'failed-send') { + page.frame.send.mockImplementation(() => { + throw new Error('Frame retired') + }) + host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', []) + } else { + page.event.sender.emit(retirement) + } + expect(state.frames.size).toBe(0) + expect(owner.port1.close).toHaveBeenCalledTimes(1) + expect(owner.port1.listenerCount('close')).toBe(0) + page.event.sender.emit('destroyed') + host.forgetBrowserExtensionContexts(sess) + expect(owner.port1.close).toHaveBeenCalledTimes(1) + }) + + it('releases both endpoints and the registry when frame transfer fails', async () => { + const { sess, state } = isolatedSession() + const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 25, 1) + page.frame.postMessage.mockImplementation(() => { + throw new Error('Frame retired before transfer') + }) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await expect(invoke(page.event, listen)).resolves.toEqual({ + ok: false, + error: 'Frame retired before transfer' + }) + const owner = channel() + expect(state.frames.size).toBe(0) + expect(contexts.browserExtensionEventHasListener(sess, 'probe', 'onPing')).toBe(false) + expect(owner.port1.close).toHaveBeenCalledTimes(1) + expect(owner.port2.close).toHaveBeenCalledTimes(1) + expect(owner.port1.listenerCount('close')).toBe(0) + } finally { + warning.mockRestore() + host.forgetBrowserExtensionContexts(sess) + } + }) +}) diff --git a/src/main/browser/extensions/browser-extension-api-namespaces.test.ts b/src/main/browser/extensions/browser-extension-api-namespaces.test.ts index d73cd3d19cd..8be68b3c1e6 100644 --- a/src/main/browser/extensions/browser-extension-api-namespaces.test.ts +++ b/src/main/browser/extensions/browser-extension-api-namespaces.test.ts @@ -19,6 +19,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ + MessageChannelMain: class { + port1 = Object.assign(new EventEmitter(), { start: vi.fn(), close: vi.fn() }) + port2 = { close: vi.fn() } + }, app: { on: vi.fn(), getPath: () => mocks.userData }, BrowserWindow: vi.fn(), Notification: { isSupported: () => false }, @@ -819,7 +823,8 @@ async function listenFrame(extensionId: string, namespace: string, event: string processId: 3, routingId, isDestroyed: () => false, - send: vi.fn() + send: vi.fn(), + postMessage: vi.fn() } mocks.frames.set(`3:${routingId}`, frame) await mocks.handlers.get(BROWSER_EXTENSION_INVOKE_CHANNEL)?.( diff --git a/src/main/browser/extensions/browser-extension-contexts.ts b/src/main/browser/extensions/browser-extension-contexts.ts index 4cf6ffed3f4..ffef07302ec 100644 --- a/src/main/browser/extensions/browser-extension-contexts.ts +++ b/src/main/browser/extensions/browser-extension-contexts.ts @@ -1,5 +1,10 @@ -import { abortWhenRendererGone } from '../../ipc/renderer-lifetime-abort' import { forgetBrowserExtensionContextLifetime } from './browser-extension-context-lifetime' +import { + createBrowserExtensionFrameContext, + forgetBrowserExtensionFrame, + trackBrowserExtensionDocument, + type FrameContext +} from './browser-extension-frame-lifetime' import { webFrameMain } from 'electron' import { BROWSER_EXTENSION_EVENT_CHANNEL, @@ -18,15 +23,6 @@ import { type WorkerEventStream } from './browser-extension-worker-event-stream' -type FrameContext = { - extensionId: string - webContentsId: number - processId: number - routingId: number - events: Set - filters: ListenerFilters -} - /** Event key → every listener's filter; absent when some listener takes everything. */ export type ListenerFilters = Map @@ -50,7 +46,6 @@ export type HostSessionState = { } const stateBySession = new Map() -const trackedWebContents = new WeakSet() export function eventKey(namespace: string, event: string): string { return `${namespace}.${event}` @@ -73,8 +68,14 @@ export function ensureHostSessionState( } export function forgetHostSession(sess: Electron.Session): void { - forgetBrowserExtensionContextLifetime(stateBySession.get(sess)) - for (const context of stateBySession.get(sess)?.workers.values() ?? []) { + const state = stateBySession.get(sess) + forgetBrowserExtensionContextLifetime(state) + if (state) { + for (const [key, context] of state.frames) { + forgetBrowserExtensionFrame(state.frames, key, context) + } + } + for (const context of state?.workers.values() ?? []) { detachWorkerEventStream(context) } stateBySession.delete(sess) @@ -93,7 +94,7 @@ export function forgetExtensionContexts(sess: Electron.Session, extensionId: str state.workers.delete(extensionId) for (const [key, frame] of state.frames) { if (frame.extensionId === extensionId) { - state.frames.delete(key) + forgetBrowserExtensionFrame(state.frames, key, frame) } } } @@ -135,15 +136,13 @@ export function setFrameListening( if (!active) { return } - context = { + context = createBrowserExtensionFrameContext( + state.frames, + frameKey, extensionId, - webContentsId: webContents.id, - processId: frame.processId, - routingId: frame.routingId, - events: new Set(), - filters: new Map() - } - state.frames.set(frameKey, context) + webContents.id, + frame + ) } if (active) { context.events.add(key) @@ -151,18 +150,11 @@ export function setFrameListening( context.events.delete(key) } setListenerFilters(context.filters, key, active ? filters : undefined) - if (!trackedWebContents.has(webContents)) { - trackedWebContents.add(webContents) - const { session: webSession, id: webContentsId } = webContents - abortWhenRendererGone(webContents).signal.addEventListener('abort', () => { - trackedWebContents.delete(webContents) - stateBySession.get(webSession)?.frames.forEach((candidate, candidateKey, frames) => { - if (candidate.webContentsId === webContentsId) { - frames.delete(candidateKey) - } - }) - }) + if (context.events.size === 0) { + forgetBrowserExtensionFrame(state.frames, frameKey, context) } + const webSession = webContents.session + trackBrowserExtensionDocument(webContents, () => stateBySession.get(webSession)?.frames) } export function setListenerFilters( @@ -240,13 +232,13 @@ function deliverToFrames( const frame = webFrameMain.fromId(context.processId, context.routingId) // Why: a frame that navigated away from the extension must stop receiving its events. if (!frame || frame.isDestroyed() || !frame.url.startsWith(origin)) { - state.frames.delete(frameKey) + forgetBrowserExtensionFrame(state.frames, frameKey, context) continue } try { frame.send(BROWSER_EXTENSION_EVENT_CHANNEL, payload) } catch { - state.frames.delete(frameKey) + forgetBrowserExtensionFrame(state.frames, frameKey, context) } } } diff --git a/src/main/browser/extensions/browser-extension-frame-lifetime.ts b/src/main/browser/extensions/browser-extension-frame-lifetime.ts new file mode 100644 index 00000000000..65bffcbae53 --- /dev/null +++ b/src/main/browser/extensions/browser-extension-frame-lifetime.ts @@ -0,0 +1,90 @@ +import { MessageChannelMain } from 'electron' +import { BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL } from '../../../shared/browser-extension-channels' +import { abortWhenRendererGone } from '../../ipc/renderer-lifetime-abort' + +export type FrameContext = { + extensionId: string + webContentsId: number + processId: number + routingId: number + events: Set + filters: Map + dispose: () => void +} + +const trackedWebContents = new WeakSet() + +export function forgetBrowserExtensionFrame( + frames: Map, + key: string, + context: FrameContext +): void { + if (frames.get(key) !== context) { + return + } + frames.delete(key) + context.dispose() +} + +export function createBrowserExtensionFrameContext( + frames: Map, + key: string, + extensionId: string, + webContentsId: number, + frame: Electron.WebFrameMain +): FrameContext { + const { port1, port2 } = new MessageChannelMain() + let disposed = false + const retire = (): void => forgetBrowserExtensionFrame(frames, key, context) + const context: FrameContext = { + extensionId, + webContentsId, + processId: frame.processId, + routingId: frame.routingId, + events: new Set(), + filters: new Map(), + dispose: () => { + if (disposed) { + return + } + disposed = true + port1.removeListener('close', retire) + port1.close() + } + } + frames.set(key, context) + port1.once('close', retire) + try { + port1.start() + // Native frame teardown closes the peer without needing an unload callback. + frame.postMessage(BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, null, [port2]) + } catch (error) { + forgetBrowserExtensionFrame(frames, key, context) + port2.close() + throw error + } + return context +} + +export function trackBrowserExtensionDocument( + webContents: Electron.WebContents, + currentFrames: () => Map | undefined +): void { + if (trackedWebContents.has(webContents)) { + return + } + trackedWebContents.add(webContents) + const webContentsId = webContents.id + abortWhenRendererGone(webContents).signal.addEventListener('abort', () => { + trackedWebContents.delete(webContents) + const frames = currentFrames() + if (!frames) { + return + } + for (const [key, context] of frames) { + if (context.webContentsId === webContentsId) { + forgetBrowserExtensionFrame(frames, key, context) + } + } + }) +} diff --git a/src/preload/browser-extension-api-surface.ts b/src/preload/browser-extension-api-surface.ts index d8e5e1276f9..7e5385df7a9 100644 --- a/src/preload/browser-extension-api-surface.ts +++ b/src/preload/browser-extension-api-surface.ts @@ -9,6 +9,7 @@ export const PRELOAD_BROWSER_EXTENSION_INVOKE_CHANNEL = 'browserExtension:invoke' export const PRELOAD_BROWSER_EXTENSION_EVENT_CHANNEL = 'browserExtension:event' +export const PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL = 'browserExtension:frameLifetime' export type BrowserExtensionSurfaceMethod = { /** diff --git a/src/preload/browser-extension-frame-lifetime.test.ts b/src/preload/browser-extension-frame-lifetime.test.ts new file mode 100644 index 00000000000..94c8b745993 --- /dev/null +++ b/src/preload/browser-extension-frame-lifetime.test.ts @@ -0,0 +1,31 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { installBrowserExtensionFrameLifetime } from './browser-extension-frame-lifetime' +import { PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL } from './browser-extension-api-surface' + +describe('extension frame lifetime receiver', () => { + it('retains the current endpoint and closes replaced and surplus endpoints', () => { + const ipcRenderer = Object.assign(new EventEmitter(), { + invoke: vi.fn(), + postMessage: vi.fn(), + send: vi.fn(), + sendSync: vi.fn(), + sendToHost: vi.fn() + }) + installBrowserExtensionFrameLifetime(ipcRenderer) + const first = { close: vi.fn() } + const second = { close: vi.fn() } + const surplus = { close: vi.fn() } + ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, { ports: [first] }) + ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, { ports: [] }) + expect(first.close).not.toHaveBeenCalled() + ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, { + ports: [second, surplus] + }) + expect(first.close).toHaveBeenCalledTimes(1) + expect(second.close).not.toHaveBeenCalled() + expect(surplus.close).toHaveBeenCalledTimes(1) + ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, { ports: [] }) + expect(second.close).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/browser-extension-frame-lifetime.ts b/src/preload/browser-extension-frame-lifetime.ts new file mode 100644 index 00000000000..785b6363fcf --- /dev/null +++ b/src/preload/browser-extension-frame-lifetime.ts @@ -0,0 +1,18 @@ +import type { IpcRenderer } from 'electron' +import { PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL } from './browser-extension-api-surface' + +export function installBrowserExtensionFrameLifetime(ipcRenderer: IpcRenderer): void { + let retainedPort: MessagePort | undefined + ipcRenderer.on(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, (event) => { + const [port, ...surplus] = event.ports + for (const extra of surplus) { + extra.close() + } + if (!port) { + return + } + retainedPort?.close() + // Retain the isolated-world endpoint until its document is destroyed or it is replaced. + retainedPort = port + }) +} diff --git a/src/preload/browser-extension-preload.ts b/src/preload/browser-extension-preload.ts index a707bc1f70f..d0f28a68532 100644 --- a/src/preload/browser-extension-preload.ts +++ b/src/preload/browser-extension-preload.ts @@ -17,6 +17,7 @@ import { passesBrowserExtensionEventFilter } from './browser-extension-event-fil import { installBrowserExtensionInertApis } from './browser-extension-inert-apis' import { installContentScriptRelayFilter } from './browser-extension-content-script-relay' import { installBrowserExtensionNativeMessaging } from './browser-extension-native-messaging' +import { installBrowserExtensionFrameLifetime } from './browser-extension-frame-lifetime' import { installBrowserExtensionStorageRelay } from './browser-extension-storage-relay' import { installBrowserExtensionStorageSync, @@ -68,6 +69,7 @@ async function invoke(request: unknown): Promise { } function installExtensionContextApis(): void { + installBrowserExtensionFrameLifetime(ipcRenderer) const dispatchers: ((namespace: string, event: string, args: unknown[]) => void)[] = [] ipcRenderer.on(PRELOAD_BROWSER_EXTENSION_EVENT_CHANNEL, (_event, payload: unknown) => { if ( diff --git a/src/shared/browser-extension-channels.ts b/src/shared/browser-extension-channels.ts index 5ab89ed9744..56f5953f568 100644 --- a/src/shared/browser-extension-channels.ts +++ b/src/shared/browser-extension-channels.ts @@ -12,6 +12,9 @@ export const BROWSER_EXTENSION_INVOKE_CHANNEL = 'browserExtension:invoke' /** Main → extension context, only for events that context registered. Payload: BrowserExtensionEventPayload. */ export const BROWSER_EXTENSION_EVENT_CHANNEL = 'browserExtension:event' +/** Main → listening frame. The transferred port owns the frame's listener registrations. */ +export const BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL = 'browserExtension:frameLifetime' + export type BrowserExtensionInvokeRequest = | { type: 'call'; namespace: string; method: string; args: unknown[] } // Why: lets main skip contexts with no listener and restart a stopped worker only when it listens.