diff --git a/packages/electron-chrome-extensions/spec/chrome-userScripts-spec.ts b/packages/electron-chrome-extensions/spec/chrome-userScripts-spec.ts new file mode 100644 index 00000000..16e4a2eb --- /dev/null +++ b/packages/electron-chrome-extensions/spec/chrome-userScripts-spec.ts @@ -0,0 +1,91 @@ +import { expect } from 'chai' +import { webContents } from 'electron' + +import { useExtensionBrowser, useServer } from './hooks' + +const waitFor = async (fn: () => Promise, done: (value: T) => boolean) => { + for (let i = 0; i < 50; i++) { + const value = await fn() + if (done(value)) return value + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return fn() +} + +describe('chrome.userScripts', () => { + const server = useServer() + const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'chrome-userScripts' }) + + const page = (js: string) => browser.webContents.executeJavaScript(js) + + it('runs USER_SCRIPT world scripts in an isolated world that can message the extension', async () => { + await browser.webContents.loadURL(server.getUrl()) + const reply = await waitFor( + () => page('document.documentElement.dataset.reply'), + (value) => !!value, + ) + expect(await page('document.documentElement.dataset.world')).to.equal('isolated') + expect(reply).to.equal('pong') + }) + + it('runs MAIN world scripts in the page', async () => { + await browser.webContents.loadURL(server.getUrl()) + const main = await waitFor( + () => page('window.mainWorld'), + (value) => value !== undefined, + ) + expect(main).to.equal(true) + }) + + it('honors excludeMatches', async () => { + await browser.webContents.loadURL(server.getUrl()) + await waitFor( + () => page('window.mainWorld'), + (value) => value !== undefined, + ) + expect(await page('window.excluded')).to.equal(undefined) + }) +}) + +describe('chrome.offscreen', () => { + const server = useServer() + const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'chrome-userScripts' }) + + const background = () => { + const host = webContents + .getAllWebContents() + .find( + (wc) => + wc.session === browser.session && + wc.getType() === 'backgroundPage' && + wc.getURL().startsWith(browser.extension.url), + ) + if (!host) throw new Error('background page not found') + return host + } + + it('creates, reports and closes an offscreen document', async () => { + const bg = background() + const result = await bg.executeJavaScript(`(async () => { + await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['TESTING'], justification: 'test' }) + const has = await chrome.offscreen.hasDocument() + const contexts = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }) + return { has, contexts } + })()`) + expect(result.has).to.equal(true) + expect(result.contexts).to.have.length(1) + expect(result.contexts[0].documentUrl).to.equal(`${browser.extension.url}offscreen.html`) + + const ready = await waitFor( + () => bg.executeJavaScript('!!globalThis.offscreenReady'), + (v) => v, + ) + expect(ready).to.equal(true) + + const after = await bg.executeJavaScript(`(async () => { + await chrome.offscreen.closeDocument() + return chrome.offscreen.hasDocument() + })()`) + expect(after).to.equal(false) + }) +}) diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/background.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/background.js new file mode 100644 index 00000000..8ace1e46 --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/background.js @@ -0,0 +1,27 @@ +/* global chrome */ + +chrome.runtime.onMessage.addListener((message, sender, reply) => { + if (message === 'ping') reply('pong') + if (message === 'offscreen-ready') globalThis.offscreenReady = true +}) + +const isolated = ` + document.documentElement.dataset.world = chrome.runtime && chrome.runtime.id ? 'isolated' : 'none' + chrome.runtime.sendMessage('ping', (reply) => { + document.documentElement.dataset.reply = reply + }) +` + +chrome.userScripts + .register([ + { id: 'isolated', matches: [''], js: [{ code: isolated }], runAt: 'document_start' }, + { id: 'main', matches: [''], js: [{ file: 'main.js' }], world: 'MAIN' }, + { + id: 'excluded', + matches: [''], + excludeMatches: ['http://127.0.0.1/*'], + js: [{ code: 'window.excluded = true' }], + world: 'MAIN', + }, + ]) + .then(() => console.log('background-script-evaluated')) diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/main.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/main.js new file mode 100644 index 00000000..23a5b04f --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/main.js @@ -0,0 +1 @@ +window.mainWorld = typeof chrome === 'undefined' || !chrome.runtime || !chrome.runtime.id diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/manifest.json b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/manifest.json new file mode 100644 index 00000000..349c8cbf --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "chrome-userScripts", + "version": "1.0", + "manifest_version": 2, + "background": { + "scripts": ["background.js"], + "persistent": true + }, + "permissions": ["userScripts", "offscreen", "tabs", ""] +} diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/offscreen.html b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/offscreen.html new file mode 100644 index 00000000..5096adfb --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/offscreen.html @@ -0,0 +1,2 @@ + + diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/offscreen.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/offscreen.js new file mode 100644 index 00000000..3d483c6d --- /dev/null +++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-userScripts/offscreen.js @@ -0,0 +1,2 @@ +/* global chrome */ +chrome.runtime.sendMessage('offscreen-ready') diff --git a/packages/electron-chrome-extensions/src/browser/api/lib/match-pattern.ts b/packages/electron-chrome-extensions/src/browser/api/lib/match-pattern.ts new file mode 100644 index 00000000..d8020eb5 --- /dev/null +++ b/packages/electron-chrome-extensions/src/browser/api/lib/match-pattern.ts @@ -0,0 +1,76 @@ +// Match patterns and globs as used by content scripts and user scripts. +// https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns + +const ALL_URLS_SCHEMES = new Set(['http:', 'https:', 'file:', 'ftp:', 'ws:', 'wss:']) +const PATTERN_RE = /^(\*|https?|file|ftp|wss?|urn):\/\/(\*|\*\.[^/*]+|[^/*]*)(\/.*)$/ + +const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** + * Converts a glob to an anchored RegExp. `*` matches any run of characters; + * `?` matches one character only for include/exclude globs, and is literal in + * match pattern paths. + */ +const globToRegExp = (glob: string, wildcards: '*' | '*?') => { + let source = '' + for (const ch of glob) { + if (ch === '*') source += '.*' + else if (ch === '?' && wildcards === '*?') source += '.' + else source += escapeRegExp(ch) + } + return new RegExp(`^${source}$`) +} + +export function matchesPattern(pattern: string, url: string): boolean { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return false + } + + if (pattern === '') return ALL_URLS_SCHEMES.has(parsed.protocol) + + const match = PATTERN_RE.exec(pattern) + if (!match) return false + const [, scheme, host, path] = match + + const protocol = parsed.protocol.slice(0, -1) + if (scheme === '*' ? protocol !== 'http' && protocol !== 'https' : scheme !== protocol) { + return false + } + + if (scheme !== 'file' && host !== '*') { + const hostname = parsed.hostname + if (host.startsWith('*.')) { + const base = host.slice(2) + if (hostname !== base && !hostname.endsWith(`.${base}`)) return false + } else if (hostname !== host) { + return false + } + } + + return globToRegExp(path, '*').test(parsed.pathname + parsed.search) +} + +export function matchesGlob(glob: string, url: string): boolean { + return globToRegExp(glob, '*?').test(url) +} + +export interface UrlFilter { + matches?: string[] + excludeMatches?: string[] + includeGlobs?: string[] + excludeGlobs?: string[] +} + +/** Whether a script with the given filter should run on `url`. */ +export function urlMatchesFilter(filter: UrlFilter, url: string): boolean { + if (!filter.matches?.some((p) => matchesPattern(p, url))) return false + if (filter.includeGlobs?.length && !filter.includeGlobs.some((g) => matchesGlob(g, url))) { + return false + } + if (filter.excludeMatches?.some((p) => matchesPattern(p, url))) return false + if (filter.excludeGlobs?.some((g) => matchesGlob(g, url))) return false + return true +} diff --git a/packages/electron-chrome-extensions/src/browser/api/offscreen.ts b/packages/electron-chrome-extensions/src/browser/api/offscreen.ts new file mode 100644 index 00000000..53221ce1 --- /dev/null +++ b/packages/electron-chrome-extensions/src/browser/api/offscreen.ts @@ -0,0 +1,119 @@ +import { BrowserWindow } from 'electron' +import { ExtensionContext } from '../context' +import { ExtensionEvent } from '../router' +import debug from 'debug' + +const d = debug('electron-chrome-extensions:offscreen') + +interface OffscreenDocument { + url: string + window: BrowserWindow +} + +/** + * chrome.offscreen: each extension may have one offscreen document, hosted in + * a hidden window that loads the extension page. + * + * https://developer.chrome.com/docs/extensions/reference/api/offscreen + */ +export class OffscreenAPI { + private documents = new Map() + + constructor(private ctx: ExtensionContext) { + const handle = this.ctx.router.apiHandler() + handle('offscreen.createDocument', this.createDocument, { permission: 'offscreen' }) + handle('offscreen.closeDocument', this.closeDocument, { permission: 'offscreen' }) + handle('offscreen.hasDocument', this.hasDocument, { permission: 'offscreen' }) + handle('runtime.getContexts', this.getContexts) + + const sessionExtensions = ctx.session.extensions || ctx.session + sessionExtensions.on('extension-unloaded', (_event, extension) => { + this.destroyDocument(extension.id) + }) + } + + private createDocument = async ( + { extension }: ExtensionEvent, + params: chrome.offscreen.CreateParameters, + ) => { + if (this.documents.has(extension.id)) { + throw new Error('Only a single offscreen document may be created.') + } + if (!params?.url) throw new Error('offscreen.createDocument requires a url') + + const url = new URL(params.url, extension.url).href + if (!url.startsWith(extension.url)) { + throw new Error('Offscreen document url must belong to the extension') + } + + const window = new BrowserWindow({ + show: false, + skipTaskbar: true, + webPreferences: { + session: this.ctx.session, + sandbox: true, + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }) + const doc = { url, window } + this.documents.set(extension.id, doc) + window.on('closed', () => { + if (this.documents.get(extension.id) === doc) this.documents.delete(extension.id) + }) + + d('creating offscreen document %s', url) + try { + await window.loadURL(url) + } catch (error) { + this.destroyDocument(extension.id) + throw error + } + } + + private closeDocument = ({ extension }: ExtensionEvent) => { + if (!this.documents.has(extension.id)) { + throw new Error('No current offscreen document.') + } + this.destroyDocument(extension.id) + } + + private hasDocument = ({ extension }: ExtensionEvent) => this.documents.has(extension.id) + + /** Reports offscreen documents. Other context types are left to the caller. */ + private getContexts = ( + { extension }: ExtensionEvent, + filter: chrome.runtime.ContextFilter = {}, + ): chrome.runtime.ExtensionContext[] => { + if (filter.contextTypes && !filter.contextTypes.includes('OFFSCREEN_DOCUMENT' as any)) { + return [] + } + const doc = this.documents.get(extension.id) + if (!doc || doc.window.isDestroyed()) return [] + if (filter.documentUrls && !filter.documentUrls.includes(doc.url)) return [] + if (filter.documentOrigins && !filter.documentOrigins.includes(new URL(doc.url).origin)) { + return [] + } + + const context = { + contextType: 'OFFSCREEN_DOCUMENT', + contextId: `offscreen-${doc.window.webContents.id}`, + tabId: -1, + windowId: -1, + frameId: 0, + documentId: undefined, + documentUrl: doc.url, + documentOrigin: new URL(doc.url).origin, + incognito: false, + } + return [context as unknown as chrome.runtime.ExtensionContext] + } + + private destroyDocument(extensionId: string) { + const doc = this.documents.get(extensionId) + if (!doc) return + this.documents.delete(extensionId) + if (!doc.window.isDestroyed()) doc.window.destroy() + } +} diff --git a/packages/electron-chrome-extensions/src/browser/api/user-scripts.ts b/packages/electron-chrome-extensions/src/browser/api/user-scripts.ts new file mode 100644 index 00000000..fd63d12a --- /dev/null +++ b/packages/electron-chrome-extensions/src/browser/api/user-scripts.ts @@ -0,0 +1,208 @@ +import { webContents as electronWebContents, webFrameMain } from 'electron' +import { readFile, realpath } from 'node:fs/promises' +import path from 'node:path' +import debug from 'debug' +import { ExtensionContext } from '../context' +import { ExtensionEvent } from '../router' +import { matchesPattern, urlMatchesFilter } from './lib/match-pattern' + +const d = debug('electron-chrome-extensions:userScripts') + +type RegisteredUserScript = chrome.userScripts.RegisteredUserScript + +const getFrameId = (frame: Electron.WebFrameMain) => + frame === frame.top ? 0 : frame.frameTreeNodeId + +/** + * chrome.userScripts: registered scripts are injected into matching frames of + * every tab as each frame commits a navigation. + * + * Scripts in the USER_SCRIPT world are injected through the extension's own + * `chrome.tabs.executeScript`, so they run in the extension's isolated world + * and can message the extension. Their messages arrive on + * `runtime.onMessage`/`runtime.onConnect`; `onUserScriptMessage` and + * `onUserScriptConnect` exist but never fire. MAIN world scripts are run in + * the page's main world directly. + * + * Registrations are kept in memory, so extensions re-register on startup. + * + * https://developer.chrome.com/docs/extensions/reference/api/userScripts + */ +export class UserScriptsAPI { + private scripts = new Map>() + + constructor(private ctx: ExtensionContext) { + const handle = this.ctx.router.apiHandler() + const opts = { permission: 'userScripts' as const } + handle('userScripts.register', this.register, opts) + handle('userScripts.update', this.update, opts) + handle('userScripts.unregister', this.unregister, opts) + handle('userScripts.getScripts', this.getScripts, opts) + handle('userScripts.configureWorld', this.configureWorld, opts) + + const sessionExtensions = ctx.session.extensions || ctx.session + sessionExtensions.on('extension-unloaded', (_event, extension) => { + this.scripts.delete(extension.id) + }) + + this.ctx.store.on('tab-added', (tab: Electron.WebContents) => { + tab.on('did-frame-navigate', (_e, url, _code, _status, _isMain, processId, routingId) => { + const frame = webFrameMain.fromId(processId, routingId) + if (frame) this.injectFrame(tab, frame, url) + }) + }) + } + + private extensionScripts(extensionId: string) { + let scripts = this.scripts.get(extensionId) + if (!scripts) { + scripts = new Map() + this.scripts.set(extensionId, scripts) + } + return scripts + } + + private register = ({ extension }: ExtensionEvent, scripts: RegisteredUserScript[]) => { + const existing = this.extensionScripts(extension.id) + for (const script of scripts) { + validate(script) + if (existing.has(script.id)) throw new Error(`Duplicate script ID '${script.id}'`) + } + for (const script of scripts) existing.set(script.id, script) + d('registered %s', scripts.map((s) => s.id).join(', ')) + } + + private update = ({ extension }: ExtensionEvent, scripts: RegisteredUserScript[]) => { + const existing = this.extensionScripts(extension.id) + const merged = scripts.map((script) => { + const current = existing.get(script.id) + if (!current) throw new Error(`Script with ID '${script.id}' does not exist`) + const next = { ...current, ...script } + validate(next) + return next + }) + for (const script of merged) existing.set(script.id, script) + } + + private unregister = ( + { extension }: ExtensionEvent, + filter?: chrome.userScripts.UserScriptFilter, + ) => { + const existing = this.extensionScripts(extension.id) + if (!filter?.ids) { + existing.clear() + return + } + for (const id of filter.ids) existing.delete(id) + } + + private getScripts = ( + { extension }: ExtensionEvent, + filter?: chrome.userScripts.UserScriptFilter, + ): RegisteredUserScript[] => { + const scripts = [...this.extensionScripts(extension.id).values()] + return filter?.ids ? scripts.filter((s) => filter.ids!.includes(s.id)) : scripts + } + + // CSP and messaging are not configurable: USER_SCRIPT world scripts run in + // the extension's isolated world, which always allows messaging. + private configureWorld = ( + _event: ExtensionEvent, + _properties: chrome.userScripts.WorldProperties, + ) => {} + + private injectFrame(wc: Electron.WebContents, frame: Electron.WebFrameMain, url: string) { + const isTop = frame === frame.top + for (const [extensionId, scripts] of this.scripts) { + for (const script of scripts.values()) { + if (!isTop && !script.allFrames) continue + if (!urlMatchesFilter(script, url)) continue + this.inject(extensionId, script, wc, frame).catch((error) => { + d('failed to inject %s into %s: %s', script.id, url, error) + }) + } + } + } + + private async inject( + extensionId: string, + script: RegisteredUserScript, + wc: Electron.WebContents, + frame: Electron.WebFrameMain, + ) { + const sessionExtensions = this.ctx.session.extensions || this.ctx.session + const extension = sessionExtensions.getExtension(extensionId) + if (!extension) return + if (!hasHostPermission(extension, frame.url)) { + throw new Error(`missing host permission for ${frame.url}`) + } + + const sources = await Promise.all( + script.js.map((source: chrome.userScripts.ScriptSource) => + source.file + ? readExtensionFile(extension, source.file) + : Promise.resolve(source.code || ''), + ), + ) + const code = sources.join('\n;\n') + + if (script.world === 'MAIN') { + await frame.executeJavaScript(code) + return + } + + const host = findBackgroundHost(this.ctx.session, extension) + if (!host) throw new Error(`no background page for ${extensionId}`) + + const details = { code, frameId: getFrameId(frame), runAt: script.runAt || 'document_idle' } + const error = await host.executeJavaScript( + `new Promise((resolve) => chrome.tabs.executeScript(${wc.id}, ${JSON.stringify(details)}, ` + + `() => resolve(chrome.runtime.lastError ? chrome.runtime.lastError.message : null)))`, + ) + if (error) throw new Error(error) + } +} + +function validate(script: RegisteredUserScript) { + if (!script.id || script.id.startsWith('_')) throw new Error(`Invalid script ID '${script.id}'`) + if (!script.matches?.length) throw new Error(`Script '${script.id}' must specify matches`) + if (!script.js?.length) throw new Error(`Script '${script.id}' must specify js`) + for (const source of script.js) { + if (!source.code === !source.file) { + throw new Error(`Script '${script.id}' sources need exactly one of code or file`) + } + if (source.file && source.file.split(/[\\/]/).includes('..')) { + throw new Error(`Script '${script.id}' file must be inside the extension`) + } + } +} + +/** Reads a file from the extension's directory, refusing anything that resolves outside it. */ +async function readExtensionFile(extension: Electron.Extension, file: string) { + const base = await realpath(extension.path) + const target = await realpath(path.resolve(base, file.replace(/^\/+/, ''))) + if (!target.startsWith(base + path.sep)) { + throw new Error(`Script file '${file}' is outside the extension`) + } + return readFile(target, 'utf8') +} + +/** Whether the extension's manifest grants host access to `url`. */ +function hasHostPermission(extension: Electron.Extension, url: string) { + const manifest = extension.manifest as chrome.runtime.Manifest & { host_permissions?: string[] } + const hosts = [...(manifest.permissions || []), ...(manifest.host_permissions || [])].filter( + (p): p is string => typeof p === 'string' && (p === '' || p.includes('://')), + ) + return hosts.some((pattern) => matchesPattern(pattern, url)) +} + +function findBackgroundHost(session: Electron.Session, extension: Electron.Extension) { + return electronWebContents + .getAllWebContents() + .find( + (wc) => + wc.session === session && + wc.getType() === 'backgroundPage' && + wc.getURL().startsWith(extension.url), + ) +} diff --git a/packages/electron-chrome-extensions/src/browser/index.ts b/packages/electron-chrome-extensions/src/browser/index.ts index d2e6a0a9..4dd9dd74 100644 --- a/packages/electron-chrome-extensions/src/browser/index.ts +++ b/packages/electron-chrome-extensions/src/browser/index.ts @@ -6,6 +6,7 @@ import { createRequire } from 'node:module' import { BrowserActionAPI } from './api/browser-action' import { TabsAPI } from './api/tabs' +import { UserScriptsAPI } from './api/user-scripts' import { WindowsAPI } from './api/windows' import { WebNavigationAPI } from './api/web-navigation' import { ExtensionStore } from './store' @@ -13,6 +14,7 @@ import { ContextMenusAPI } from './api/context-menus' import { RuntimeAPI } from './api/runtime' import { CookiesAPI } from './api/cookies' import { NotificationsAPI } from './api/notifications' +import { OffscreenAPI } from './api/offscreen' import { ChromeExtensionImpl } from './impl' import { CommandsAPI } from './api/commands' import { ExtensionContext } from './context' @@ -50,8 +52,13 @@ function resolvePreloadPath(modulePath?: string) { return path.join(modulePath, 'dist', preloadFilename) } - // Fallback to preload relative to entrypoint directory - return path.join(__dirname, preloadFilename) + // Fallback to preload relative to entrypoint directory. The bundled entry + // lives in dist/cjs or dist/esm while the preload is built to dist/. + const candidates = [ + path.join(__dirname, preloadFilename), + path.join(__dirname, '..', preloadFilename), + ] + return candidates.find((candidate) => existsSync(candidate)) || candidates[0] } export interface ChromeExtensionOptions extends ChromeExtensionImpl { @@ -126,9 +133,11 @@ export class ElectronChromeExtensions extends EventEmitter { commands: CommandsAPI cookies: CookiesAPI notifications: NotificationsAPI + offscreen: OffscreenAPI permissions: PermissionsAPI runtime: RuntimeAPI tabs: TabsAPI + userScripts: UserScriptsAPI webNavigation: WebNavigationAPI windows: WindowsAPI } @@ -163,9 +172,11 @@ export class ElectronChromeExtensions extends EventEmitter { commands: new CommandsAPI(this.ctx), cookies: new CookiesAPI(this.ctx), notifications: new NotificationsAPI(this.ctx), + offscreen: new OffscreenAPI(this.ctx), permissions: new PermissionsAPI(this.ctx), runtime: new RuntimeAPI(this.ctx), tabs: new TabsAPI(this.ctx), + userScripts: new UserScriptsAPI(this.ctx), webNavigation: new WebNavigationAPI(this.ctx), windows: new WindowsAPI(this.ctx), } diff --git a/packages/electron-chrome-extensions/src/renderer/index.ts b/packages/electron-chrome-extensions/src/renderer/index.ts index 78770e54..2d57a45d 100644 --- a/packages/electron-chrome-extensions/src/renderer/index.ts +++ b/packages/electron-chrome-extensions/src/renderer/index.ts @@ -514,12 +514,40 @@ export const injectExtensionAPIs = () => { electron.connectNative(extensionId, application, receive, disconnect, callback) return port }, + getContexts: invokeExtension('runtime.getContexts'), openOptionsPage: invokeExtension('runtime.openOptionsPage'), sendNativeMessage: invokeExtension('runtime.sendNativeMessage'), } }, }, + offscreen: { + shouldInject: () => !!manifest.permissions?.includes('offscreen' as any), + factory: (base) => { + return { + ...base, + Reason: (base as any)?.Reason || {}, + createDocument: invokeExtension('offscreen.createDocument'), + closeDocument: invokeExtension('offscreen.closeDocument'), + hasDocument: invokeExtension('offscreen.hasDocument'), + } + }, + }, + + userScripts: { + shouldInject: () => !!manifest.permissions?.includes('userScripts' as any), + factory: (base) => { + return { + ...base, + register: invokeExtension('userScripts.register'), + update: invokeExtension('userScripts.update'), + unregister: invokeExtension('userScripts.unregister'), + getScripts: invokeExtension('userScripts.getScripts'), + configureWorld: invokeExtension('userScripts.configureWorld'), + } + }, + }, + storage: { factory: (base) => { const local = base && base.local