mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix: retire extension iframe event listeners with native frame (#35011)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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<typeof vi.fn>; start: ReturnType<typeof vi.fn> }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
channels: new Array<{ port1: PortStub; port2: PortStub }>(),
|
||||
userData: '',
|
||||
prompt: vi.fn<() => Promise<boolean>>(),
|
||||
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<unknown>
|
||||
) => 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<unknown>
|
||||
) => 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)?.(
|
||||
|
||||
@@ -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<string>
|
||||
filters: ListenerFilters
|
||||
}
|
||||
|
||||
/** Event key → every listener's filter; absent when some listener takes everything. */
|
||||
export type ListenerFilters = Map<string, unknown[]>
|
||||
|
||||
@@ -50,7 +46,6 @@ export type HostSessionState = {
|
||||
}
|
||||
|
||||
const stateBySession = new Map<Electron.Session, HostSessionState>()
|
||||
const trackedWebContents = new WeakSet<Electron.WebContents>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>
|
||||
filters: Map<string, unknown[]>
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const trackedWebContents = new WeakSet<Electron.WebContents>()
|
||||
|
||||
export function forgetBrowserExtensionFrame(
|
||||
frames: Map<string, FrameContext>,
|
||||
key: string,
|
||||
context: FrameContext
|
||||
): void {
|
||||
if (frames.get(key) !== context) {
|
||||
return
|
||||
}
|
||||
frames.delete(key)
|
||||
context.dispose()
|
||||
}
|
||||
|
||||
export function createBrowserExtensionFrameContext(
|
||||
frames: Map<string, FrameContext>,
|
||||
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<string, FrameContext> | 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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<BrowserExtensionInvokeReply> {
|
||||
}
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user