Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { expect } from 'chai'
import { webContents } from 'electron'

import { useExtensionBrowser, useServer } from './hooks'

const waitFor = async <T>(fn: () => Promise<T>, 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)
})
})
Original file line number Diff line number Diff line change
@@ -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: ['<all_urls>'], js: [{ code: isolated }], runAt: 'document_start' },
{ id: 'main', matches: ['<all_urls>'], js: [{ file: 'main.js' }], world: 'MAIN' },
{
id: 'excluded',
matches: ['<all_urls>'],
excludeMatches: ['http://127.0.0.1/*'],
js: [{ code: 'window.excluded = true' }],
world: 'MAIN',
},
])
.then(() => console.log('background-script-evaluated'))
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
window.mainWorld = typeof chrome === 'undefined' || !chrome.runtime || !chrome.runtime.id
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "chrome-userScripts",
"version": "1.0",
"manifest_version": 2,
"background": {
"scripts": ["background.js"],
"persistent": true
},
"permissions": ["userScripts", "offscreen", "tabs", "<all_urls>"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!doctype html>
<script src="offscreen.js"></script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* global chrome */
chrome.runtime.sendMessage('offscreen-ready')
Original file line number Diff line number Diff line change
@@ -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 === '<all_urls>') 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
}
119 changes: 119 additions & 0 deletions packages/electron-chrome-extensions/src/browser/api/offscreen.ts
Original file line number Diff line number Diff line change
@@ -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<string, OffscreenDocument>()

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()
}
}
Loading