diff --git a/scripts/test-extension-lifecycle-sandbox.mjs b/scripts/test-extension-lifecycle-sandbox.mjs new file mode 100644 index 00000000..ae5470c5 --- /dev/null +++ b/scripts/test-extension-lifecycle-sandbox.mjs @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const extensionViewPath = path.join(root, 'src/renderer/src/ExtensionView.tsx'); + +function captureFromOptions(options) { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function createFakeEventTarget(label) { + const listenerIds = new WeakMap(); + const listeners = new Map(); + let nextListenerId = 1; + + function listenerId(listener) { + if (!listener || (typeof listener !== 'function' && typeof listener !== 'object')) { + return 'null'; + } + let id = listenerIds.get(listener); + if (!id) { + id = nextListenerId++; + listenerIds.set(listener, id); + } + return id; + } + + function key(type, listener, options) { + return `${String(type)}:${captureFromOptions(options)}:${listenerId(listener)}`; + } + + return { + label, + listeners, + addEventListener(type, listener, options) { + if (!listener) return; + listeners.set(key(type, listener, options), { type, listener, options }); + }, + removeEventListener(type, listener, options) { + listeners.delete(key(type, listener, options)); + }, + }; +} + +function createHostWindow() { + let nextTimerId = 1; + const intervals = new Set(); + const timeouts = new Set(); + const rafs = new Set(); + const windowTarget = createFakeEventTarget('window'); + const documentTarget = createFakeEventTarget('document'); + + const hostWindow = { + document: documentTarget, + navigator: { userAgent: 'SuperCmd lifecycle harness' }, + location: { href: 'supercmd://lifecycle-harness' }, + electron: { marker: 'electron-facade' }, + setInterval() { + const id = nextTimerId++; + intervals.add(id); + return id; + }, + clearInterval(id) { + intervals.delete(id); + }, + setTimeout() { + const id = nextTimerId++; + timeouts.add(id); + return id; + }, + clearTimeout(id) { + timeouts.delete(id); + }, + requestAnimationFrame() { + const id = nextTimerId++; + rafs.add(id); + return id; + }, + cancelAnimationFrame(id) { + rafs.delete(id); + }, + addEventListener: windowTarget.addEventListener, + removeEventListener: windowTarget.removeEventListener, + }; + + hostWindow.window = hostWindow; + hostWindow.self = hostWindow; + hostWindow.globalThis = hostWindow; + hostWindow.global = hostWindow; + hostWindow.top = hostWindow; + hostWindow.parent = hostWindow; + documentTarget.defaultView = hostWindow; + + return { + hostWindow, + hostDocument: documentTarget, + snapshot() { + return { + intervals: intervals.size, + timeouts: timeouts.size, + rafs: rafs.size, + windowListeners: windowTarget.listeners.size, + documentListeners: documentTarget.listeners.size, + total: + intervals.size + + timeouts.size + + rafs.size + + windowTarget.listeners.size + + documentTarget.listeners.size, + }; + }, + reset() { + intervals.clear(); + timeouts.clear(); + rafs.clear(); + windowTarget.listeners.clear(); + documentTarget.listeners.clear(); + }, + }; +} + +const host = createHostWindow(); +globalThis.window = host.hostWindow; +globalThis.document = host.hostDocument; +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, +}; + +function extractLifecycleSource() { + const source = fs.readFileSync(extensionViewPath, 'utf8'); + const start = source.indexOf('type ExtensionTimerHandle'); + const end = source.indexOf('/**\n * JS shim for the `swift:AppleReminders`', start); + assert.notEqual(start, -1, 'Could not locate lifecycle registry start marker'); + assert.notEqual(end, -1, 'Could not locate lifecycle registry end marker'); + return source + .slice(start, end) + .replace(/\bexport\s+(?=(interface|function)\b)/g, ''); +} + +function loadLifecycleHarness() { + const source = ` + ${extractLifecycleSource()} + globalThis.__extensionLifecycleHarness = { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, + }; + `; + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.None, + target: ts.ScriptTarget.ES2022, + }, + fileName: extensionViewPath, + }); + const sandbox = { + console, + window: host.hostWindow, + document: host.hostDocument, + }; + vm.createContext(sandbox); + vm.runInContext(transpiled.outputText, sandbox, { filename: extensionViewPath }); + return sandbox.__extensionLifecycleHarness; +} + +const { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, +} = loadLifecycleHarness(); + +const extensionCode = ` +const onResize = () => {}; +const onVisibilityChange = () => {}; +const intervalId = window.setInterval(() => {}, 60000); +window.addEventListener("resize", onResize); +window.document.addEventListener("visibilitychange", onVisibilityChange); + +exports.default = function LifecycleProbe() { + return { + intervalId, + compatibility: { + documentRead: Boolean(window.document) && window.document === document, + navigatorRead: window.navigator.userAgent, + locationRead: window.location.href, + electronRead: window.electron.marker, + globalWindowRead: globalThis.window === window, + defaultViewRead: document.defaultView === window + } + }; +}; +`; + +const EXTENSION_WRAPPER_ARGUMENTS = [ + 'exports', + 'require', + 'module', + '__filename', + '__dirname', + 'process', + 'Buffer', + 'global', + 'globalThis', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'window', + 'document', + 'navigator', + '__scDynamicImport', +]; + +function createBareTimerApi(registry) { + const setIntervalTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setInterval(callback, timeout, ...args); + registry.intervals.add(id); + registry.intervalClearers.set(id, host.hostWindow.clearInterval); + return id; + }; + const clearIntervalTracked = (id) => { + registry.intervals.delete(id); + registry.intervalClearers.delete(id); + host.hostWindow.clearInterval(id); + }; + const setTimeoutTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setTimeout(callback, timeout, ...args); + registry.timeouts.add(id); + registry.timeoutClearers.set(id, host.hostWindow.clearTimeout); + return id; + }; + const clearTimeoutTracked = (id) => { + registry.timeouts.delete(id); + registry.timeoutClearers.delete(id); + host.hostWindow.clearTimeout(id); + }; + const requestAnimationFrameTracked = (callback) => { + const id = host.hostWindow.requestAnimationFrame(callback); + registry.rafs.add(id); + registry.rafClearers.set(id, host.hostWindow.cancelAnimationFrame); + return id; + }; + const cancelAnimationFrameTracked = (id) => { + registry.rafs.delete(id); + registry.rafClearers.delete(id); + host.hostWindow.cancelAnimationFrame(id); + }; + return { + setImmediate: (callback, ...args) => setTimeoutTracked(() => callback(...args), 0), + clearImmediate: clearTimeoutTracked, + setInterval: setIntervalTracked, + clearInterval: clearIntervalTracked, + setTimeout: setTimeoutTracked, + clearTimeout: clearTimeoutTracked, + requestAnimationFrame: requestAnimationFrameTracked, + cancelAnimationFrame: cancelAnimationFrameTracked, + }; +} + +function runWrapper({ lifecycleScope, registry, scoped }) { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const bareTimerApi = scoped ? lifecycleScope : createBareTimerApi(registry); + const wrapperWindow = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapperDocument = scoped ? lifecycleScope.scopedDocument : host.hostDocument; + const globalObject = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapper = new Function(...EXTENSION_WRAPPER_ARGUMENTS, extensionCode); + + wrapper( + moduleExports, + () => ({}), + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalObject, + globalObject, + bareTimerApi.setImmediate, + bareTimerApi.clearImmediate, + bareTimerApi.setInterval, + bareTimerApi.clearInterval, + bareTimerApi.setTimeout, + bareTimerApi.clearTimeout, + bareTimerApi.requestAnimationFrame, + bareTimerApi.cancelAnimationFrame, + wrapperWindow, + wrapperDocument, + undefined, + async () => ({}), + ); + + return fakeModule.exports.default(); +} + +test('extension lifecycle sandbox cleanup', async (t) => { + await t.test('documents the pre-fix window API leak', () => { + host.reset(); + const registry = createTimerRegistry(); + const result = runWrapper({ registry, scoped: false }); + const beforeClear = host.snapshot(); + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.equal(registry.intervals.size, 0, 'legacy window.setInterval bypasses the bare timer registry'); + assert.equal(registry.eventListeners.size, 0, 'legacy window.addEventListener bypasses lifecycle cleanup'); + assert.equal(afterClear.total, beforeClear.total, 'legacy registry cleanup leaves window handles behind'); + + console.log('extension lifecycle leak before fix:', { beforeClear, afterClear }); + }); + + await t.test('cleans scoped window timers and listeners on unmount', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const result = runWrapper({ lifecycleScope, registry, scoped: true }); + const beforeClear = host.snapshot(); + const registryBeforeClear = { + intervals: registry.intervals.size, + eventListeners: registry.eventListeners.size, + }; + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.deepEqual(registryBeforeClear, { intervals: 1, eventListeners: 2 }); + assert.equal(afterClear.total, 0); + + console.log('extension lifecycle cleanup after fix:', { beforeClear, registryBeforeClear, afterClear }); + }); + + await t.test('cleans prior scoped handles before re-evaluation', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + + runWrapper({ lifecycleScope, registry, scoped: true }); + const firstEvaluation = host.snapshot(); + clearTimerRegistry(registry); + const afterReevaluationCleanup = host.snapshot(); + runWrapper({ lifecycleScope, registry, scoped: true }); + const secondEvaluation = host.snapshot(); + clearTimerRegistry(registry); + const afterUnmount = host.snapshot(); + + assert.equal(firstEvaluation.total, 3); + assert.equal(afterReevaluationCleanup.total, 0); + assert.equal(secondEvaluation.total, 3); + assert.equal(afterUnmount.total, 0); + + console.log('extension lifecycle re-evaluation cleanup:', { + firstEvaluation, + afterReevaluationCleanup, + secondEvaluation, + afterUnmount, + }); + }); +}); diff --git a/src/renderer/src/ExtensionView.tsx b/src/renderer/src/ExtensionView.tsx index 85643d80..96beb3e5 100644 --- a/src/renderer/src/ExtensionView.tsx +++ b/src/renderer/src/ExtensionView.tsx @@ -3362,29 +3362,341 @@ function ensureGlobals() { } } +type ExtensionTimerHandle = any; + +interface TrackedEventListener { + target: EventTarget; + type: string; + listener: EventListenerOrEventListenerObject; + options?: boolean | AddEventListenerOptions; + capture: boolean; +} + /** - * Per-ExtensionView registry of timer handles created by the extension's - * sandboxed setInterval/setTimeout/requestAnimationFrame. Cleared on unmount - * so a buggy extension (e.g. raycast/timers) cannot leak timers + retained - * fibers into the host renderer. + * Per-ExtensionView registry of lifecycle handles created by the extension's + * sandboxed timers and scoped event targets. Cleared on unmount so a buggy + * extension (e.g. raycast/timers) cannot leak DOM handles + retained fibers + * into the host renderer. */ export interface TimerRegistry { - intervals: Set; - timeouts: Set; - rafs: Set; + intervals: Set; + timeouts: Set; + rafs: Set; + intervalClearers: Map void>; + timeoutClearers: Map void>; + rafClearers: Map void>; + eventListeners: Set; } export function createTimerRegistry(): TimerRegistry { - return { intervals: new Set(), timeouts: new Set(), rafs: new Set() }; + return { + intervals: new Set(), + timeouts: new Set(), + rafs: new Set(), + intervalClearers: new Map(), + timeoutClearers: new Map(), + rafClearers: new Map(), + eventListeners: new Set(), + }; } export function clearTimerRegistry(registry: TimerRegistry): void { - registry.intervals.forEach((id) => window.clearInterval(id)); - registry.timeouts.forEach((id) => window.clearTimeout(id)); - registry.rafs.forEach((id) => window.cancelAnimationFrame(id)); + Array.from(registry.eventListeners).forEach((entry) => { + try { + entry.target.removeEventListener(entry.type, entry.listener, entry.options); + } catch {} + }); + Array.from(registry.intervals).forEach((id) => { + const clear = registry.intervalClearers.get(id) || window.clearInterval.bind(window); + clear(id); + }); + Array.from(registry.timeouts).forEach((id) => { + const clear = registry.timeoutClearers.get(id) || window.clearTimeout.bind(window); + clear(id); + }); + Array.from(registry.rafs).forEach((id) => { + const clear = registry.rafClearers.get(id) || window.cancelAnimationFrame.bind(window); + clear(id); + }); + registry.eventListeners.clear(); registry.intervals.clear(); registry.timeouts.clear(); registry.rafs.clear(); + registry.intervalClearers.clear(); + registry.timeoutClearers.clear(); + registry.rafClearers.clear(); +} + +function getEventListenerCapture(options?: boolean | AddEventListenerOptions): boolean { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function findTrackedEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): TrackedEventListener | null { + if (!registry || !listener) return null; + const capture = getEventListenerCapture(options); + for (const entry of registry.eventListeners) { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + return entry; + } + } + return null; +} + +function trackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): void { + if (!registry || !listener) return; + if (findTrackedEventListener(registry, target, type, listener, options)) return; + registry.eventListeners.add({ + target, + type, + listener, + options, + capture: getEventListenerCapture(options), + }); +} + +function untrackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions +): void { + if (!registry || !listener) return; + const capture = getEventListenerCapture(options); + Array.from(registry.eventListeners).forEach((entry) => { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + registry.eventListeners.delete(entry); + } + }); +} + +function shouldBindHostFunction(prop: string | symbol, value: Function): boolean { + if (typeof prop === 'string' && /^[A-Z]/.test(prop)) return false; + try { + if (/^class\s/.test(Function.prototype.toString.call(value))) return false; + } catch {} + return true; +} + +function getWindowPeer(hostWindow: any, scopedWindow: any, prop: 'top' | 'parent' | 'opener'): any { + try { + const value = hostWindow?.[prop]; + return value === hostWindow ? scopedWindow : value; + } catch { + return scopedWindow; + } +} + +function createScopedHostProxy( + host: any, + registry: TimerRegistry | undefined, + kind: 'window' | 'document', + getScopedWindow: () => any, + getScopedDocument: () => any, + timerApi?: { + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + } +): any { + const boundFunctions = new WeakMap(); + const target = {}; + + return new Proxy(target, { + get(_target, prop) { + if (kind === 'window') { + if (prop === 'window' || prop === 'self' || prop === 'globalThis' || prop === 'global') return getScopedWindow(); + if (prop === 'top' || prop === 'parent' || prop === 'opener') return getWindowPeer(host, getScopedWindow(), prop); + if (prop === 'document') return getScopedDocument(); + if (prop === 'setInterval') return timerApi?.setInterval; + if (prop === 'clearInterval') return timerApi?.clearInterval; + if (prop === 'setTimeout') return timerApi?.setTimeout; + if (prop === 'clearTimeout') return timerApi?.clearTimeout; + if (prop === 'requestAnimationFrame') return timerApi?.requestAnimationFrame; + if (prop === 'cancelAnimationFrame') return timerApi?.cancelAnimationFrame; + } else if (prop === 'defaultView') { + return getScopedWindow(); + } + + if (prop === 'addEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) => { + host.addEventListener(type, listener as any, options); + trackEventListener(registry, host, type, listener, options); + }; + } + + if (prop === 'removeEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) => { + host.removeEventListener(type, listener as any, options); + untrackEventListener(registry, host, type, listener, options); + }; + } + + const value = Reflect.get(host, prop, host); + if (typeof value !== 'function' || !shouldBindHostFunction(prop, value)) return value; + const cached = boundFunctions.get(value); + if (cached) return cached; + const bound = value.bind(host) as Function; + boundFunctions.set(value, bound); + return bound; + }, + set(_target, prop, value) { + return Reflect.set(host, prop, value, host); + }, + has(_target, prop) { + return prop in host; + }, + deleteProperty(_target, prop) { + return Reflect.deleteProperty(host, prop); + }, + defineProperty(_target, prop, descriptor) { + return Reflect.defineProperty(host, prop, descriptor); + }, + getOwnPropertyDescriptor(_target, prop) { + const descriptor = Reflect.getOwnPropertyDescriptor(host, prop); + if (!descriptor) return undefined; + return { ...descriptor, configurable: true }; + }, + ownKeys() { + return Reflect.ownKeys(host); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(host); + }, + }); +} + +export function createExtensionLifecycleScope( + registry: TimerRegistry | undefined, + hostWindow: any = window, + hostDocument: any = hostWindow?.document ?? document +): { + scopedWindow: any; + scopedDocument: any; + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + setImmediate: (callback: Function, ...args: any[]) => ExtensionTimerHandle; + clearImmediate: (id?: ExtensionTimerHandle) => void; +} { + const nativeSetInterval = hostWindow.setInterval.bind(hostWindow); + const nativeClearInterval = hostWindow.clearInterval.bind(hostWindow); + const nativeSetTimeout = hostWindow.setTimeout.bind(hostWindow); + const nativeClearTimeout = hostWindow.clearTimeout.bind(hostWindow); + const nativeRequestAnimationFrame = hostWindow.requestAnimationFrame.bind(hostWindow); + const nativeCancelAnimationFrame = hostWindow.cancelAnimationFrame.bind(hostWindow); + + const trackInterval = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + const id = nativeSetInterval(handler as any, timeout as any, ...args); + registry?.intervals.add(id); + registry?.intervalClearers.set(id, nativeClearInterval); + return id; + }; + const trackClearInterval = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.intervals.delete(id); + registry?.intervalClearers.delete(id); + } + nativeClearInterval(id); + }; + const trackTimeout = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + const id = nativeSetTimeout(handler as any, timeout as any, ...args); + registry?.timeouts.add(id); + registry?.timeoutClearers.set(id, nativeClearTimeout); + return id; + }; + const trackClearTimeout = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.timeouts.delete(id); + registry?.timeoutClearers.delete(id); + } + nativeClearTimeout(id); + }; + const trackRaf = (callback: FrameRequestCallback) => { + const id = nativeRequestAnimationFrame(callback); + registry?.rafs.add(id); + registry?.rafClearers.set(id, nativeCancelAnimationFrame); + return id; + }; + const trackCancelRaf = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.rafs.delete(id); + registry?.rafClearers.delete(id); + } + nativeCancelAnimationFrame(id); + }; + const trackSetImmediate = (callback: Function, ...args: any[]) => + trackTimeout(() => callback(...args), 0); + const trackClearImmediate = (id?: ExtensionTimerHandle) => trackClearTimeout(id); + + const timerApi = { + setInterval: trackInterval, + clearInterval: trackClearInterval, + setTimeout: trackTimeout, + clearTimeout: trackClearTimeout, + requestAnimationFrame: trackRaf, + cancelAnimationFrame: trackCancelRaf, + }; + + let scopedWindow: any; + let scopedDocument: any; + scopedDocument = createScopedHostProxy( + hostDocument, + registry, + 'document', + () => scopedWindow, + () => scopedDocument + ); + scopedWindow = createScopedHostProxy( + hostWindow, + registry, + 'window', + () => scopedWindow, + () => scopedDocument, + timerApi + ); + + return { + scopedWindow, + scopedDocument, + setInterval: trackInterval, + clearInterval: trackClearInterval, + setTimeout: trackTimeout, + clearTimeout: trackClearTimeout, + requestAnimationFrame: trackRaf, + cancelAnimationFrame: trackCancelRaf, + setImmediate: trackSetImmediate, + clearImmediate: trackClearImmediate, + }; } /** @@ -3909,44 +4221,10 @@ function loadExtensionExport( throw new Error(`Unsupported dynamic import in extension runtime: ${id}`); }; - // Sandboxed timer APIs — passed as named parameters so the extension's - // bundle resolves bare `setInterval`/`setTimeout`/`requestAnimationFrame` - // references against this scope instead of the host `window`. Handles are - // tracked in `timerRegistry` so the consumer (ExtensionView) can clear - // anything still pending on unmount, defending against extensions that - // forget their own cleanup (e.g. raycast/timers). - const trackInterval = (cb: any, ms?: any, ...rest: any[]) => { - const id = window.setInterval(cb as any, ms as any, ...rest); - timerRegistry?.intervals.add(id); - return id; - }; - const trackClearInterval = (id: any) => { - if (typeof id === 'number') timerRegistry?.intervals.delete(id); - window.clearInterval(id); - }; - const trackTimeout = (cb: any, ms?: any, ...rest: any[]) => { - const id = window.setTimeout(cb as any, ms as any, ...rest); - timerRegistry?.timeouts.add(id); - return id; - }; - const trackClearTimeout = (id: any) => { - if (typeof id === 'number') timerRegistry?.timeouts.delete(id); - window.clearTimeout(id); - }; - const trackRaf = (cb: FrameRequestCallback) => { - const id = window.requestAnimationFrame(cb); - timerRegistry?.rafs.add(id); - return id; - }; - const trackCancelRaf = (id: any) => { - if (typeof id === 'number') timerRegistry?.rafs.delete(id); - window.cancelAnimationFrame(id); - }; - // setImmediate / clearImmediate are Node-isms not on `window` — polyfill - // via setTimeout(0) and route through the same registry. - const trackSetImmediate = (cb: Function, ...args: any[]) => - trackTimeout(() => cb(...args), 0); - const trackClearImmediate = (id: any) => trackClearTimeout(id); + // Sandboxed lifecycle APIs — passed as named parameters so the extension's + // bundle resolves bare timers and `window.*`/`document.*` event listeners + // against this ExtensionView instance instead of the host renderer globals. + const lifecycleScope = createExtensionLifecycleScope(timerRegistry); // Execute the CJS bundle in a function scope. // We pass all the standard CJS arguments plus `process`, `Buffer`, @@ -3980,6 +4258,8 @@ function loadExtensionExport( 'clearTimeout', 'requestAnimationFrame', 'cancelAnimationFrame', + 'window', + 'document', 'navigator', '__scDynamicImport', executableCode @@ -3993,16 +4273,18 @@ function loadExtensionExport( '/extension', bundleProcess, bundleBuffer, - globalThis, - globalThis, - trackSetImmediate, - trackClearImmediate, - trackInterval, - trackClearInterval, - trackTimeout, - trackClearTimeout, - trackRaf, - trackCancelRaf, + lifecycleScope.scopedWindow, + lifecycleScope.scopedWindow, + lifecycleScope.setImmediate, + lifecycleScope.clearImmediate, + lifecycleScope.setInterval, + lifecycleScope.clearInterval, + lifecycleScope.setTimeout, + lifecycleScope.clearTimeout, + lifecycleScope.requestAnimationFrame, + lifecycleScope.cancelAnimationFrame, + lifecycleScope.scopedWindow, + lifecycleScope.scopedDocument, undefined, // navigator — see comment above scDynamicImport, );