diff --git a/packages/chrome-ui/menu.js b/packages/chrome-ui/menu.js
new file mode 100644
index 0000000..5ffc97e
--- /dev/null
+++ b/packages/chrome-ui/menu.js
@@ -0,0 +1,51 @@
+const { Menu } = require('electron')
+
+const setupMenu = (browser) => {
+ const isMac = process.platform === 'darwin'
+
+ const tab = () => browser.getFocusedWindow().getFocusedTab()
+ const tabWc = () => tab().webContents
+
+ const template = [
+ ...(isMac ? [{ role: 'appMenu' }] : []),
+ { role: 'fileMenu' },
+ { role: 'editMenu' },
+ {
+ label: 'View',
+ submenu: [
+ {
+ label: 'Reload',
+ accelerator: 'CmdOrCtrl+R',
+ nonNativeMacOSRole: true,
+ click: () => tabWc().reload(),
+ },
+ {
+ label: 'Force Reload',
+ accelerator: 'Shift+CmdOrCtrl+R',
+ nonNativeMacOSRole: true,
+ click: () => tabWc().reloadIgnoringCache(),
+ },
+ {
+ label: 'Toggle Developer Tool asdf',
+ accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I',
+ nonNativeMacOSRole: true,
+ click: () => tabWc().toggleDevTools(),
+ },
+ { type: 'separator' },
+ { role: 'resetZoom' },
+ { role: 'zoomIn' },
+ { role: 'zoomOut' },
+ { type: 'separator' },
+ { role: 'togglefullscreen' },
+ ],
+ },
+ { role: 'windowMenu' },
+ ]
+
+ const menu = Menu.buildFromTemplate(template)
+ Menu.setApplicationMenu(menu)
+}
+
+module.exports = {
+ setupMenu,
+}
diff --git a/packages/chrome-ui/tabs.js b/packages/chrome-ui/tabs.js
new file mode 100644
index 0000000..c5c1079
--- /dev/null
+++ b/packages/chrome-ui/tabs.js
@@ -0,0 +1,153 @@
+const { EventEmitter } = require('events')
+const { WebContentsView } = require('electron')
+
+const toolbarHeight = 64
+
+class Tab {
+ constructor(parentWindow, wcvOpts = {}) {
+ this.invalidateLayout = this.invalidateLayout.bind(this)
+
+ // Delete undefined properties which cause WebContentsView constructor to
+ // throw. This should probably be fixed in Electron upstream.
+ if (wcvOpts.hasOwnProperty('webContents') && !wcvOpts.webContents) delete wcvOpts.webContents
+ if (wcvOpts.hasOwnProperty('webPreferences') && !wcvOpts.webPreferences)
+ delete wcvOpts.webPreferences
+
+ this.view = new WebContentsView(wcvOpts)
+ this.id = this.view.webContents.id
+ this.window = parentWindow
+ this.webContents = this.view.webContents
+ this.window.contentView.addChildView(this.view)
+ }
+
+ destroy() {
+ if (this.destroyed) return
+
+ this.destroyed = true
+
+ this.hide()
+
+ this.window.contentView.removeChildView(this.view)
+ this.window = undefined
+
+ if (!this.webContents.isDestroyed()) {
+ if (this.webContents.isDevToolsOpened()) {
+ this.webContents.closeDevTools()
+ }
+
+ // TODO: why is this no longer called?
+ this.webContents.emit('destroyed')
+
+ this.webContents.destroy()
+ }
+
+ this.webContents = undefined
+ this.view = undefined
+ }
+
+ loadURL(url) {
+ return this.view.webContents.loadURL(url)
+ }
+
+ show() {
+ this.invalidateLayout()
+ this.startResizeListener()
+ this.view.setVisible(true)
+ }
+
+ hide() {
+ this.stopResizeListener()
+ this.view.setVisible(false)
+ }
+
+ reload() {
+ this.view.webContents.reload()
+ }
+
+ invalidateLayout() {
+ const [width, height] = this.window.getSize()
+ const padding = 4
+ this.view.setBounds({
+ x: padding,
+ y: toolbarHeight,
+ width: width - padding * 2,
+ height: height - toolbarHeight - padding,
+ })
+ this.view.setBorderRadius(8)
+ }
+
+ // Replacement for BrowserView.setAutoResize. This could probably be better...
+ startResizeListener() {
+ this.stopResizeListener()
+ this.window.on('resize', this.invalidateLayout)
+ }
+ stopResizeListener() {
+ this.window.off('resize', this.invalidateLayout)
+ }
+}
+
+class Tabs extends EventEmitter {
+ tabList = []
+ selected = null
+
+ constructor(browserWindow) {
+ super()
+ this.window = browserWindow
+ }
+
+ destroy() {
+ this.tabList.forEach((tab) => tab.destroy())
+ this.tabList = []
+
+ this.selected = undefined
+
+ if (this.window) {
+ this.window.destroy()
+ this.window = undefined
+ }
+ }
+
+ get(tabId) {
+ return this.tabList.find((tab) => tab.id === tabId)
+ }
+
+ create(webContentsViewOptions) {
+ const tab = new Tab(this.window, webContentsViewOptions)
+ this.tabList.push(tab)
+ if (!this.selected) this.selected = tab
+ tab.show() // must be attached to window
+ this.emit('tab-created', tab)
+ this.select(tab.id)
+ return tab
+ }
+
+ remove(tabId) {
+ const tabIndex = this.tabList.findIndex((tab) => tab.id === tabId)
+ if (tabIndex < 0) {
+ throw new Error(`Tabs.remove: unable to find tab.id = ${tabId}`)
+ }
+ const tab = this.tabList[tabIndex]
+ this.tabList.splice(tabIndex, 1)
+ tab.destroy()
+ if (this.selected === tab) {
+ this.selected = undefined
+ const nextTab = this.tabList[tabIndex] || this.tabList[tabIndex - 1]
+ if (nextTab) this.select(nextTab.id)
+ }
+ this.emit('tab-destroyed', tab)
+ if (this.tabList.length === 0) {
+ this.destroy()
+ }
+ }
+
+ select(tabId) {
+ const tab = this.get(tabId)
+ if (!tab) return
+ if (this.selected) this.selected.hide()
+ tab.show()
+ this.selected = tab
+ this.emit('tab-selected', tab)
+ }
+}
+
+exports.Tabs = Tabs
diff --git a/packages/chrome-ui/ui/manifest.json b/packages/chrome-ui/ui/manifest.json
new file mode 100644
index 0000000..c082ac5
--- /dev/null
+++ b/packages/chrome-ui/ui/manifest.json
@@ -0,0 +1,9 @@
+{
+ "name": "WebUI",
+ "version": "1.0.0",
+ "manifest_version": 3,
+ "permissions": [],
+ "chrome_url_overrides": {
+ "newtab": "new-tab.html"
+ }
+}
diff --git a/packages/chrome-ui/ui/new-tab.html b/packages/chrome-ui/ui/new-tab.html
new file mode 100644
index 0000000..eb04c66
--- /dev/null
+++ b/packages/chrome-ui/ui/new-tab.html
@@ -0,0 +1,31 @@
+
+
+
+
+ New Tab
+
+
+
+ New Tab
+
+
+
diff --git a/packages/chrome-ui/ui/webui.html b/packages/chrome-ui/ui/webui.html
new file mode 100644
index 0000000..eb5d7a0
--- /dev/null
+++ b/packages/chrome-ui/ui/webui.html
@@ -0,0 +1,253 @@
+
+
+
+
+ Shell Browser
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chrome-ui/ui/webui.js b/packages/chrome-ui/ui/webui.js
new file mode 100644
index 0000000..8bf7a62
--- /dev/null
+++ b/packages/chrome-ui/ui/webui.js
@@ -0,0 +1,193 @@
+class WebUI {
+ windowId = -1
+ activeTabId = -1
+ /** @type {chrome.tabs.Tab[]} */
+ tabList = []
+
+ constructor() {
+ const $ = document.querySelector.bind(document)
+
+ this.$ = {
+ tabList: $('#tabstrip .tab-list'),
+ tabTemplate: $('#tabtemplate'),
+ createTabButton: $('#createtab'),
+ goBackButton: $('#goback'),
+ goForwardButton: $('#goforward'),
+ reloadButton: $('#reload'),
+ addressUrl: $('#addressurl'),
+
+ browserActions: $('#actions'),
+
+ minimizeButton: $('#minimize'),
+ maximizeButton: $('#maximize'),
+ closeButton: $('#close'),
+ }
+
+ this.$.createTabButton.addEventListener('click', () => chrome.tabs.create())
+ this.$.goBackButton.addEventListener('click', () => chrome.tabs.goBack())
+ this.$.goForwardButton.addEventListener('click', () => chrome.tabs.goForward())
+ this.$.reloadButton.addEventListener('click', () => chrome.tabs.reload())
+ this.$.addressUrl.addEventListener('keypress', this.onAddressUrlKeyPress.bind(this))
+
+ this.$.minimizeButton.addEventListener('click', () =>
+ chrome.windows.get(chrome.windows.WINDOW_ID_CURRENT, (win) => {
+ chrome.windows.update(win.id, { state: win.state === 'minimized' ? 'normal' : 'minimized' })
+ }),
+ )
+ this.$.maximizeButton.addEventListener('click', () =>
+ chrome.windows.get(chrome.windows.WINDOW_ID_CURRENT, (win) => {
+ chrome.windows.update(win.id, { state: win.state === 'maximized' ? 'normal' : 'maximized' })
+ }),
+ )
+ this.$.closeButton.addEventListener('click', () => chrome.windows.remove())
+
+ const platformClass = `platform-${navigator.userAgentData.platform.toLowerCase()}`
+ document.body.classList.add(platformClass)
+
+ this.initTabs()
+ }
+
+ async initTabs() {
+ const tabs = await new Promise((resolve) => chrome.tabs.query({ windowId: -2 }, resolve))
+ this.tabList = [...tabs]
+ this.renderTabs()
+
+ const activeTab = this.tabList.find((tab) => tab.active)
+ if (activeTab) {
+ this.setActiveTab(activeTab)
+ }
+
+ // Wait to setup tabs and windowId prior to listening for updates.
+ this.setupBrowserListeners()
+ }
+
+ setupBrowserListeners() {
+ if (!chrome.tabs.onCreated) {
+ throw new Error(`chrome global not setup. Did the extension preload not get run?`)
+ }
+
+ const findTab = (tabId) => {
+ const existingTab = this.tabList.find((tab) => tab.id === tabId)
+ return existingTab
+ }
+
+ const findOrCreateTab = (tabId) => {
+ const existingTab = findTab(tabId)
+ if (existingTab) return existingTab
+
+ const newTab = { id: tabId }
+ this.tabList.push(newTab)
+ return newTab
+ }
+
+ chrome.tabs.onCreated.addListener((tab) => {
+ if (tab.windowId !== this.windowId) return
+ const newTab = findOrCreateTab(tab.id)
+ Object.assign(newTab, tab)
+ this.renderTabs()
+ })
+
+ chrome.tabs.onActivated.addListener((activeInfo) => {
+ if (activeInfo.windowId !== this.windowId) return
+
+ this.setActiveTab(activeInfo)
+ })
+
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo, details) => {
+ const tab = findTab(tabId)
+ if (!tab) return
+ Object.assign(tab, details)
+ this.renderTabs()
+ if (tabId === this.activeTabId) this.renderToolbar(tab)
+ })
+
+ chrome.tabs.onRemoved.addListener((tabId) => {
+ const tabIndex = this.tabList.findIndex((tab) => tab.id === tabId)
+ if (tabIndex > -1) {
+ this.tabList.splice(tabIndex, 1)
+ this.$.tabList.querySelector(`[data-tab-id="${tabId}"]`).remove()
+ }
+ })
+ }
+
+ setActiveTab(activeTab) {
+ this.activeTabId = activeTab.id || activeTab.tabId
+ this.windowId = activeTab.windowId
+
+ for (const tab of this.tabList) {
+ if (tab.id === this.activeTabId) {
+ tab.active = true
+ this.renderTab(tab)
+ this.renderToolbar(tab)
+ } else {
+ if (tab.active) {
+ tab.active = false
+ this.renderTab(tab)
+ }
+ }
+ }
+ }
+
+ onAddressUrlKeyPress(event) {
+ if (event.code === 'Enter') {
+ const url = this.$.addressUrl.value
+ chrome.tabs.update({ url })
+ }
+ }
+
+ createTabNode(tab) {
+ const tabElem = this.$.tabTemplate.content.cloneNode(true).firstElementChild
+ tabElem.dataset.tabId = tab.id
+
+ tabElem.addEventListener('click', () => {
+ chrome.tabs.update(tab.id, { active: true })
+ })
+ tabElem.querySelector('.close').addEventListener('click', () => {
+ chrome.tabs.remove(tab.id)
+ })
+ const faviconElem = tabElem.querySelector('.favicon')
+ faviconElem?.addEventListener('load', () => {
+ faviconElem.classList.toggle('loaded', true)
+ })
+ faviconElem?.addEventListener('error', () => {
+ faviconElem.classList.toggle('loaded', false)
+ })
+
+ this.$.tabList.appendChild(tabElem)
+ return tabElem
+ }
+
+ renderTab(tab) {
+ let tabElem = this.$.tabList.querySelector(`[data-tab-id="${tab.id}"]`)
+ if (!tabElem) tabElem = this.createTabNode(tab)
+
+ if (tab.active) {
+ tabElem.dataset.active = ''
+ } else {
+ delete tabElem.dataset.active
+ }
+
+ const favicon = tabElem.querySelector('.favicon')
+ if (tab.favIconUrl) {
+ favicon.src = tab.favIconUrl
+ } else {
+ delete favicon.src
+ }
+
+ tabElem.querySelector('.title').textContent = tab.title
+ tabElem.querySelector('.audio').disabled = !tab.audible
+ }
+
+ renderTabs() {
+ this.tabList.forEach((tab) => {
+ this.renderTab(tab)
+ })
+ }
+
+ renderToolbar(tab) {
+ this.$.addressUrl.value = tab.url
+ // this.$.browserActions.tab = tab.id
+ }
+}
+
+window.webui = new WebUI()
diff --git a/packages/electron-chrome-context-menu/.gitignore b/packages/electron-chrome-context-menu/.gitignore
new file mode 100644
index 0000000..334a245
--- /dev/null
+++ b/packages/electron-chrome-context-menu/.gitignore
@@ -0,0 +1,2 @@
+dist
+*.map
\ No newline at end of file
diff --git a/packages/electron-chrome-context-menu/.npmignore b/packages/electron-chrome-context-menu/.npmignore
new file mode 100644
index 0000000..15231cf
--- /dev/null
+++ b/packages/electron-chrome-context-menu/.npmignore
@@ -0,0 +1 @@
+tsconfig.json
diff --git a/packages/electron-chrome-context-menu/README.md b/packages/electron-chrome-context-menu/README.md
new file mode 100644
index 0000000..0fb859d
--- /dev/null
+++ b/packages/electron-chrome-context-menu/README.md
@@ -0,0 +1,56 @@
+# electron-chrome-context-menu
+
+> Chrome context menu for Electron browsers
+
+Building a modern web browser requires including many features users have grown accustomed to. Context menus are a small, but noticeable feature when done improperly.
+
+This module aims to provide a context menu with close to feature parity to that of Google Chrome.
+
+## Install
+
+> npm install electron-chrome-context-menu
+
+## Usage
+
+```ts
+// ES imports
+import buildChromeContextMenu from 'electron-chrome-context-menu'
+// CommonJS
+const buildChromeContextMenu = require('electron-chrome-context-menu').default
+
+const { app } = require('electron')
+
+app.on('web-contents-created', (event, webContents) => {
+ webContents.on('context-menu', (e, params) => {
+ const menu = buildChromeContextMenu({
+ params,
+ webContents,
+ openLink: (url, disposition) => {
+ webContents.loadURL(url)
+ }
+ })
+
+ menu.popup()
+ })
+})
+```
+
+> For a complete example, see the [`electron-browser-shell`](https://github.com/samuelmaddock/electron-browser-shell) project.
+
+## API
+
+### `buildChromeContextMenu(options)`
+
+* `options` Object
+ * `params` Electron.ContextMenuParams - Context menu parameters emitted from the WebContents 'context-menu' event.
+ * `webContents` Electron.WebContents - WebContents which emitted the 'context-menu' event.
+ * `openLink(url, disposition, params)` - Handler for opening links.
+ * `url` String
+ * `disposition` String - Can be `default`, `foreground-tab`, `background-tab`, and `new-window`.
+ * `params` Electron.ContextMenuParams
+ * `extensionMenuItems` Electron.MenuItem[] (optional) - Collection of menu items for active web extensions.
+ * `labels` Object (optional) - Labels used to create menu items. Replace this if localization is needed.
+
+## License
+
+MIT
diff --git a/packages/electron-chrome-context-menu/index.ts b/packages/electron-chrome-context-menu/index.ts
new file mode 100644
index 0000000..c811bee
--- /dev/null
+++ b/packages/electron-chrome-context-menu/index.ts
@@ -0,0 +1,249 @@
+import { app, BrowserWindow, clipboard, Menu, MenuItem } from 'electron'
+
+const LABELS = {
+ openInNewTab: (type: 'link' | Electron.ContextMenuParams['mediaType']) =>
+ `Open ${type} in new tab`,
+ openInNewWindow: (type: 'link' | Electron.ContextMenuParams['mediaType']) =>
+ `Open ${type} in new window`,
+ copyAddress: (type: 'link' | Electron.ContextMenuParams['mediaType']) => `Copy ${type} address`,
+ undo: 'Undo',
+ redo: 'Redo',
+ cut: 'Cut',
+ copy: 'Copy',
+ delete: 'Delete',
+ paste: 'Paste',
+ selectAll: 'Select All',
+ back: 'Back',
+ forward: 'Forward',
+ reload: 'Reload',
+ inspect: 'Inspect',
+ addToDictionary: 'Add to dictionary',
+ exitFullScreen: 'Exit full screen',
+ emoji: 'Emoji',
+}
+
+const getBrowserWindowFromWebContents = (webContents: Electron.WebContents) => {
+ return BrowserWindow.getAllWindows().find((win) => {
+ if (win.webContents === webContents) return true
+
+ let browserViews: Electron.BrowserView[]
+
+ if ('getBrowserViews' in win) {
+ browserViews = win.getBrowserViews()
+ } else if ('getBrowserView' in win) {
+ // @ts-ignore
+ browserViews = [win.getBrowserView()]
+ } else {
+ browserViews = []
+ }
+
+ return browserViews.some((view) => view.webContents === webContents)
+ })
+}
+
+type ChromeContextMenuLabels = typeof LABELS
+
+interface ChromeContextMenuOptions {
+ /** Context menu parameters emitted from the WebContents 'context-menu' event. */
+ params: Electron.ContextMenuParams
+
+ /** WebContents which emitted the 'context-menu' event. */
+ webContents: Electron.WebContents
+
+ /** Handler for opening links. */
+ openLink: (
+ url: string,
+ disposition: 'default' | 'foreground-tab' | 'background-tab' | 'new-window',
+ params: Electron.ContextMenuParams,
+ ) => void
+
+ /** Chrome extension menu items. */
+ extensionMenuItems?: MenuItem[]
+
+ /** Labels used to create menu items. Replace this if localization is needed. */
+ labels?: ChromeContextMenuLabels
+
+ /**
+ * @deprecated Use 'labels' instead.
+ */
+ strings?: ChromeContextMenuLabels
+}
+
+export const buildChromeContextMenu = (opts: ChromeContextMenuOptions): Menu => {
+ const { params, webContents, openLink, extensionMenuItems } = opts
+
+ const labels = opts.labels || opts.strings || LABELS
+
+ const menu = new Menu()
+ const append = (opts: Electron.MenuItemConstructorOptions) => menu.append(new MenuItem(opts))
+ const appendSeparator = () => menu.append(new MenuItem({ type: 'separator' }))
+
+ if (params.linkURL) {
+ append({
+ label: labels.openInNewTab('link'),
+ click: () => {
+ openLink(params.linkURL, 'default', params)
+ },
+ })
+ append({
+ label: labels.openInNewWindow('link'),
+ click: () => {
+ openLink(params.linkURL, 'new-window', params)
+ },
+ })
+ appendSeparator()
+ append({
+ label: labels.copyAddress('link'),
+ click: () => {
+ clipboard.writeText(params.linkURL)
+ },
+ })
+ appendSeparator()
+ } else if (params.mediaType !== 'none') {
+ // TODO: Loop, Show controls
+ append({
+ label: labels.openInNewTab(params.mediaType),
+ click: () => {
+ openLink(params.srcURL, 'default', params)
+ },
+ })
+ append({
+ label: labels.copyAddress(params.mediaType),
+ click: () => {
+ clipboard.writeText(params.srcURL)
+ },
+ })
+ appendSeparator()
+ }
+
+ if (params.isEditable) {
+ if (params.misspelledWord) {
+ for (const suggestion of params.dictionarySuggestions) {
+ append({
+ label: suggestion,
+ click: () => webContents.replaceMisspelling(suggestion),
+ })
+ }
+
+ if (params.dictionarySuggestions.length > 0) appendSeparator()
+
+ append({
+ label: labels.addToDictionary,
+ click: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
+ })
+ } else {
+ if (
+ app.isEmojiPanelSupported() &&
+ !['input-number', 'input-telephone'].includes(params.formControlType)
+ ) {
+ append({
+ label: labels.emoji,
+ click: () => app.showEmojiPanel(),
+ })
+ appendSeparator()
+ }
+
+ append({
+ label: labels.redo,
+ enabled: params.editFlags.canRedo,
+ click: () => webContents.redo(),
+ })
+ append({
+ label: labels.undo,
+ enabled: params.editFlags.canUndo,
+ click: () => webContents.undo(),
+ })
+ }
+
+ appendSeparator()
+
+ append({
+ label: labels.cut,
+ enabled: params.editFlags.canCut,
+ click: () => webContents.cut(),
+ })
+ append({
+ label: labels.copy,
+ enabled: params.editFlags.canCopy,
+ click: () => webContents.copy(),
+ })
+ append({
+ label: labels.paste,
+ enabled: params.editFlags.canPaste,
+ click: () => webContents.paste(),
+ })
+ append({
+ label: labels.delete,
+ enabled: params.editFlags.canDelete,
+ click: () => webContents.delete(),
+ })
+ appendSeparator()
+ if (params.editFlags.canSelectAll) {
+ append({
+ label: labels.selectAll,
+ click: () => webContents.selectAll(),
+ })
+ appendSeparator()
+ }
+ } else if (params.selectionText) {
+ append({
+ label: labels.copy,
+ click: () => {
+ clipboard.writeText(params.selectionText)
+ },
+ })
+ appendSeparator()
+ }
+
+ if (menu.items.length === 0) {
+ const browserWindow = getBrowserWindowFromWebContents(webContents)
+
+ // TODO: Electron needs a way to detect whether we're in HTML5 full screen.
+ // Also need to properly exit full screen in Blink rather than just exiting
+ // the Electron BrowserWindow.
+ if (browserWindow?.fullScreen) {
+ append({
+ label: labels.exitFullScreen,
+ click: () => browserWindow.setFullScreen(false),
+ })
+
+ appendSeparator()
+ }
+
+ append({
+ label: labels.back,
+ enabled: webContents.navigationHistory.canGoBack(),
+ click: () => webContents.navigationHistory.goBack(),
+ })
+ append({
+ label: labels.forward,
+ enabled: webContents.navigationHistory.canGoForward(),
+ click: () => webContents.navigationHistory.goForward(),
+ })
+ append({
+ label: labels.reload,
+ click: () => webContents.reload(),
+ })
+ appendSeparator()
+ }
+
+ if (extensionMenuItems) {
+ extensionMenuItems.forEach((item) => menu.append(item))
+ if (extensionMenuItems.length > 0) appendSeparator()
+ }
+
+ append({
+ label: labels.inspect,
+ click: () => {
+ webContents.inspectElement(params.x, params.y)
+
+ if (!webContents.isDevToolsFocused()) {
+ webContents.devToolsWebContents?.focus()
+ }
+ },
+ })
+
+ return menu
+}
+
+export default buildChromeContextMenu
diff --git a/packages/electron-chrome-context-menu/package.json b/packages/electron-chrome-context-menu/package.json
new file mode 100644
index 0000000..4b56a38
--- /dev/null
+++ b/packages/electron-chrome-context-menu/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "electron-chrome-context-menu",
+ "version": "1.0.0",
+ "description": "Chrome context menu for Electron browsers",
+ "main": "dist/index.js",
+ "scripts": {
+ "build": "tsc",
+ "prepublishOnly": "yarn build"
+ },
+ "keywords": [
+ "electron",
+ "chrome",
+ "context",
+ "menu"
+ ],
+ "repository": "",
+ "author": "Damon ",
+ "license": "MIT",
+ "peerDependencies": {
+ "electron": ">=38.2.2"
+ },
+ "devDependencies": {
+ "typescript": "^4.5.4"
+ }
+}
diff --git a/packages/electron-chrome-context-menu/tsconfig.json b/packages/electron-chrome-context-menu/tsconfig.json
new file mode 100644
index 0000000..a88986e
--- /dev/null
+++ b/packages/electron-chrome-context-menu/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+
+ "compilerOptions": {
+ "moduleResolution": "node",
+ "outDir": "dist",
+ "declaration": true
+ },
+
+ "include": ["*"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/electron-chrome-extensions/.gitignore b/packages/electron-chrome-extensions/.gitignore
new file mode 100644
index 0000000..4ef0c02
--- /dev/null
+++ b/packages/electron-chrome-extensions/.gitignore
@@ -0,0 +1,3 @@
+dist
+*.map
+*.preload.js
diff --git a/packages/electron-chrome-extensions/.npmignore b/packages/electron-chrome-extensions/.npmignore
new file mode 100644
index 0000000..c32f7da
--- /dev/null
+++ b/packages/electron-chrome-extensions/.npmignore
@@ -0,0 +1,4 @@
+src
+spec
+script
+tsconfig.json
diff --git a/packages/electron-chrome-extensions/README.md b/packages/electron-chrome-extensions/README.md
new file mode 100644
index 0000000..81afbf4
--- /dev/null
+++ b/packages/electron-chrome-extensions/README.md
@@ -0,0 +1,501 @@
+# electron-chrome-extensions
+
+> Chrome extension API support for Electron.
+
+Electron provides [basic support for Chrome extensions](https://www.electronjs.org/docs/api/extensions) out of the box. However, it only supports a subset of APIs with a focus on DevTools. Concepts like tabs, popups, and extension actions aren't known to Electron.
+
+This library aims to bring extension support in Electron up to the level you'd come to expect from a browser like Google Chrome. API behavior is customizable so you can define how to handle things like tab or window creation specific to your application's needs.
+
+## Install
+
+```
+npm install electron-chrome-extensions
+```
+
+## Screenshots
+
+| uBlock Origin | Dark Reader |
+| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+|
|
|
+
+## Usage
+
+### Basic
+
+Simple browser using Electron's [default session](https://www.electronjs.org/docs/api/session#sessiondefaultsession) and one tab.
+
+```js
+const { app, BrowserWindow } = require('electron')
+const { ElectronChromeExtensions } = require('electron-chrome-extensions')
+
+app.whenReady().then(() => {
+ const extensions = new ElectronChromeExtensions()
+ const browserWindow = new BrowserWindow()
+
+ // Adds the active tab of the browser
+ extensions.addTab(browserWindow.webContents, browserWindow)
+
+ browserWindow.loadURL('https://samuelmaddock.com')
+ browserWindow.show()
+})
+```
+
+### Advanced
+
+Multi-tab browser with full support for Chrome extension APIs.
+
+> For a complete example, see the [`electron-browser-shell`](https://github.com/samuelmaddock/electron-browser-shell) project.
+
+```js
+const { app, session, BrowserWindow } = require('electron')
+const { ElectronChromeExtensions } = require('electron-chrome-extensions')
+
+app.whenReady().then(() => {
+ const browserSession = session.fromPartition('persist:custom')
+
+ const extensions = new ElectronChromeExtensions({
+ session: browserSession,
+ createTab(details) {
+ // Optionally implemented for chrome.tabs.create support
+ },
+ selectTab(tab, browserWindow) {
+ // Optionally implemented for chrome.tabs.update support
+ },
+ removeTab(tab, browserWindow) {
+ // Optionally implemented for chrome.tabs.remove support
+ },
+ createWindow(details) {
+ // Optionally implemented for chrome.windows.create support
+ },
+ removeWindow(browserWindow) {
+ // Optionally implemented for chrome.windows.remove support
+ },
+ requestPermissions(extension, permissions) {
+ // Optionally implemented for chrome.permissions.request support
+ },
+ })
+
+ const browserWindow = new BrowserWindow({
+ webPreferences: {
+ // Use same session given to Extensions class
+ session: browserSession,
+ // Required for extension preload scripts
+ sandbox: true,
+ // Recommended for loading remote content
+ contextIsolation: true,
+ },
+ })
+
+ // Adds the active tab of the browser
+ extensions.addTab(browserWindow.webContents, browserWindow)
+
+ browserWindow.loadURL('https://samuelmaddock.com')
+ browserWindow.show()
+})
+```
+
+### Packaging the preload script
+
+This module uses a [preload script](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload#what-is-a-preload-script).
+When packaging your application, it's required that the preload script is included. This can be
+handled in two ways:
+
+1. Include `node_modules` in your packaged app. This allows `electron-chrome-extensions/preload` to
+ be resolved.
+2. In the case of using JavaScript bundlers, you may need to copy the preload script next to your
+ app's entry point script. You can try using
+ [copy-webpack-plugin](https://github.com/webpack-contrib/copy-webpack-plugin),
+ [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy),
+ or [rollup-plugin-copy](https://github.com/vladshcherbin/rollup-plugin-copy) depending on your app's
+ configuration.
+
+Here's an example for webpack configurations:
+
+```js
+module.exports = {
+ entry: './index.js',
+ plugins: [
+ new CopyWebpackPlugin({
+ patterns: [require.resolve('electron-chrome-extensions/preload')],
+ }),
+ ],
+}
+```
+
+## API
+
+### Class: ElectronChromeExtensions
+
+> Create main process handler for Chrome extension APIs.
+
+#### `new ElectronChromeExtensions([options])`
+
+- `options` Object
+ - `license` String - Distribution license compatible with your application. See LICENSE.md for more details. \
+ Valid options include `GPL-3.0`, `Patron-License-2020-11-19`
+ - `session` Electron.Session (optional) - Session which should support
+ Chrome extension APIs. `session.defaultSession` is used by default.
+ - `createTab(details) => Promise<[Electron.WebContents, Electron.BrowserWindow]>` (optional) -
+ Called when `chrome.tabs.create` is invoked by an extension. Allows the
+ application to handle how tabs are created.
+ - `details` [chrome.tabs.CreateProperties](https://developer.chrome.com/docs/extensions/reference/tabs/#method-create)
+ - `selectTab(webContents, browserWindow)` (optional) - Called when
+ `chrome.tabs.update` is invoked by an extension with the option to set the
+ active tab.
+ - `webContents` Electron.WebContents - The tab to be activated.
+ - `browserWindow` Electron.BrowserWindow - The window which owns the tab.
+ - `removeTab(webContents, browserWindow)` (optional) - Called when
+ `chrome.tabs.remove` is invoked by an extension.
+ - `webContents` Electron.WebContents - The tab to be removed.
+ - `browserWindow` Electron.BrowserWindow - The window which owns the tab.
+ - `createWindow(details) => Promise`
+ (optional) - Called when `chrome.windows.create` is invoked by an extension.
+ - `details` [chrome.windows.CreateData](https://developer.chrome.com/docs/extensions/reference/windows/#method-create)
+ - `removeWindow(browserWindow) => Promise`
+ (optional) - Called when `chrome.windows.remove` is invoked by an extension.
+ - `browserWindow` Electron.BrowserWindow
+ - `assignTabDetails(details, webContents) => void` (optional) - Called when `chrome.tabs` creates
+ an object for tab details to be sent to an extension background script. Provide this function to
+ assign custom details such as `discarded`, `frozen`, or `groupId`.
+ - `details` [chrome.tabs.Tab](https://developer.chrome.com/docs/extensions/reference/api/tabs#type-Tab)
+ - `webContents` Electron.WebContents - The tab for which details are being created.
+
+```ts
+new ElectronChromeExtensions({
+ createTab(details) {
+ const tab = myTabApi.createTab()
+ if (details.url) {
+ tab.webContents.loadURL(details.url)
+ }
+ return [tab.webContents, tab.browserWindow]
+ },
+ createWindow(details) {
+ const window = new BrowserWindow()
+ return window
+ },
+})
+```
+
+For a complete usage example, see the browser implementation in the
+[`electron-browser-shell`](https://github.com/samuelmaddock/electron-browser-shell/blob/master/packages/shell/browser/main.js)
+project.
+
+#### Instance Methods
+
+##### `extensions.addTab(tab, window)`
+
+- `tab` Electron.WebContents - A tab that the extension system should keep
+ track of.
+- `window` Electron.BrowserWindow - The window which owns the tab.
+
+Makes the tab accessible from the `chrome.tabs` API.
+
+##### `extensions.selectTab(tab)`
+
+- `tab` Electron.WebContents
+
+Notify the extension system that a tab has been selected as the active tab.
+
+##### `extensions.getContextMenuItems(tab, params)`
+
+- `tab` Electron.WebContents - The tab from which the context-menu event originated.
+- `params` Electron.ContextMenuParams - Parameters from the [`context-menu` event](https://www.electronjs.org/docs/api/web-contents#event-context-menu).
+
+Returns [`Electron.MenuItem[]`](https://www.electronjs.org/docs/api/menu-item#class-menuitem) -
+An array of all extension context menu items given the context.
+
+##### `extensions.getURLOverrides()`
+
+Returns `Object` which maps special URL types to an extension URL. See [chrome_urls_overrides](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides) for a list of
+supported URL types.
+
+Example:
+
+```js
+{
+ newtab: 'chrome-extension:///newtab.html'
+}
+```
+
+#### Instance Events
+
+##### Event: 'browser-action-popup-created'
+
+Returns:
+
+- `popup` PopupView - An instance of the popup.
+
+Emitted when a popup is created by the `chrome.browserAction` API.
+
+##### Event: 'url-overrides-updated'
+
+Returns:
+
+- `urlOverrides` Object - A map of url types to extension URLs.
+
+Emitted after an extension is loaded with [chrome_urls_overrides](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides) set.
+
+### Element: ``
+
+
+
+The `` element provides a row of [browser actions](https://developer.chrome.com/extensions/browserAction) which may be pressed to activate the `chrome.browserAction.onClicked` event or display the extension popup.
+
+To enable the element on a webpage, you must define a preload script which injects the API on specific pages.
+
+#### Attributes
+
+- `partition` string (optional) - The `Electron.Session` partition which extensions are loaded in. Defaults to the session in which `` lives.
+- `tab` string (optional) - The tab's `Electron.WebContents` ID to use for displaying
+ the relevant browser action state. Defaults to the active tab of the current browser window.
+- `alignment` string (optional) - How the popup window should be aligned relative to the extension action. Defaults to `bottom left`. Use any assortment of `top`, `bottom`, `left`, and `right`.
+
+#### Browser action example
+
+##### Preload
+
+Inject the browserAction API to make the `` element accessible in your application.
+
+```js
+import { injectBrowserAction } from 'electron-chrome-extensions/browser-action'
+
+// Inject element into our page
+if (location.href === 'webui://browser-chrome.html') {
+ injectBrowserAction()
+}
+```
+
+> The use of `import` implies that your preload script must be compiled using a JavaScript bundler like Webpack.
+
+##### Webpage
+
+Add the `` element with attributes appropriate for your application.
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+##### Main process
+
+For extension icons to appear in the list, the `crx://` protocol needs to be handled in the Session
+where it's intended to be displayed.
+
+```js
+import { app, session } from 'electron'
+import { ElectronChromeExtensions } from 'electron-chrome-extensions'
+
+app.whenReady().then(() => {
+ // Provide the session where your app will display
+ const appSession = session.defaultSession
+ ElectronChromeExtensions.handleCRXProtocol(appSession)
+})
+```
+
+##### Custom CSS
+
+The `` element is a [Web Component](https://developer.mozilla.org/en-US/docs/Web/Web_Components). Its styles are encapsulated within a [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM). However, it's still possible to customize its appearance using the [CSS shadow parts](https://developer.mozilla.org/en-US/docs/Web/CSS/::part) selector `::part(name)`.
+
+Accessible parts include `action` and `badge`.
+
+```css
+/* Layout action buttons vertically. */
+browser-action-list {
+ flex-direction: column;
+}
+
+/* Modify size of action buttons. */
+browser-action-list::part(action) {
+ width: 16px;
+ height: 16px;
+}
+
+/* Modify hover styles of action buttons. */
+browser-action-list::part(action):hover {
+ background-color: red;
+ border-radius: 0;
+}
+```
+
+## Supported `chrome.*` APIs
+
+The following APIs are supported, in addition to [those already built-in to Electron.](https://www.electronjs.org/docs/api/extensions)
+
+
+Click to reveal supported APIs
+
+### [`chrome.action`](https://developer.chrome.com/extensions/action)
+
+- [x] chrome.action.setTitle
+- [x] chrome.action.getTitle
+- [x] chrome.action.setIcon
+- [x] chrome.action.setPopup
+- [x] chrome.action.getPopup
+- [x] chrome.action.setBadgeText
+- [x] chrome.action.getBadgeText
+- [x] chrome.action.setBadgeBackgroundColor
+- [x] chrome.action.getBadgeBackgroundColor
+- [ ] chrome.action.enable
+- [ ] chrome.action.disable
+- [x] chrome.action.openPopup
+- [x] chrome.action.onClicked
+
+### [`chrome.commands`](https://developer.chrome.com/extensions/commands)
+
+- [ ] chrome.commands.getAll
+- [ ] chrome.commands.onCommand
+
+### [`chrome.cookies`](https://developer.chrome.com/extensions/cookies)
+
+- [x] chrome.cookies.get
+- [x] chrome.cookies.getAll
+- [x] chrome.cookies.set
+- [x] chrome.cookies.remove
+- [x] chrome.cookies.getAllCookieStores
+- [x] chrome.cookies.onChanged
+
+### [`chrome.contextMenus`](https://developer.chrome.com/extensions/contextMenus)
+
+- [x] chrome.contextMenus.create
+- [ ] chrome.contextMenus.update
+- [x] chrome.contextMenus.remove
+- [x] chrome.contextMenus.removeAll
+- [x] chrome.contextMenus.onClicked
+
+### [`chrome.notifications`](https://developer.chrome.com/extensions/notifications)
+
+- [x] chrome.notifications.clear
+- [x] chrome.notifications.create
+- [x] chrome.notifications.getAll
+- [x] chrome.notifications.getPermissionLevel
+- [x] chrome.notifications.update
+- [ ] chrome.notifications.onButtonClicked
+- [x] chrome.notifications.onClicked
+- [x] chrome.notifications.onClosed
+
+See [Electron's Notification tutorial](https://www.electronjs.org/docs/tutorial/notifications) for how to support them in your app.
+
+### [`chrome.runtime`](https://developer.chrome.com/extensions/runtime)
+
+- [x] chrome.runtime.connect
+- [x] chrome.runtime.getBackgroundPage
+- [x] chrome.runtime.getManifest
+- [x] chrome.runtime.getURL
+- [x] chrome.runtime.id
+- [x] chrome.runtime.lastError
+- [x] chrome.runtime.onConnect
+- [x] chrome.runtime.onInstalled
+- [x] chrome.runtime.onMessage
+- [x] chrome.runtime.onStartup
+- [x] chrome.runtime.onSuspend
+- [x] chrome.runtime.onSuspendCanceled
+- [x] chrome.runtime.openOptionsPage
+- [x] chrome.runtime.sendMessage
+
+### [`chrome.storage`](https://developer.chrome.com/extensions/storage)
+
+- [x] chrome.storage.local
+- [x] chrome.storage.managed - fallback to `local`
+- [x] chrome.storage.sync - fallback to `local`
+
+### [`chrome.tabs`](https://developer.chrome.com/extensions/tabs)
+
+- [x] chrome.tabs.get
+- [x] chrome.tabs.getCurrent
+- [x] chrome.tabs.connect
+- [x] chrome.tabs.sendMessage
+- [x] chrome.tabs.create
+- [ ] chrome.tabs.duplicate
+- [x] chrome.tabs.query
+- [ ] chrome.tabs.highlight
+- [x] chrome.tabs.update
+- [ ] chrome.tabs.move
+- [x] chrome.tabs.reload
+- [x] chrome.tabs.remove
+- [ ] chrome.tabs.detectLanguage
+- [ ] chrome.tabs.captureVisibleTab
+- [x] chrome.tabs.executeScript
+- [x] chrome.tabs.insertCSS
+- [x] chrome.tabs.setZoom
+- [x] chrome.tabs.getZoom
+- [x] chrome.tabs.setZoomSettings
+- [x] chrome.tabs.getZoomSettings
+- [ ] chrome.tabs.discard
+- [x] chrome.tabs.goForward
+- [x] chrome.tabs.goBack
+- [x] chrome.tabs.onCreated
+- [x] chrome.tabs.onUpdated
+- [ ] chrome.tabs.onMoved
+- [x] chrome.tabs.onActivated
+- [ ] chrome.tabs.onHighlighted
+- [ ] chrome.tabs.onDetached
+- [ ] chrome.tabs.onAttached
+- [x] chrome.tabs.onRemoved
+- [ ] chrome.tabs.onReplaced
+- [x] chrome.tabs.onZoomChange
+
+> [!NOTE]
+> Electron does not provide tab functionality such as discarded, frozen, or group IDs. If an
+> application developer wishes to implement this functionality, emit a `"tab-updated"` event on the
+> tab's WebContents for `chrome.tabs.onUpdated` to be made aware of changes. Tab properties can be
+> assigned using the `assignTabDetails` option provided to the `ElectronChromeExtensions`
+> constructor.
+
+### [`chrome.webNavigation`](https://developer.chrome.com/extensions/webNavigation)
+
+- [x] chrome.webNavigation.getFrame (Electron 12+)
+- [x] chrome.webNavigation.getAllFrames (Electron 12+)
+- [x] chrome.webNavigation.onBeforeNavigate
+- [x] chrome.webNavigation.onCommitted
+- [x] chrome.webNavigation.onDOMContentLoaded
+- [x] chrome.webNavigation.onCompleted
+- [ ] chrome.webNavigation.onErrorOccurred
+- [x] chrome.webNavigation.onCreateNavigationTarget
+- [ ] chrome.webNavigation.onReferenceFragmentUpdated
+- [ ] chrome.webNavigation.onTabReplaced
+- [x] chrome.webNavigation.onHistoryStateUpdated
+
+### [`chrome.windows`](https://developer.chrome.com/extensions/windows)
+
+- [x] chrome.windows.get
+- [x] chrome.windows.getCurrent
+- [x] chrome.windows.getLastFocused
+- [x] chrome.windows.getAll
+- [x] chrome.windows.create
+- [x] chrome.windows.update
+- [x] chrome.windows.remove
+- [x] chrome.windows.onCreated
+- [x] chrome.windows.onRemoved
+- [x] chrome.windows.onFocusChanged
+- [x] chrome.windows.onBoundsChanged
+
+
+## Limitations
+
+### electron-chrome-extensions
+
+- The latest version of Electron is recommended. Minimum support requires Electron v35.0.0-beta.8.
+- All background scripts are persistent.
+
+### electron
+
+- Usage of Electron's `webRequest` API will prevent `chrome.webRequest` listeners from being called.
+- Chrome extensions are not supported in non-persistent/incognito sessions.
+- `chrome.webNavigation.onDOMContentLoaded` is only emitted for the top frame until [support for iframes](https://github.com/electron/electron/issues/27344) is added.
+- Service worker preload scripts require Electron's sandbox to be enabled. This is the default behavior, but might be overridden by the `--no-sandbox` flag or `sandbox: false` in the `webPreferences` of a `BrowserWindow`. Check for the `--no-sandbox` flag using `ps -eaf | grep `.
+
+## License
+
+GPL-3
+
+For proprietary use, please [contact me](mailto:sam@samuelmaddock.com?subject=electron-chrome-extensions%20license) or [sponsor me on GitHub](https://github.com/sponsors/samuelmaddock/) under the appropriate tier to [acquire a proprietary-use license](https://github.com/samuelmaddock/electron-browser-shell/blob/master/LICENSE-PATRON.md). These contributions help make development and maintenance of this project more sustainable and show appreciation for the work thus far.
diff --git a/packages/electron-chrome-extensions/esbuild.config.js b/packages/electron-chrome-extensions/esbuild.config.js
new file mode 100644
index 0000000..19737e0
--- /dev/null
+++ b/packages/electron-chrome-extensions/esbuild.config.js
@@ -0,0 +1,55 @@
+const packageJson = require('./package.json')
+const { createConfig, build, EXTERNAL_BASE } = require('../../build/esbuild/esbuild.config.base')
+
+console.log(`building ${packageJson.name}`)
+
+const external = [...EXTERNAL_BASE, 'electron-chrome-extensions/preload']
+
+const browserConfig = createConfig({
+ entryPoints: ['src/index.ts'],
+ outfile: 'dist/cjs/index.js',
+ platform: 'node',
+ external,
+})
+
+const browserESMConfig = createConfig({
+ entryPoints: ['src/index.ts'],
+ outfile: 'dist/esm/index.mjs',
+ platform: 'node',
+ external,
+ format: 'esm',
+})
+
+build(browserConfig)
+build(browserESMConfig)
+
+const preloadConfig = createConfig({
+ entryPoints: ['src/preload.ts'],
+ outfile: 'dist/chrome-extension-api.preload.js',
+ platform: 'browser',
+ external,
+ sourcemap: false,
+})
+
+build(preloadConfig)
+
+const browserActionPreloadConfig = createConfig({
+ entryPoints: ['src/browser-action.ts'],
+ outfile: 'dist/cjs/browser-action.js',
+ platform: 'browser',
+ format: 'cjs',
+ external,
+ sourcemap: false,
+})
+
+const browserActionESMPreloadConfig = createConfig({
+ entryPoints: ['src/browser-action.ts'],
+ outfile: 'dist/esm/browser-action.mjs',
+ platform: 'browser',
+ external,
+ sourcemap: false,
+ format: 'esm',
+})
+
+build(browserActionPreloadConfig)
+build(browserActionESMPreloadConfig)
diff --git a/packages/electron-chrome-extensions/package.json b/packages/electron-chrome-extensions/package.json
new file mode 100644
index 0000000..af99748
--- /dev/null
+++ b/packages/electron-chrome-extensions/package.json
@@ -0,0 +1,61 @@
+{
+ "name": "electron-chrome-extensions",
+ "version": "1.0.0",
+ "description": "Chrome extension support for Electron",
+ "main": "./dist/cjs/index.js",
+ "module": "./dist/esm/index.mjs",
+ "types": "./dist/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/esm/index.mjs",
+ "require": "./dist/cjs/index.js"
+ },
+ "./browser-action": {
+ "types": "./dist/types/browser-action.d.ts",
+ "import": "./dist/esm/browser-action.mjs",
+ "require": "./dist/cjs/browser-action.js"
+ },
+ "./dist/browser-action": {
+ "types": "./dist/types/browser-action.d.ts",
+ "import": "./dist/esm/browser-action.mjs",
+ "require": "./dist/cjs/browser-action.js"
+ },
+ "./preload": "./dist/chrome-extension-api.preload.js"
+ },
+ "scripts": {
+ "build": "yarn clean && tsc && node esbuild.config.js",
+ "clean": "node ../../scripts/clean.js",
+ "prepublishOnly": "NODE_ENV=production yarn build",
+ "pretest": "esbuild spec/fixtures/crx-test-preload.ts --bundle --external:electron --outfile=spec/fixtures/crx-test-preload.js --platform=node",
+ "test": "node ./script/spec-runner.js"
+ },
+ "keywords": [
+ "electron",
+ "chrome",
+ "extensions"
+ ],
+ "repository": "",
+ "author": "Damon ",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "dependencies": {
+ "debug": "^4.3.1"
+ },
+ "devDependencies": {
+ "@types/chai": "^4.2.14",
+ "@types/chai-as-promised": "^7.1.3",
+ "@types/chrome": "^0.0.300",
+ "@types/mocha": "^8.0.4",
+ "chai": "^4.2.0",
+ "chai-as-promised": "^7.1.1",
+ "colors": "^1.4.0",
+ "electron": "^38.2.2",
+ "esbuild": "^0.24.2",
+ "minimist": "^1.2.7",
+ "mocha": "^8.2.1",
+ "ts-node": "^10.9.1",
+ "typescript": "^4.9.4",
+ "walkdir": "^0.4.1"
+ },
+ "peerDependencies": {}
+}
diff --git a/packages/electron-chrome-extensions/script/native-messaging-host/.gitignore b/packages/electron-chrome-extensions/script/native-messaging-host/.gitignore
new file mode 100644
index 0000000..d338ecf
--- /dev/null
+++ b/packages/electron-chrome-extensions/script/native-messaging-host/.gitignore
@@ -0,0 +1,3 @@
+crxtesthost
+crxtesthost.blob
+crxtesthost.exe
diff --git a/packages/electron-chrome-extensions/script/native-messaging-host/build.js b/packages/electron-chrome-extensions/script/native-messaging-host/build.js
new file mode 100755
index 0000000..494a425
--- /dev/null
+++ b/packages/electron-chrome-extensions/script/native-messaging-host/build.js
@@ -0,0 +1,106 @@
+#!/usr/bin/env node
+
+const { promises: fs } = require('node:fs')
+const path = require('node:path')
+const os = require('node:os')
+const util = require('node:util')
+const cp = require('node:child_process')
+const exec = util.promisify(cp.exec)
+
+const basePath = 'script/native-messaging-host/'
+const outDir = path.join(__dirname, '.')
+const exeName = `crxtesthost${process.platform === 'win32' ? '.exe' : ''}`
+const seaBlobName = 'crxtesthost.blob'
+
+async function createSEA() {
+ await fs.rm(path.join(outDir, seaBlobName), { force: true })
+ await fs.rm(path.join(outDir, exeName), { force: true })
+
+ await exec('node --experimental-sea-config sea-config.json', { cwd: outDir })
+ await fs.cp(process.execPath, path.join(outDir, exeName))
+
+ if (process.platform === 'darwin') {
+ await exec(`codesign --remove-signature ${exeName}`, { cwd: outDir })
+ }
+
+ console.info(`Building ${exeName}…`)
+ const buildCmd = [
+ 'npx postject',
+ `${basePath}${exeName}`,
+ 'NODE_SEA_BLOB',
+ `${basePath}${seaBlobName}`,
+ '--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2',
+ ...(process.platform === 'darwin' ? ['--macho-segment-name NODE_SEA'] : []),
+ ]
+ await exec(buildCmd.join(' '), { cwd: outDir })
+
+ if (process.platform === 'darwin') {
+ await exec(`codesign --sign - ${exeName}`, { cwd: outDir })
+ }
+}
+
+async function installConfig(extensionIds) {
+ console.info(`Installing config…`)
+
+ const hostName = 'com.crx.test'
+ const manifest = {
+ name: hostName,
+ description: 'electron-chrome-extensions test',
+ path: path.join(outDir, exeName),
+ type: 'stdio',
+ allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`),
+ }
+
+ const writeManifest = async (manifestPath) => {
+ await fs.mkdir(manifestPath, { recursive: true })
+ const filePath = path.join(manifestPath, `${hostName}.json`)
+ const data = Buffer.from(JSON.stringify(manifest, null, 2))
+ await fs.writeFile(filePath, data)
+ return filePath
+ }
+
+ switch (process.platform) {
+ case 'darwin': {
+ const manifestDir = path.join(
+ os.homedir(),
+ 'Library',
+ 'Application Support',
+ 'Electron',
+ 'NativeMessagingHosts',
+ )
+ await writeManifest(manifestDir)
+ break
+ }
+ case 'win32': {
+ const manifestDir = path.join(
+ os.homedir(),
+ 'AppData',
+ 'Roaming',
+ 'Electron',
+ 'NativeMessagingHosts',
+ )
+ const manifestPath = await writeManifest(manifestDir)
+ const registryKey = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${hostName}`
+ await exec(`reg add "${registryKey}" /ve /t REG_SZ /d "${manifestPath}" /f`, {
+ stdio: 'inherit',
+ })
+ break
+ }
+ default:
+ return
+ }
+}
+
+async function main() {
+ const extensionIdsArg = process.argv[2]
+ if (!extensionIdsArg) {
+ console.error('Must pass in csv of allowed extension IDs')
+ process.exit(1)
+ }
+
+ const extensionIds = extensionIdsArg.split(',')
+ await createSEA()
+ await installConfig(extensionIds)
+}
+
+main()
diff --git a/packages/electron-chrome-extensions/script/native-messaging-host/main.js b/packages/electron-chrome-extensions/script/native-messaging-host/main.js
new file mode 100644
index 0000000..e98d6ec
--- /dev/null
+++ b/packages/electron-chrome-extensions/script/native-messaging-host/main.js
@@ -0,0 +1,26 @@
+const fs = require('node:fs')
+
+function readMessage() {
+ let buffer = Buffer.alloc(4)
+ if (fs.readSync(0, buffer, 0, 4, null) !== 4) {
+ process.exit(1)
+ }
+
+ let messageLength = buffer.readUInt32LE(0)
+ let messageBuffer = Buffer.alloc(messageLength)
+ fs.readSync(0, messageBuffer, 0, messageLength, null)
+
+ return JSON.parse(messageBuffer.toString())
+}
+
+function sendMessage(message) {
+ let json = JSON.stringify(message)
+ let buffer = Buffer.alloc(4 + json.length)
+ buffer.writeUInt32LE(json.length, 0)
+ buffer.write(json, 4)
+
+ fs.writeSync(1, buffer)
+}
+
+const message = readMessage()
+sendMessage(message)
diff --git a/packages/electron-chrome-extensions/script/native-messaging-host/sea-config.json b/packages/electron-chrome-extensions/script/native-messaging-host/sea-config.json
new file mode 100644
index 0000000..42040c4
--- /dev/null
+++ b/packages/electron-chrome-extensions/script/native-messaging-host/sea-config.json
@@ -0,0 +1,4 @@
+{
+ "main": "main.js",
+ "output": "crxtesthost.blob"
+}
diff --git a/packages/electron-chrome-extensions/script/spec-runner.js b/packages/electron-chrome-extensions/script/spec-runner.js
new file mode 100644
index 0000000..db50550
--- /dev/null
+++ b/packages/electron-chrome-extensions/script/spec-runner.js
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+
+const childProcess = require('child_process')
+const path = require('path')
+const unknownFlags = []
+
+require('colors')
+const pass = '✓'.green
+const fail = '✗'.red
+
+const args = require('minimist')(process.argv, {
+ string: ['target'],
+ unknown: (arg) => unknownFlags.push(arg),
+})
+
+const unknownArgs = []
+for (const flag of unknownFlags) {
+ unknownArgs.push(flag)
+ const onlyFlag = flag.replace(/^-+/, '')
+ if (args[onlyFlag]) {
+ unknownArgs.push(args[onlyFlag])
+ }
+}
+
+async function main() {
+ await runElectronTests()
+}
+
+async function runElectronTests() {
+ const errors = []
+
+ const testResultsDir = process.env.ELECTRON_TEST_RESULTS_DIR
+
+ try {
+ console.info('\nRunning:')
+ if (testResultsDir) {
+ process.env.MOCHA_FILE = path.join(testResultsDir, `test-results.xml`)
+ }
+ await runMainProcessElectronTests()
+ } catch (err) {
+ errors.push([err])
+ }
+
+ if (errors.length !== 0) {
+ for (const err of errors) {
+ console.error('\n\nRunner Failed:', err[0])
+ console.error(err[1])
+ }
+ console.log(`${fail} Electron test runners have failed`)
+ process.exit(1)
+ }
+}
+
+async function runMainProcessElectronTests() {
+ let exe = require('electron')
+ const runnerArgs = ['spec', ...unknownArgs.slice(2)]
+
+ // Fix issue in CI
+ // "The SUID sandbox helper binary was found, but is not configured correctly."
+ if (process.platform === 'linux') {
+ runnerArgs.push('--no-sandbox')
+ }
+
+ const { status, signal } = childProcess.spawnSync(exe, runnerArgs, {
+ cwd: path.resolve(__dirname, '..'),
+ env: process.env,
+ stdio: 'inherit',
+ })
+ if (status !== 0) {
+ if (status) {
+ const textStatus =
+ process.platform === 'win32' ? `0x${status.toString(16)}` : status.toString()
+ console.log(`${fail} Electron tests failed with code ${textStatus}.`)
+ } else {
+ console.log(`${fail} Electron tests failed with kill signal ${signal}.`)
+ }
+ process.exit(1)
+ }
+ console.log(`${pass} Electron main process tests passed.`)
+}
+
+main().catch((error) => {
+ console.error('An error occurred inside the spec runner:', error)
+ process.exit(1)
+})
diff --git a/packages/electron-chrome-extensions/spec/chrome-browserAction-spec.ts b/packages/electron-chrome-extensions/spec/chrome-browserAction-spec.ts
new file mode 100644
index 0000000..2b1961b
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/chrome-browserAction-spec.ts
@@ -0,0 +1,308 @@
+import * as path from 'node:path'
+import { expect } from 'chai'
+import { BrowserView, Extension, ipcMain, session, WebContents, WebContentsView } from 'electron'
+
+import { emittedOnce } from './events-helpers'
+import { uuid } from './spec-helpers'
+import { useExtensionBrowser, useServer } from './hooks'
+import { createCrxRemoteWindow } from './crx-helpers'
+import { ElectronChromeExtensions } from '../'
+
+describe('chrome.browserAction', () => {
+ const server = useServer()
+
+ const defaultAnchorRect = {
+ x: 0,
+ y: 0,
+ width: 16,
+ height: 16,
+ }
+
+ const activateExtension = async (
+ partition: string,
+ webContents: WebContents,
+ extension: Extension,
+ tabId: number = -1,
+ ) => {
+ const details = {
+ eventType: 'click',
+ extensionId: extension.id,
+ tabId,
+ anchorRect: defaultAnchorRect,
+ }
+
+ const js = `browserAction.activate('${partition}', ${JSON.stringify(details)})`
+ await webContents.executeJavaScript(js)
+ }
+
+ describe('messaging', () => {
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'chrome-browserAction-click',
+ })
+
+ it('supports cross-session communication', async () => {
+ const otherSession = session.fromPartition(`persist:crx-${uuid()}`)
+
+ if ('registerPreloadScript' in otherSession) {
+ browser.session.getPreloadScripts().forEach((script: any) => {
+ otherSession.registerPreloadScript(script)
+ })
+ } else {
+ // @ts-expect-error Deprecated electron@<35
+ otherSession.setPreloads(browser.session.getPreloads())
+ }
+
+ const view = new BrowserView({
+ webPreferences: { session: otherSession, nodeIntegration: false, contextIsolation: true },
+ })
+ await view.webContents.loadURL(server.getUrl())
+ browser.window.addBrowserView(view)
+ await activateExtension(browser.partition, view.webContents, browser.extension)
+ })
+
+ it('can request action for specific tab', async () => {
+ const tab = browser.window.webContents
+ await activateExtension(browser.partition, tab, browser.extension, tab.id)
+ })
+
+ it('throws for unknown tab', async () => {
+ const tab = browser.window.webContents
+ const unknownTabId = 99999
+ let caught = false
+ try {
+ await activateExtension(browser.partition, tab, browser.extension, unknownTabId)
+ } catch {
+ caught = true
+ }
+ expect(caught).to.be.true
+ })
+ })
+
+ describe('onClicked', () => {
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'chrome-browserAction-click',
+ })
+
+ it('fires listeners when activated', async () => {
+ const tabPromise = emittedOnce(ipcMain, 'success')
+ await activateExtension(browser.partition, browser.window.webContents, browser.extension)
+ const [_, tabDetails] = await tabPromise
+ expect(tabDetails).to.be.an('object')
+ expect(tabDetails.id).to.equal(browser.window.webContents.id)
+ })
+ })
+
+ describe('popup', () => {
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'chrome-browserAction-popup',
+ })
+
+ it('opens when the browser action is clicked', async () => {
+ const popupPromise = emittedOnce(browser.extensions, 'browser-action-popup-created')
+ await activateExtension(browser.partition, browser.window.webContents, browser.extension)
+ const [popup] = await popupPromise
+ expect(popup.extensionId).to.equal(browser.extension.id)
+ })
+
+ it('opens when BrowserView is the active tab', async () => {
+ const view = new BrowserView({
+ webPreferences: {
+ session: browser.session,
+ nodeIntegration: false,
+ contextIsolation: true,
+ },
+ })
+ await view.webContents.loadURL(server.getUrl())
+ browser.window.addBrowserView(view)
+ browser.extensions.addTab(view.webContents, browser.window)
+ browser.extensions.selectTab(view.webContents)
+
+ const popupPromise = emittedOnce(browser.extensions, 'browser-action-popup-created')
+ await activateExtension(browser.partition, browser.window.webContents, browser.extension)
+ const [popup] = await popupPromise
+ expect(popup.extensionId).to.equal(browser.extension.id)
+ })
+ })
+
+ describe('details', () => {
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'rpc',
+ })
+
+ const props = [
+ { method: 'BadgeBackgroundColor', detail: 'color', value: '#cacaca' },
+ { method: 'BadgeText', detail: 'text' },
+ { method: 'Popup', detail: 'popup' },
+ { method: 'Title', detail: 'title' },
+ ]
+
+ for (const { method, detail, value } of props) {
+ it(`sets and gets '${detail}'`, async () => {
+ const newValue = value || uuid()
+ await browser.crx.exec(`browserAction.set${method}`, { [detail]: newValue })
+ const result = await browser.crx.exec(`browserAction.get${method}`)
+ expect(result).to.equal(newValue)
+ })
+
+ it(`restores initial values for '${detail}'`, async () => {
+ const newValue = value || uuid()
+ const initial = await browser.crx.exec(`browserAction.get${method}`)
+ await browser.crx.exec(`browserAction.set${method}`, { [detail]: newValue })
+ await browser.crx.exec(`browserAction.set${method}`, { [detail]: null })
+ const result = await browser.crx.exec(`browserAction.get${method}`)
+ expect(result).to.equal(initial)
+ })
+ }
+
+ it('uses custom popup when opening browser action', async () => {
+ const popupUuid = uuid()
+ const popupPath = `popup.html?${popupUuid}`
+ await browser.crx.exec('browserAction.setPopup', { popup: popupPath })
+ const popupPromise = emittedOnce(browser.extensions, 'browser-action-popup-created')
+ await activateExtension(browser.partition, browser.window.webContents, browser.extension)
+ const [popup] = await popupPromise
+ await popup.whenReady()
+ expect(popup.browserWindow.webContents.getURL()).to.equal(
+ `chrome-extension://${browser.extension.id}/${popupPath}`,
+ )
+ })
+ })
+
+ describe(' element', () => {
+ const basePath = path.join(__dirname, 'fixtures/browser-action-list')
+
+ const browser = useExtensionBrowser({
+ extensionName: 'chrome-browserAction-popup',
+ })
+
+ const getExtensionActionIds = async (
+ webContents: Electron.WebContents = browser.webContents,
+ ) => {
+ // Await update propagation to avoid flaky tests
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ return await webContents.executeJavaScript(
+ `(${() => {
+ const list = document.querySelector('browser-action-list')!
+ const actions = list.shadowRoot!.querySelectorAll('.action')
+ const ids = Array.from(actions).map((elem) => elem.id)
+ return ids
+ }})();`,
+ )
+ }
+
+ it('lists actions', async () => {
+ await browser.webContents.loadFile(path.join(basePath, 'default.html'))
+ const extensionIds = await getExtensionActionIds()
+ expect(extensionIds).to.deep.equal([browser.extension.id])
+ })
+
+ it('lists actions in remote partition', async () => {
+ const remoteWindow = createCrxRemoteWindow()
+ const remoteTab = remoteWindow.webContents
+
+ await remoteTab.loadURL(server.getUrl())
+
+ // Add for remote partition.
+ await remoteTab.executeJavaScript(
+ `(${(partition: string) => {
+ const list = document.createElement('browser-action-list')
+ list.setAttribute('partition', partition)
+ document.body.appendChild(list)
+ }})('${browser.partition}');`,
+ )
+
+ const extensionIds = await getExtensionActionIds(remoteTab)
+ expect(extensionIds).to.deep.equal([browser.extension.id])
+ })
+
+ it('removes action for unloaded extension', async () => {
+ await browser.webContents.loadFile(path.join(basePath, 'default.html'))
+ expect(browser.session.getExtension(browser.extension.id)).to.be.an('object')
+ browser.session.removeExtension(browser.extension.id)
+ expect(browser.session.getExtension(browser.extension.id)).to.be.an('null')
+
+ const extensionIds = await getExtensionActionIds()
+ expect(extensionIds).to.have.lengthOf(0)
+ })
+ })
+
+ describe('crx:// protocol', () => {
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'chrome-browserAction-popup',
+ })
+
+ it('supports same-session requests', async () => {
+ ElectronChromeExtensions.handleCRXProtocol(browser.session)
+
+ // Load again now that crx protocol is handled
+ await browser.webContents.loadURL(server.getUrl())
+
+ const result = await browser.webContents.executeJavaScript(
+ `(${function (extensionId: any, tabId: any) {
+ const img = document.createElement('img')
+ const params = new URLSearchParams({
+ tabId: `${tabId}`,
+ t: `${Date.now()}`,
+ })
+ const src = `crx://extension-icon/${extensionId}/32/2?${params.toString()}`
+ return new Promise((resolve, reject) => {
+ img.onload = () => resolve('success')
+ img.onerror = () => {
+ reject(new Error('error loading img, check devtools console' + src))
+ }
+ img.src = src
+ })
+ }})(${[browser.extension.id, browser.webContents.id]
+ .map((v) => JSON.stringify(v))
+ .join(', ')});`,
+ )
+
+ expect(result).to.equal('success')
+ })
+
+ it('supports cross-session requests', async () => {
+ const extensionsPartition = browser.partition
+ const otherSession = session.fromPartition(`persist:crx-${uuid()}`)
+ ElectronChromeExtensions.handleCRXProtocol(otherSession)
+
+ browser.session.getPreloadScripts().forEach((script) => {
+ otherSession.registerPreloadScript(script)
+ })
+
+ const view = new WebContentsView({
+ webPreferences: { session: otherSession, nodeIntegration: false, contextIsolation: true },
+ })
+ browser.window.contentView.addChildView(view)
+ await view.webContents.loadURL(server.getUrl())
+
+ const result = await view.webContents.executeJavaScript(
+ `(${function (extensionId: any, tabId: any, partition: any) {
+ const img = document.createElement('img')
+ const params = new URLSearchParams({
+ tabId: `${tabId}`,
+ partition,
+ t: `${Date.now()}`,
+ })
+ const src = `crx://extension-icon/${extensionId}/32/2?${params.toString()}`
+ return new Promise((resolve, reject) => {
+ img.onload = () => resolve('success')
+ img.onerror = () => {
+ reject(new Error('error loading img, check devtools console'))
+ }
+ img.src = src
+ })
+ }})(${[browser.extension.id, browser.webContents.id, extensionsPartition]
+ .map((v) => JSON.stringify(v))
+ .join(', ')});`,
+ )
+
+ expect(result).to.equal('success')
+ })
+ })
+})
diff --git a/packages/electron-chrome-extensions/spec/chrome-contextMenus-spec.ts b/packages/electron-chrome-extensions/spec/chrome-contextMenus-spec.ts
new file mode 100644
index 0000000..10d6888
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/chrome-contextMenus-spec.ts
@@ -0,0 +1,98 @@
+import { expect } from 'chai'
+import { ipcMain } from 'electron'
+import { once } from 'node:events'
+
+import { useExtensionBrowser, useServer } from './hooks'
+import { uuid } from './spec-helpers'
+
+describe('chrome.contextMenus', () => {
+ const server = useServer()
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'rpc',
+ })
+
+ const getContextMenuItems = async () => {
+ // TODO: why is this needed since upgrading to Electron 22?
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ const contextMenuPromise = once(browser.webContents, 'context-menu')
+
+ // Simulate right-click to create context-menu event.
+ const opts = { x: 0, y: 0, button: 'right' as any }
+ browser.webContents.sendInputEvent({ ...opts, type: 'mouseDown' })
+ browser.webContents.sendInputEvent({ ...opts, type: 'mouseUp' })
+
+ const [, params] = await contextMenuPromise
+ return browser.extensions.getContextMenuItems(browser.webContents, params)
+ }
+
+ describe('create()', () => {
+ it('creates item with label', async () => {
+ const id = uuid()
+ const title = 'ヤッホー'
+ await browser.crx.exec('contextMenus.create', { id, title })
+ const items = await getContextMenuItems()
+ expect(items).to.have.lengthOf(1)
+ expect(items[0].id).to.equal(id)
+ expect(items[0].label).to.equal(title)
+ })
+
+ it('creates a child item', async () => {
+ const parentId = uuid()
+ const id = uuid()
+ await browser.crx.exec('contextMenus.create', { id: parentId, title: 'parent' })
+ await browser.crx.exec('contextMenus.create', { id, parentId, title: 'child' })
+ const items = await getContextMenuItems()
+ expect(items).to.have.lengthOf(1)
+ expect(items[0].label).to.equal('parent')
+ expect(items[0].submenu).to.be.an('object')
+ expect(items[0].submenu!.items).to.have.lengthOf(1)
+ expect(items[0].submenu!.items[0].label).to.equal('child')
+ })
+
+ it('groups multiple top-level items', async () => {
+ await browser.crx.exec('contextMenus.create', { id: uuid(), title: 'one' })
+ await browser.crx.exec('contextMenus.create', { id: uuid(), title: 'two' })
+ const items = await getContextMenuItems()
+ expect(items).to.have.lengthOf(1)
+ expect(items[0].label).to.equal(browser.extension.name)
+ expect(items[0].submenu).to.be.an('object')
+ expect(items[0].submenu!.items).to.have.lengthOf(2)
+ expect(items[0].submenu!.items[0].label).to.equal('one')
+ expect(items[0].submenu!.items[1].label).to.equal('two')
+ })
+
+ it('invokes the create callback', async () => {
+ const ipcName = 'create-callback'
+ await browser.crx.exec('contextMenus.create', {
+ title: 'callback',
+ onclick: { __IPC_FN__: ipcName },
+ })
+ const items = await getContextMenuItems()
+ const p = once(ipcMain, ipcName)
+ items[0].click()
+ await p
+ })
+ })
+
+ describe('remove()', () => {
+ it('removes item', async () => {
+ const id = uuid()
+ await browser.crx.exec('contextMenus.create', { id })
+ await browser.crx.exec('contextMenus.remove', id)
+ const items = await getContextMenuItems()
+ expect(items).to.be.empty
+ })
+ })
+
+ describe('removeAll()', () => {
+ it('removes all items', async () => {
+ await browser.crx.exec('contextMenus.create', {})
+ await browser.crx.exec('contextMenus.create', {})
+ await browser.crx.exec('contextMenus.removeAll')
+ const items = await getContextMenuItems()
+ expect(items).to.be.empty
+ })
+ })
+})
diff --git a/packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts b/packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts
new file mode 100644
index 0000000..840f15c
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts
@@ -0,0 +1,43 @@
+import { expect } from 'chai'
+import { randomUUID } from 'node:crypto'
+import { promisify } from 'node:util'
+import * as cp from 'node:child_process'
+import * as path from 'node:path'
+const exec = promisify(cp.exec)
+
+import { useExtensionBrowser, useServer } from './hooks'
+import { getExtensionId } from './crx-helpers'
+
+// TODO: build crxtesthost on Linux (see script/native-messaging-host/build.js)
+if (process.platform !== 'linux') {
+ describe('nativeMessaging', () => {
+ const server = useServer()
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'rpc',
+ })
+ const hostApplication = 'com.crx.test'
+
+ before(async function () {
+ this.timeout(60e3)
+ const extensionId = await getExtensionId('rpc')
+ const nativeMessagingPath = path.join(__dirname, '..', 'script', 'native-messaging-host')
+ const buildScript = path.join(nativeMessagingPath, 'build.js')
+ await exec(`node ${buildScript} ${extensionId}`)
+ })
+
+ describe('sendNativeMessage()', () => {
+ it('sends and receives primitive value', async () => {
+ const value = randomUUID()
+ const result = await browser.crx.exec('runtime.sendNativeMessage', hostApplication, value)
+ expect(result).to.equal(value)
+ })
+
+ it('sends and receives object', async () => {
+ const value = { json: randomUUID(), wow: 'nice' }
+ const result = await browser.crx.exec('runtime.sendNativeMessage', hostApplication, value)
+ expect(result).to.deep.equal(value)
+ })
+ })
+ })
+}
diff --git a/packages/electron-chrome-extensions/spec/chrome-notifications-spec.ts b/packages/electron-chrome-extensions/spec/chrome-notifications-spec.ts
new file mode 100644
index 0000000..c2780a8
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/chrome-notifications-spec.ts
@@ -0,0 +1,57 @@
+import { expect } from 'chai'
+
+import { useExtensionBrowser, useServer } from './hooks'
+import { uuid } from './spec-helpers'
+
+const basicOpts: chrome.notifications.NotificationOptions = {
+ type: 'basic',
+ title: 'title',
+ message: 'message',
+ iconUrl: 'icon_16.png',
+ silent: true,
+}
+
+describe('chrome.notifications', () => {
+ const server = useServer()
+ const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'rpc' })
+
+ describe('create()', () => {
+ it('creates and shows a basic notification', async () => {
+ const notificationId = uuid()
+ const result = await browser.crx.exec('notifications.create', notificationId, basicOpts)
+ expect(result).to.equal(notificationId)
+ await browser.crx.exec('notifications.clear', notificationId)
+ })
+
+ it('ignores invalid options', async () => {
+ const notificationId = uuid()
+ const result = await browser.crx.exec('notifications.create', notificationId, {})
+ expect(result).is.null
+ })
+
+ it('ignores icons outside of extensions directory', async () => {
+ const notificationId = uuid()
+ const result = await browser.crx.exec('notifications.create', notificationId, {
+ ...basicOpts,
+ iconUrl: '../chrome-browserAction/icon_16.png',
+ })
+ expect(result).is.null
+ })
+
+ it('creates a notification with no ID given', async () => {
+ const notificationId = await browser.crx.exec('notifications.create', basicOpts)
+ expect(notificationId).to.be.string
+ await browser.crx.exec('notifications.clear', notificationId)
+ })
+ })
+
+ describe('getAll()', () => {
+ it('lists created notification', async () => {
+ const notificationId = uuid()
+ await browser.crx.exec('notifications.create', notificationId, basicOpts)
+ const list = await browser.crx.exec('notifications.getAll')
+ expect(list).to.deep.equal([notificationId])
+ await browser.crx.exec('notifications.clear', notificationId)
+ })
+ })
+})
diff --git a/packages/electron-chrome-extensions/spec/chrome-tabs-spec.ts b/packages/electron-chrome-extensions/spec/chrome-tabs-spec.ts
new file mode 100644
index 0000000..05b6eda
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/chrome-tabs-spec.ts
@@ -0,0 +1,299 @@
+import { expect } from 'chai'
+import { app, BrowserWindow } from 'electron'
+import { emittedOnce } from './events-helpers'
+
+import { useExtensionBrowser, useServer } from './hooks'
+import { ChromeExtensionImpl } from '../dist/types/browser/impl'
+
+describe('chrome.tabs', () => {
+ let assignTabDetails: ChromeExtensionImpl['assignTabDetails']
+
+ const server = useServer()
+ const browser = useExtensionBrowser({
+ url: server.getUrl,
+ extensionName: 'rpc',
+ assignTabDetails(details, tab) {
+ assignTabDetails?.(details, tab)
+ },
+ })
+
+ afterEach(() => {
+ assignTabDetails = undefined
+ })
+
+ describe('get()', () => {
+ it('returns tab details', async () => {
+ const tabId = browser.window.webContents.id
+ const result = await browser.crx.exec('tabs.get', tabId)
+ expect(result).to.be.an('object')
+ expect(result.id).to.equal(tabId)
+ expect(result.windowId).to.equal(browser.window.id)
+ })
+ })
+
+ describe('getCurrent()', () => {
+ it('gets details of the active tab', async () => {
+ const result = await browser.crx.exec('tabs.getCurrent')
+ expect(result).to.be.an('object')
+ })
+ })
+
+ describe('create()', () => {
+ it('creates a tab', async () => {
+ const wcPromise = emittedOnce(app, 'web-contents-created')
+ const tabInfo = await browser.crx.exec('tabs.create', { url: server.getUrl() })
+ const [, wc] = await wcPromise
+ expect(tabInfo).to.be.an('object')
+ expect(tabInfo.id).to.equal(wc.id)
+ expect(tabInfo.active).to.equal(true)
+ expect(tabInfo.url).to.equal(server.getUrl())
+ expect(tabInfo.windowId).to.equal(browser.window.id)
+ expect(tabInfo.title).to.be.a('string')
+ })
+
+ // TODO: Navigating to chrome-extension:// receives ERR_BLOCKED_BY_CLIENT (-20)
+ it.skip('resolves relative URL', async () => {
+ const relativeUrl = './options.html'
+ const tabInfo = await browser.crx.exec('tabs.create', { url: relativeUrl })
+ const url = new URL(relativeUrl, browser.extension.url).href
+ expect(tabInfo).to.be.an('object')
+ expect(tabInfo.url).to.equal(url)
+ })
+
+ it('fails on chrome:// URLs', async () => {
+ const tabInfo = await browser.crx.exec('tabs.create', { url: 'chrome://kill' })
+ expect(tabInfo).to.be.a('null')
+ })
+
+ it('fails on javascript: URLs', async () => {
+ const tabInfo = browser.crx.exec('tabs.create', { url: "javascript:alert('hacked')" })
+ expect(await tabInfo).to.be.a('null')
+ })
+ })
+
+ describe('query()', () => {
+ it('gets the active tab', async () => {
+ const result = await browser.crx.exec('tabs.query', { active: true })
+ expect(result).to.be.an('array')
+ expect(result).to.be.length(1)
+ expect(result[0].id).to.be.equal(browser.window.webContents.id)
+ expect(result[0].windowId).to.be.equal(browser.window.id)
+ })
+
+ it('gets the active tab of multiple windows', async () => {
+ const secondWindow = new BrowserWindow({
+ show: false,
+ webPreferences: {
+ session: browser.session,
+ nodeIntegration: false,
+ contextIsolation: true,
+ },
+ })
+
+ browser.extensions.addTab(secondWindow.webContents, secondWindow)
+
+ const result = await browser.crx.exec('tabs.query', { active: true })
+ expect(result).to.be.an('array')
+ expect(result).to.be.length(2)
+ expect(result[0].windowId).to.be.equal(browser.window.id)
+ expect(result[1].windowId).to.be.equal(secondWindow.id)
+ })
+
+ it('matches exact title', async () => {
+ const results = await browser.crx.exec('tabs.query', { title: 'title' })
+ expect(results).to.be.an('array')
+ expect(results).to.be.length(1)
+ expect(results[0].title).to.be.equal('title')
+ })
+
+ it('matches title pattern', async () => {
+ const results = await browser.crx.exec('tabs.query', { title: '*' })
+ expect(results).to.be.an('array')
+ expect(results).to.be.length(1)
+ expect(results[0].title).to.be.equal('title')
+ })
+
+ it('matches exact url', async () => {
+ const url = server.getUrl()
+ const results = await browser.crx.exec('tabs.query', { url })
+ expect(results).to.be.an('array')
+ expect(results).to.be.length(1)
+ expect(results[0].url).to.be.equal(url)
+ })
+
+ it('matches wildcard url pattern', async () => {
+ const url = 'http://*/*'
+ const results = await browser.crx.exec('tabs.query', { url })
+ expect(results).to.be.an('array')
+ expect(results).to.be.length(1)
+ expect(results[0].url).to.be.equal(server.getUrl())
+ })
+
+ it('matches either url pattern', async () => {
+ const patterns = ['http://foo.bar/*', `${server.getUrl()}*`]
+ const results = await browser.crx.exec('tabs.query', { url: patterns })
+ expect(results).to.be.an('array')
+ expect(results).to.be.length(1)
+ expect(results[0].url).to.be.equal(server.getUrl())
+ })
+ })
+
+ describe('reload()', () => {
+ it('reloads the active tab', async () => {
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.reload')
+ await navigatePromise
+ })
+
+ it('reloads a specified tab', async () => {
+ const tabId = browser.window.webContents.id
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.reload', tabId)
+ await navigatePromise
+ })
+ })
+
+ describe('update()', () => {
+ it('navigates the tab', async () => {
+ const tabId = browser.window.webContents.id
+ const updateUrl = `${server.getUrl()}foo`
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.update', tabId, { url: updateUrl })
+ await navigatePromise
+ expect(browser.window.webContents.getURL()).to.equal(updateUrl)
+ })
+
+ it('navigates the active tab', async () => {
+ const updateUrl = `${server.getUrl()}foo`
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.update', { url: updateUrl })
+ await navigatePromise
+ expect(browser.window.webContents.getURL()).to.equal(updateUrl)
+ })
+
+ it('fails on chrome:// URLs', async () => {
+ const tabId = browser.webContents.id
+ const tabInfo = await browser.crx.exec('tabs.update', tabId, { url: 'chrome://kill' })
+ expect(tabInfo).to.be.a('null')
+ })
+ })
+
+ describe('goForward()', () => {
+ it('navigates the active tab forward', async () => {
+ const initialUrl = browser.window.webContents.getURL()
+ const targetUrl = `${server.getUrl()}foo`
+ await browser.window.webContents.loadURL(targetUrl)
+ expect(browser.window.webContents.navigationHistory.canGoBack()).to.be.true
+ browser.window.webContents.navigationHistory.goBack()
+ await emittedOnce(browser.window.webContents, 'did-navigate')
+ expect(browser.window.webContents.navigationHistory.canGoForward()).to.be.true
+ expect(browser.window.webContents.getURL()).to.equal(initialUrl)
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.goForward')
+ await navigatePromise
+ expect(browser.window.webContents.getURL()).to.equal(targetUrl)
+ })
+
+ it('navigates a specified tab forward', async () => {
+ const tabId = browser.window.webContents.id
+ const initialUrl = browser.window.webContents.getURL()
+ const targetUrl = `${server.getUrl()}foo`
+ await browser.window.webContents.loadURL(targetUrl)
+ expect(browser.window.webContents.navigationHistory.canGoBack()).to.be.true
+ browser.window.webContents.navigationHistory.goBack()
+ await emittedOnce(browser.window.webContents, 'did-navigate')
+ expect(browser.window.webContents.navigationHistory.canGoForward()).to.be.true
+ expect(browser.window.webContents.getURL()).to.equal(initialUrl)
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.goForward', tabId)
+ await navigatePromise
+ expect(browser.window.webContents.getURL()).to.equal(targetUrl)
+ })
+ })
+
+ describe('goBack()', () => {
+ it('navigates the active tab back', async () => {
+ const initialUrl = browser.window.webContents.getURL()
+ await browser.window.webContents.loadURL(`${server.getUrl()}foo`)
+ expect(browser.window.webContents.navigationHistory.canGoBack()).to.be.true
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.goBack')
+ await navigatePromise
+ expect(browser.window.webContents.getURL()).to.equal(initialUrl)
+ })
+
+ it('navigates a specified tab back', async () => {
+ const tabId = browser.window.webContents.id
+ const initialUrl = browser.window.webContents.getURL()
+ await browser.window.webContents.loadURL(`${server.getUrl()}foo`)
+ expect(browser.window.webContents.canGoBack()).to.be.true
+ const navigatePromise = emittedOnce(browser.window.webContents, 'did-navigate')
+ browser.crx.exec('tabs.goBack', tabId)
+ await navigatePromise
+ expect(browser.window.webContents.getURL()).to.equal(initialUrl)
+ })
+ })
+
+ describe('executeScript()', () => {
+ it('injects code into a tab', async () => {
+ const tabId = browser.window.webContents.id
+ const [result] = await browser.crx.exec('tabs.executeScript', tabId, {
+ code: 'location.href',
+ })
+ expect(result).to.equal(browser.window.webContents.getURL())
+ })
+
+ it('injects code into the active tab', async () => {
+ const [result] = await browser.crx.exec('tabs.executeScript', { code: 'location.href' })
+ expect(result).to.equal(browser.window.webContents.getURL())
+ })
+ })
+
+ describe('onCreated', () => {
+ it('emits when tab is added', async () => {
+ const p = browser.crx.eventOnce('tabs.onCreated')
+
+ const secondWindow = new BrowserWindow({
+ show: false,
+ webPreferences: {
+ session: browser.session,
+ nodeIntegration: false,
+ contextIsolation: true,
+ },
+ })
+ const secondTab = secondWindow.webContents
+
+ const url = `${server.getUrl()}foo`
+ await secondWindow.loadURL(url)
+
+ browser.extensions.addTab(secondTab, secondWindow)
+
+ const [tabDetails] = await p
+ expect(tabDetails).to.be.an('object')
+ expect(tabDetails.id).to.equal(secondTab.id)
+ expect(tabDetails.windowId).to.equal(secondWindow.id)
+ expect(tabDetails.url).to.equal(secondTab.getURL())
+ })
+ })
+
+ describe('onUpdated', () => {
+ it('emits on "tab-updated" event', async () => {
+ const p = browser.crx.eventOnce('tabs.onUpdated')
+
+ // Wait for tabs.onUpdated listener to be set
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ assignTabDetails = (details) => {
+ details.discarded = true
+ }
+
+ browser.webContents.emit('tab-updated')
+
+ const [_tabId, changeInfo, _tabDetails] = await p
+ expect(changeInfo).to.be.an('object')
+ expect(Object.keys(changeInfo)).to.have.lengthOf(1)
+ expect(changeInfo).to.haveOwnProperty('discarded')
+ expect(changeInfo.discarded).to.equal(true)
+ })
+ })
+})
diff --git a/packages/electron-chrome-extensions/spec/chrome-webNavigation-spec.ts b/packages/electron-chrome-extensions/spec/chrome-webNavigation-spec.ts
new file mode 100644
index 0000000..1d7ab55
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/chrome-webNavigation-spec.ts
@@ -0,0 +1,40 @@
+import { expect } from 'chai'
+import { ipcMain } from 'electron'
+
+import { useExtensionBrowser, useServer } from './hooks'
+
+describe('chrome.webNavigation', () => {
+ const server = useServer()
+ const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'chrome-webNavigation' })
+
+ // TODO: for some reason 'onCommitted' will sometimes not arrive
+ it.skip('emits events in the correct order', async () => {
+ const expectedEventLog = [
+ 'onBeforeNavigate',
+ 'onCommitted',
+ 'onDOMContentLoaded',
+ 'onCompleted',
+ ]
+
+ const eventsPromise = new Promise((resolve) => {
+ const eventLog: string[] = []
+ ipcMain.on('logEvent', (e, eventName) => {
+ if (eventLog.length === 0 && eventName !== 'onBeforeNavigate') {
+ // ignore events that come in late from initial load
+ return
+ }
+
+ eventLog.push(eventName)
+
+ if (eventLog.length === expectedEventLog.length) {
+ resolve(eventLog)
+ }
+ })
+ })
+
+ await browser.window.webContents.loadURL(`${server.getUrl()}`)
+
+ const eventLog = await eventsPromise
+ expect(eventLog).to.deep.equal(expectedEventLog)
+ })
+})
diff --git a/packages/electron-chrome-extensions/spec/chrome-windows-spec.ts b/packages/electron-chrome-extensions/spec/chrome-windows-spec.ts
new file mode 100644
index 0000000..d999134
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/chrome-windows-spec.ts
@@ -0,0 +1,45 @@
+import { expect } from 'chai'
+import { app, webContents } from 'electron'
+import { emittedOnce } from './events-helpers'
+
+import { useExtensionBrowser, useServer } from './hooks'
+
+describe('chrome.windows', () => {
+ const server = useServer()
+ const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'rpc' })
+
+ describe('get()', () => {
+ it('gets details on the window', async () => {
+ const windowId = browser.window.id
+ const result = await browser.crx.exec('windows.get', windowId)
+ expect(result).to.be.an('object')
+ expect(result.id).to.equal(windowId)
+ })
+ })
+
+ describe('getLastFocused()', () => {
+ it('gets the last focused window', async () => {
+ // HACK: focus() doesn't actually emit this in tests
+ browser.window.emit('focus')
+ const windowId = browser.window.id
+ const result = await browser.crx.exec('windows.getLastFocused')
+ expect(result).to.be.an('object')
+ expect(result.id).to.equal(windowId)
+ })
+ })
+
+ describe('remove()', () => {
+ it('removes the window', async () => {
+ const windowId = browser.window.id
+ const closedPromise = emittedOnce(browser.window, 'closed')
+ browser.crx.exec('windows.remove', windowId)
+ await closedPromise
+ })
+
+ it('removes the current window', async () => {
+ const closedPromise = emittedOnce(browser.window, 'closed')
+ browser.crx.exec('windows.remove')
+ await closedPromise
+ })
+ })
+})
diff --git a/packages/electron-chrome-extensions/spec/crx-helpers.ts b/packages/electron-chrome-extensions/spec/crx-helpers.ts
new file mode 100644
index 0000000..bb971ad
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/crx-helpers.ts
@@ -0,0 +1,110 @@
+import * as path from 'node:path'
+import { app, BrowserWindow, session, webContents } from 'electron'
+import { uuid } from './spec-helpers'
+
+export const createCrxSession = () => {
+ const partitionName = `crx-${uuid()}`
+ const partition = `persist:${partitionName}`
+ return {
+ partitionName,
+ partition,
+ session: session.fromPartition(partition),
+ }
+}
+
+export const addCrxPreload = (session: Electron.Session) => {
+ const preloadPath = path.join(__dirname, 'fixtures', 'crx-test-preload.js')
+ if ('registerPreloadScript' in session) {
+ session.registerPreloadScript({
+ id: 'crx-test-preload',
+ type: 'frame',
+ filePath: preloadPath,
+ })
+ } else {
+ // @ts-expect-error Deprecated electron@<35
+ session.setPreloads([...session.getPreloads(), preloadPath])
+ }
+}
+
+export const createCrxRemoteWindow = () => {
+ const sessionDetails = createCrxSession()
+ addCrxPreload(sessionDetails.session)
+
+ const win = new BrowserWindow({
+ show: false,
+ webPreferences: {
+ session: sessionDetails.session,
+ nodeIntegration: false,
+ contextIsolation: true,
+ },
+ })
+
+ return win
+}
+
+const isBackgroundHostSupported = (extension: Electron.Extension) =>
+ extension.manifest.manifest_version === 2 && extension.manifest.background?.scripts?.length > 0
+
+export const waitForBackgroundPage = async (
+ extension: Electron.Extension,
+ session: Electron.Session,
+) => {
+ if (!isBackgroundHostSupported(extension)) return
+
+ return await new Promise((resolve) => {
+ const resolveHost = (wc: Electron.WebContents) => {
+ app.removeListener('web-contents-created', onWebContentsCreated)
+ resolve(wc)
+ }
+
+ const hostPredicate = (wc: Electron.WebContents) =>
+ !wc.isDestroyed() && wc.getURL().includes(extension.id) && wc.session === session
+
+ const observeWebContents = (wc: Electron.WebContents) => {
+ if (wc.getType() !== 'backgroundPage') return
+
+ if (hostPredicate(wc)) {
+ resolveHost(wc)
+ return
+ }
+
+ wc.once('did-frame-navigate', () => {
+ if (hostPredicate(wc)) {
+ resolveHost(wc)
+ }
+ })
+ }
+
+ const onWebContentsCreated = (_event: any, wc: Electron.WebContents) => observeWebContents(wc)
+
+ webContents.getAllWebContents().forEach(observeWebContents)
+ app.on('web-contents-created', onWebContentsCreated)
+ })
+}
+
+export async function waitForBackgroundScriptEvaluated(
+ extension: Electron.Extension,
+ session: Electron.Session,
+) {
+ if (!isBackgroundHostSupported(extension)) return
+
+ const backgroundHost = await waitForBackgroundPage(extension, session)
+ if (!backgroundHost) return
+
+ await new Promise((resolve) => {
+ const onConsoleMessage = (_event: any, _level: any, message: string) => {
+ if (message === 'background-script-evaluated') {
+ backgroundHost.removeListener('console-message', onConsoleMessage)
+ resolve()
+ }
+ }
+ backgroundHost.on('console-message', onConsoleMessage)
+ })
+}
+
+export async function getExtensionId(name: string) {
+ const extensionPath = path.join(__dirname, 'fixtures', name)
+ const ses = createCrxSession().session
+ const extension = await ses.loadExtension(extensionPath)
+ return extension.id
+}
diff --git a/packages/electron-chrome-extensions/spec/events-helpers.ts b/packages/electron-chrome-extensions/spec/events-helpers.ts
new file mode 100644
index 0000000..5031fbc
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/events-helpers.ts
@@ -0,0 +1,89 @@
+// Copyright (c) 2013-2020 GitHub Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+/**
+ * @fileoverview A set of helper functions to make it easier to work
+ * with events in async/await manner.
+ */
+
+/**
+ * @param {!EventTarget} target
+ * @param {string} eventName
+ * @return {!Promise}
+ */
+export const waitForEvent = (target: EventTarget, eventName: string) => {
+ return new Promise((resolve) => {
+ target.addEventListener(eventName, resolve, { once: true })
+ })
+}
+
+/**
+ * @param {!EventEmitter} emitter
+ * @param {string} eventName
+ * @return {!Promise} With Event as the first item.
+ */
+export const emittedOnce = (
+ emitter: NodeJS.EventEmitter,
+ eventName: string,
+ trigger?: () => void,
+) => {
+ return emittedNTimes(emitter, eventName, 1, trigger).then(([result]) => result)
+}
+
+export const emittedNTimes = async (
+ emitter: NodeJS.EventEmitter,
+ eventName: string,
+ times: number,
+ trigger?: () => void,
+) => {
+ const events: any[][] = []
+ const p = new Promise((resolve) => {
+ const handler = (...args: any[]) => {
+ events.push(args)
+ if (events.length === times) {
+ emitter.removeListener(eventName, handler)
+ resolve(events)
+ }
+ }
+ emitter.on(eventName, handler)
+ })
+ if (trigger) {
+ await Promise.resolve(trigger())
+ }
+ return p
+}
+
+export const emittedUntil = async (
+ emitter: NodeJS.EventEmitter,
+ eventName: string,
+ untilFn: Function,
+) => {
+ const p = new Promise((resolve) => {
+ const handler = (...args: any[]) => {
+ if (untilFn(...args)) {
+ emitter.removeListener(eventName, handler)
+ resolve(args)
+ }
+ }
+ emitter.on(eventName, handler)
+ })
+ return p
+}
diff --git a/packages/electron-chrome-extensions/spec/extensions-spec.ts b/packages/electron-chrome-extensions/spec/extensions-spec.ts
new file mode 100644
index 0000000..36affd7
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/extensions-spec.ts
@@ -0,0 +1,24 @@
+import { expect } from 'chai'
+import { session } from 'electron'
+import { ElectronChromeExtensions } from '../'
+
+describe('Extensions', () => {
+ const testSession = session.fromPartition('test-extensions')
+ const extensions = new ElectronChromeExtensions({
+ license: 'internal-license-do-not-use' as any,
+ session: testSession,
+ })
+
+ it('retrieves the instance with fromSession()', () => {
+ expect(ElectronChromeExtensions.fromSession(testSession)).to.equal(extensions)
+ })
+
+ it('throws when two instances are created for session', () => {
+ expect(() => {
+ new ElectronChromeExtensions({
+ license: 'internal-license-do-not-use' as any,
+ session: testSession,
+ })
+ }).to.throw()
+ })
+})
diff --git a/packages/electron-chrome-extensions/spec/fixtures/.gitignore b/packages/electron-chrome-extensions/spec/fixtures/.gitignore
new file mode 100644
index 0000000..1aacc90
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/.gitignore
@@ -0,0 +1 @@
+crx-test-preload.js
diff --git a/packages/electron-chrome-extensions/spec/fixtures/browser-action-list/default.html b/packages/electron-chrome-extensions/spec/fixtures/browser-action-list/default.html
new file mode 100644
index 0000000..5070383
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/browser-action-list/default.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/background.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/background.js
new file mode 100644
index 0000000..4dd487c
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/background.js
@@ -0,0 +1,7 @@
+/* global chrome */
+
+chrome.browserAction.onClicked.addListener((tab) => {
+ chrome.tabs.sendMessage(tab.id, tab)
+})
+
+console.log('background-script-evaluated')
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/content-script.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/content-script.js
new file mode 100644
index 0000000..f1da2d0
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/content-script.js
@@ -0,0 +1,12 @@
+/* eslint-disable */
+
+function evalInMainWorld(fn) {
+ const script = document.createElement('script')
+ script.textContent = `((${fn})())`
+ document.documentElement.appendChild(script)
+}
+
+chrome.runtime.onMessage.addListener((message) => {
+ const funcStr = `() => { electronTest.sendIpc('success', ${JSON.stringify(message)}) }`
+ evalInMainWorld(funcStr)
+})
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/manifest.json b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/manifest.json
new file mode 100644
index 0000000..71d83f7
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/manifest.json
@@ -0,0 +1,19 @@
+{
+ "name": "chrome-browserAction-click",
+ "version": "1.0",
+ "manifest_version": 2,
+ "browser_action": {
+ "default_title": "browserAction click"
+ },
+ "content_scripts": [
+ {
+ "matches": [""],
+ "js": ["content-script.js"],
+ "run_at": "document_start"
+ }
+ ],
+ "background": {
+ "scripts": ["background.js"],
+ "persistent": true
+ }
+}
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_16.png b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_16.png
new file mode 100644
index 0000000..f55a714
Binary files /dev/null and b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_16.png differ
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_32.png b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_32.png
new file mode 100644
index 0000000..5d08233
Binary files /dev/null and b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_32.png differ
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/manifest.json b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/manifest.json
new file mode 100644
index 0000000..9c1b5a4
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/manifest.json
@@ -0,0 +1,13 @@
+{
+ "name": "chrome-browserAction-popup",
+ "version": "1.0",
+ "manifest_version": 2,
+ "browser_action": {
+ "default_icon": {
+ "16": "icon_16.png",
+ "32": "icon_32.png"
+ },
+ "default_popup": "popup.html",
+ "default_title": "browserAction Popup"
+ }
+}
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/popup.html b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/popup.html
new file mode 100644
index 0000000..4903595
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/popup.html
@@ -0,0 +1,2 @@
+
+browserAction
\ No newline at end of file
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/background.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/background.js
new file mode 100644
index 0000000..fd1d40f
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/background.js
@@ -0,0 +1,40 @@
+/* global chrome */
+
+const eventNames = [
+ 'onBeforeNavigate',
+ 'onCommitted',
+ 'onCompleted',
+ 'onCreatedNavigationTarget',
+ 'onDOMContentLoaded',
+ 'onErrorOccurred',
+ 'onHistoryStateUpdated',
+ 'onReferenceFragmentUpdated',
+ 'onTabReplaced',
+]
+
+let activeTabId
+
+let eventLog = []
+const logEvent = (eventName) => {
+ if (eventName) eventLog.push(eventName)
+ if (typeof activeTabId === 'undefined') return
+
+ eventLog.forEach((eventName) => {
+ chrome.tabs.sendMessage(activeTabId, { name: 'logEvent', args: eventName })
+ })
+
+ eventLog = []
+}
+
+eventNames.forEach((eventName) => {
+ chrome.webNavigation[eventName].addListener(() => {
+ logEvent(eventName)
+ })
+})
+
+chrome.tabs.query({ active: true, windowId: chrome.windows.WINDOW_ID_CURRENT }, ([tab]) => {
+ activeTabId = tab.id
+ logEvent()
+})
+
+console.log('background-script-evaluated')
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/content-script.js b/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/content-script.js
new file mode 100644
index 0000000..448f1e3
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/content-script.js
@@ -0,0 +1,12 @@
+/* eslint-disable */
+
+function evalInMainWorld(fn) {
+ const script = document.createElement('script')
+ script.textContent = `((${fn})())`
+ document.documentElement.appendChild(script)
+}
+
+chrome.runtime.onMessage.addListener(({ name, args }) => {
+ const funcStr = `() => { electronTest.sendIpc(${JSON.stringify(name)}, ${JSON.stringify(args)}) }`
+ evalInMainWorld(funcStr)
+})
diff --git a/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/manifest.json b/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/manifest.json
new file mode 100644
index 0000000..5840882
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/manifest.json
@@ -0,0 +1,16 @@
+{
+ "name": "chrome-webNavigation",
+ "version": "1.0",
+ "manifest_version": 2,
+ "content_scripts": [
+ {
+ "matches": [""],
+ "js": ["content-script.js"],
+ "run_at": "document_start"
+ }
+ ],
+ "background": {
+ "scripts": ["background.js"],
+ "persistent": true
+ }
+}
diff --git a/packages/electron-chrome-extensions/spec/fixtures/crx-test-preload.ts b/packages/electron-chrome-extensions/spec/fixtures/crx-test-preload.ts
new file mode 100644
index 0000000..2dde8aa
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/crx-test-preload.ts
@@ -0,0 +1,23 @@
+import { contextBridge, ipcRenderer } from 'electron'
+import { injectBrowserAction } from '../../src/browser-action'
+
+// This should go without saying, but you should never do this in a production
+// app. These bindings are purely for testing convenience.
+const apiName = 'electronTest'
+const api = {
+ sendIpc(channel: string, ...args: any[]) {
+ return ipcRenderer.send(channel, ...args)
+ },
+ invokeIpc(channel: string, ...args: any[]) {
+ return ipcRenderer.invoke(channel, ...args)
+ },
+}
+
+try {
+ contextBridge.exposeInMainWorld(apiName, api)
+} catch {
+ window[apiName] = api
+}
+
+// Inject in all test pages.
+injectBrowserAction()
diff --git a/packages/electron-chrome-extensions/spec/fixtures/rpc/background.js b/packages/electron-chrome-extensions/spec/fixtures/rpc/background.js
new file mode 100644
index 0000000..1fcfec9
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/rpc/background.js
@@ -0,0 +1,72 @@
+/* global chrome */
+
+const sendIpc = ({ tabId, name }) => {
+ chrome.tabs.sendMessage(tabId, { type: 'send-ipc', args: [name] })
+}
+
+const transformArgs = (args, sender) => {
+ const tabId = sender.tab.id
+
+ const transformArg = (arg) => {
+ if (arg && typeof arg === 'object') {
+ // Convert object to function that sends IPC
+ if ('__IPC_FN__' in arg) {
+ return () => {
+ sendIpc({ tabId, name: arg.__IPC_FN__ })
+ }
+ } else {
+ // Deep transform objects
+ for (const key of Object.keys(arg)) {
+ if (arg.hasOwnProperty(key)) {
+ arg[key] = transformArg(arg[key])
+ }
+ }
+ }
+ }
+
+ return arg
+ }
+
+ return args.map(transformArg)
+}
+
+chrome.runtime.onMessage.addListener((message, sender, reply) => {
+ switch (message.type) {
+ case 'api': {
+ const { method, args } = message
+
+ const [apiName, subMethod] = method.split('.')
+
+ if (typeof chrome[apiName][subMethod] === 'function') {
+ const transformedArgs = transformArgs(args, sender)
+ chrome[apiName][subMethod](...transformedArgs, reply)
+ }
+
+ break
+ }
+
+ case 'event-once': {
+ const { name } = message
+
+ const [apiName, eventName] = name.split('.')
+
+ if (typeof chrome[apiName][eventName] === 'object') {
+ const event = chrome[apiName][eventName]
+ event.addListener(function callback(...args) {
+ if (chrome.runtime.lastError) {
+ reply(chrome.runtime.lastError)
+ } else {
+ reply(args)
+ }
+
+ event.removeListener(callback)
+ })
+ }
+ }
+ }
+
+ // Respond asynchronously
+ return true
+})
+
+console.log('background-script-evaluated')
diff --git a/packages/electron-chrome-extensions/spec/fixtures/rpc/content-script.js b/packages/electron-chrome-extensions/spec/fixtures/rpc/content-script.js
new file mode 100644
index 0000000..69a5983
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/rpc/content-script.js
@@ -0,0 +1,60 @@
+/* eslint-disable */
+
+function evalInMainWorld(fn) {
+ const script = document.createElement('script')
+ script.textContent = `((${fn})())`
+ document.documentElement.appendChild(script)
+}
+
+function sendIpc(name, ...args) {
+ const jsonArgs = [name, ...args].map((arg) => JSON.stringify(arg))
+ const funcStr = `() => { electronTest.sendIpc(${jsonArgs.join(', ')}) }`
+ evalInMainWorld(funcStr)
+}
+
+async function exec(action) {
+ const send = async () => {
+ return new Promise((resolve, reject) => {
+ chrome.runtime.sendMessage(action, (result) => {
+ if (chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError.message)
+ } else {
+ resolve(result)
+ }
+ })
+ })
+ }
+
+ // Retry logic - the connection doesn't seem to always be available when
+ // attempting to send. This started when upgrading to Electron 22 from 15.
+ let result
+ for (let i = 0; i < 3; i++) {
+ try {
+ result = await send()
+ break
+ } catch (e) {
+ console.error(e)
+ await new Promise((resolve) => setTimeout(resolve, 100)) // sleep
+ }
+ }
+
+ sendIpc('success', result)
+}
+
+window.addEventListener('message', (event) => {
+ exec(event.data)
+})
+
+evalInMainWorld(() => {
+ window.exec = (json) => window.postMessage(JSON.parse(json))
+})
+
+chrome.runtime.onMessage.addListener((message) => {
+ switch (message.type) {
+ case 'send-ipc': {
+ const [name] = message.args
+ sendIpc(name)
+ break
+ }
+ }
+})
diff --git a/packages/electron-chrome-extensions/spec/fixtures/rpc/icon_16.png b/packages/electron-chrome-extensions/spec/fixtures/rpc/icon_16.png
new file mode 100644
index 0000000..f55a714
Binary files /dev/null and b/packages/electron-chrome-extensions/spec/fixtures/rpc/icon_16.png differ
diff --git a/packages/electron-chrome-extensions/spec/fixtures/rpc/manifest.json b/packages/electron-chrome-extensions/spec/fixtures/rpc/manifest.json
new file mode 100644
index 0000000..fb5483f
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/rpc/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "chrome-rpc",
+ "version": "1.0",
+ "browser_action": {
+ "default_title": "RPC"
+ },
+ "content_scripts": [
+ {
+ "matches": [""],
+ "js": ["content-script.js"],
+ "run_at": "document_end"
+ }
+ ],
+ "background": {
+ "scripts": ["background.js"],
+ "persistent": true
+ },
+ "manifest_version": 2,
+ "permissions": [
+ "contextMenus",
+ "nativeMessaging",
+ "webRequest",
+ "webRequestBlocking",
+ ""
+ ]
+}
diff --git a/packages/electron-chrome-extensions/spec/fixtures/rpc/popup.html b/packages/electron-chrome-extensions/spec/fixtures/rpc/popup.html
new file mode 100644
index 0000000..4903595
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/fixtures/rpc/popup.html
@@ -0,0 +1,2 @@
+
+browserAction
\ No newline at end of file
diff --git a/packages/electron-chrome-extensions/spec/hooks.ts b/packages/electron-chrome-extensions/spec/hooks.ts
new file mode 100644
index 0000000..49d0639
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/hooks.ts
@@ -0,0 +1,169 @@
+import { ipcMain, BrowserWindow, app, Extension, webContents } from 'electron'
+import * as http from 'http'
+import * as path from 'node:path'
+import { AddressInfo } from 'net'
+import { ElectronChromeExtensions } from '../'
+import { emittedOnce } from './events-helpers'
+import { addCrxPreload, createCrxSession, waitForBackgroundScriptEvaluated } from './crx-helpers'
+import { ChromeExtensionImpl } from '../dist/types/browser/impl'
+
+export const useServer = () => {
+ const emptyPage = `
+
+
+ title
+
+
+
+
+`
+
+ // NB. extensions are only allowed on http://, https:// and ftp:// (!) urls by default.
+ let server: http.Server
+ let url: string
+
+ before(async () => {
+ server = http.createServer((req, res) => {
+ res.writeHead(200, { 'Content-Type': 'text/html' })
+ res.end(emptyPage)
+ })
+ await new Promise((resolve) =>
+ server.listen(0, '127.0.0.1', () => {
+ url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`
+ resolve()
+ }),
+ )
+ })
+ after(() => {
+ server.close()
+ })
+
+ return {
+ getUrl: () => url,
+ }
+}
+
+const fixtures = path.join(__dirname, 'fixtures')
+
+export const useExtensionBrowser = (opts: {
+ url?: () => string
+ file?: string
+ extensionName: string
+ openDevTools?: boolean
+ assignTabDetails?: ChromeExtensionImpl['assignTabDetails']
+}) => {
+ let w: Electron.BrowserWindow
+ let extensions: ElectronChromeExtensions
+ let extension: Extension
+ let partition: string
+ let customSession: Electron.Session
+
+ beforeEach(async () => {
+ const sessionDetails = createCrxSession()
+
+ partition = sessionDetails.partition
+ customSession = sessionDetails.session
+
+ addCrxPreload(customSession)
+
+ extensions = new ElectronChromeExtensions({
+ license: 'internal-license-do-not-use' as any,
+ session: customSession,
+ async createTab(details) {
+ const tab = (webContents as any).create({ sandbox: true })
+ if (details.url) await tab.loadURL(details.url)
+ return [tab, w!]
+ },
+ assignTabDetails(details, tab) {
+ opts.assignTabDetails?.(details, tab)
+ },
+ })
+
+ extension = await customSession.loadExtension(path.join(fixtures, opts.extensionName))
+ await waitForBackgroundScriptEvaluated(extension, customSession)
+
+ w = new BrowserWindow({
+ show: false,
+ webPreferences: { session: customSession, nodeIntegration: false, contextIsolation: true },
+ })
+
+ if (opts.openDevTools) {
+ w.webContents.openDevTools({ mode: 'detach' })
+ }
+
+ extensions.addTab(w.webContents, w)
+
+ if (opts.file) {
+ await w.loadFile(opts.file)
+ } else if (opts.url) {
+ await w.loadURL(opts.url())
+ }
+ })
+
+ afterEach(() => {
+ if (!w.isDestroyed()) {
+ if (w.webContents.isDevToolsOpened()) {
+ w.webContents.closeDevTools()
+ }
+
+ w.destroy()
+ }
+ })
+
+ return {
+ get window() {
+ return w
+ },
+ get webContents() {
+ return w.webContents
+ },
+ get extensions() {
+ return extensions
+ },
+ get extension() {
+ return extension
+ },
+ get session() {
+ return customSession
+ },
+ get partition() {
+ return partition
+ },
+
+ crx: {
+ async exec(method: string, ...args: any[]) {
+ const p = emittedOnce(ipcMain, 'success')
+ const rpcStr = JSON.stringify({ type: 'api', method, args })
+ const safeRpcStr = rpcStr.replace(/'/g, "\\'")
+ const js = `exec('${safeRpcStr}')`
+ await w.webContents.executeJavaScript(js)
+ const [, result] = await p
+ return result
+ },
+
+ async eventOnce(eventName: string) {
+ const p = emittedOnce(ipcMain, 'success')
+ await w.webContents.executeJavaScript(
+ `exec('${JSON.stringify({ type: 'event-once', name: eventName })}')`,
+ )
+ const [, results] = await p
+
+ if (typeof results === 'string') {
+ throw new Error(results)
+ }
+
+ return results
+ },
+ },
+ }
+}
+
+export const useBackgroundPageLogging = () => {
+ app.on('web-contents-created', (event, wc) => {
+ if (wc.getType() === 'backgroundPage') {
+ wc.on('console-message', (ev, level, message, line, sourceId) => {
+ console.log(`(${sourceId}) ${message}`)
+ })
+ }
+ })
+}
diff --git a/packages/electron-chrome-extensions/spec/index.js b/packages/electron-chrome-extensions/spec/index.js
new file mode 100644
index 0000000..ad610d7
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/index.js
@@ -0,0 +1,186 @@
+// Copyright (c) 2013-2020 GitHub Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+const Module = require('module')
+const path = require('path')
+const { promises: fs } = require('fs')
+const v8 = require('v8')
+
+Module.globalPaths.push(path.resolve(__dirname, '../spec/node_modules'))
+
+// We want to terminate on errors, not throw up a dialog
+process.on('uncaughtException', (err) => {
+ console.error('Unhandled exception in main spec runner:', err)
+ process.exit(1)
+})
+
+// Tell ts-node which tsconfig to use
+process.env.TS_NODE_PROJECT = path.resolve(__dirname, '../tsconfig.json')
+process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true'
+
+const { app, protocol } = require('electron')
+
+v8.setFlagsFromString('--expose_gc')
+app.commandLine.appendSwitch('js-flags', '--expose_gc')
+app.commandLine.appendSwitch('enable-features', 'ElectronSerialChooser')
+// Prevent the spec runner quiting when the first window closes
+app.on('window-all-closed', () => null)
+
+// Use fake device for Media Stream to replace actual camera and microphone.
+app.commandLine.appendSwitch('use-fake-device-for-media-stream')
+
+// @ts-ignore
+global.standardScheme = 'app'
+// @ts-ignore
+global.zoomScheme = 'zoom'
+protocol.registerSchemesAsPrivileged([
+ // @ts-ignore
+ { scheme: global.standardScheme, privileges: { standard: true, secure: true, stream: false } },
+ // @ts-ignore
+ { scheme: global.zoomScheme, privileges: { standard: true, secure: true } },
+ { scheme: 'cors-blob', privileges: { corsEnabled: true, supportFetchAPI: true } },
+ { scheme: 'cors', privileges: { corsEnabled: true, supportFetchAPI: true } },
+ { scheme: 'no-cors', privileges: { supportFetchAPI: true } },
+ { scheme: 'no-fetch', privileges: { corsEnabled: true } },
+ { scheme: 'stream', privileges: { standard: true, stream: true } },
+ { scheme: 'foo', privileges: { standard: true } },
+ { scheme: 'bar', privileges: { standard: true } },
+ { scheme: 'crx', privileges: { bypassCSP: true } },
+])
+
+const cleanupTestSessions = async () => {
+ const sessionsPath = path.join(app.getPath('userData'), 'Partitions')
+
+ let sessions
+
+ try {
+ sessions = await fs.readdir(sessionsPath)
+ } catch (e) {
+ return // dir doesn't exist
+ }
+
+ sessions = sessions.filter((session) => session.startsWith('crx-'))
+ if (sessions.length === 0) return
+
+ console.log(`Cleaning up ${sessions.length} sessions from previous test runners`)
+
+ for (const session of sessions) {
+ const sessionPath = path.join(sessionsPath, session)
+ await fs.rm(sessionPath, { recursive: true, force: true })
+ }
+}
+
+app
+ .whenReady()
+ .then(async () => {
+ require('ts-node/register')
+
+ await cleanupTestSessions()
+
+ const argv = require('yargs')
+ .boolean('ci')
+ .array('files')
+ .string('g')
+ .alias('g', 'grep')
+ .boolean('i')
+ .alias('i', 'invert').argv
+
+ const Mocha = require('mocha')
+ const mochaOptions = {}
+ if (process.env.MOCHA_REPORTER) {
+ mochaOptions.reporter = process.env.MOCHA_REPORTER
+ }
+ if (process.env.MOCHA_MULTI_REPORTERS) {
+ mochaOptions.reporterOptions = {
+ reporterEnabled: process.env.MOCHA_MULTI_REPORTERS,
+ }
+ }
+ const mocha = new Mocha(mochaOptions)
+
+ // The cleanup method is registered this way rather than through an
+ // `afterEach` at the top level so that it can run before other `afterEach`
+ // methods.
+ //
+ // The order of events is:
+ // 1. test completes,
+ // 2. `defer()`-ed methods run, in reverse order,
+ // 3. regular `afterEach` hooks run.
+ const { runCleanupFunctions, getFiles } = require('./spec-helpers')
+ mocha.suite.on('suite', function attach(suite) {
+ suite.afterEach('cleanup', runCleanupFunctions)
+ suite.on('suite', attach)
+ })
+
+ if (!process.env.MOCHA_REPORTER) {
+ mocha.ui('bdd').reporter('tap')
+ }
+ const mochaTimeout = process.env.MOCHA_TIMEOUT || 10000
+ mocha.timeout(mochaTimeout)
+
+ if (argv.grep) mocha.grep(argv.grep)
+ if (argv.invert) mocha.invert()
+
+ const filter = (file) => {
+ if (!/-spec\.[tj]s$/.test(file)) {
+ return false
+ }
+
+ // This allows you to run specific modules only:
+ // npm run test -match=menu
+ const moduleMatch = process.env.npm_config_match
+ ? new RegExp(process.env.npm_config_match, 'g')
+ : null
+ if (moduleMatch && !moduleMatch.test(file)) {
+ return false
+ }
+
+ const baseElectronDir = path.resolve(__dirname, '..')
+ if (argv.files && !argv.files.includes(path.relative(baseElectronDir, file))) {
+ return false
+ }
+
+ return true
+ }
+
+ const testFiles = await getFiles(__dirname, { filter })
+ testFiles.sort().forEach((file) => {
+ mocha.addFile(file)
+ })
+
+ const cb = () => {
+ // Ensure the callback is called after runner is defined
+ process.nextTick(() => {
+ process.exit(runner.failures)
+ })
+ }
+
+ // Set up chai in the correct order
+ const chai = require('chai')
+ chai.use(require('chai-as-promised'))
+ // chai.use(require('dirty-chai'));
+
+ const runner = mocha.run(cb)
+ })
+ .catch((err) => {
+ console.error('An error occurred while running the spec-main spec runner')
+ console.error(err)
+ process.exit(1)
+ })
diff --git a/packages/electron-chrome-extensions/spec/spec-helpers.ts b/packages/electron-chrome-extensions/spec/spec-helpers.ts
new file mode 100644
index 0000000..266fbb6
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/spec-helpers.ts
@@ -0,0 +1,136 @@
+// Copyright (c) 2013-2020 GitHub Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+import * as childProcess from 'node:child_process'
+import * as nodeCrypto from 'node:crypto'
+import * as path from 'node:path'
+import * as http from 'node:http'
+import * as v8 from 'v8'
+import { SuiteFunction, TestFunction } from 'mocha'
+
+const addOnly = (fn: Function): T => {
+ const wrapped = (...args: any[]) => {
+ return fn(...args)
+ }
+ ;(wrapped as any).only = wrapped
+ ;(wrapped as any).skip = wrapped
+ return wrapped as any
+}
+
+export const ifit = (condition: boolean) => (condition ? it : addOnly(it.skip))
+export const ifdescribe = (condition: boolean) =>
+ condition ? describe : addOnly(describe.skip)
+
+export const delay = (time: number = 0) => new Promise((resolve) => setTimeout(resolve, time))
+
+type CleanupFunction = (() => void) | (() => Promise)
+const cleanupFunctions: CleanupFunction[] = []
+export async function runCleanupFunctions() {
+ for (const cleanup of cleanupFunctions) {
+ const r = cleanup()
+ if (r instanceof Promise) {
+ await r
+ }
+ }
+ cleanupFunctions.length = 0
+}
+
+export function defer(f: CleanupFunction) {
+ cleanupFunctions.unshift(f)
+}
+
+class RemoteControlApp {
+ process: childProcess.ChildProcess
+ port: number
+
+ constructor(proc: childProcess.ChildProcess, port: number) {
+ this.process = proc
+ this.port = port
+ }
+
+ remoteEval = (js: string): Promise => {
+ return new Promise((resolve, reject) => {
+ const req = http.request(
+ {
+ host: '127.0.0.1',
+ port: this.port,
+ method: 'POST',
+ },
+ (res) => {
+ const chunks = [] as Buffer[]
+ res.on('data', (chunk) => {
+ chunks.push(chunk)
+ })
+ res.on('end', () => {
+ const ret = v8.deserialize(Buffer.concat(chunks))
+ if (Object.prototype.hasOwnProperty.call(ret, 'error')) {
+ reject(new Error(`remote error: ${ret.error}\n\nTriggered at:`))
+ } else {
+ resolve(ret.result)
+ }
+ })
+ },
+ )
+ req.write(js)
+ req.end()
+ })
+ }
+
+ remotely = (script: Function, ...args: any[]): Promise => {
+ return this.remoteEval(`(${script})(...${JSON.stringify(args)})`)
+ }
+}
+
+export async function startRemoteControlApp() {
+ const appPath = path.join(__dirname, 'fixtures', 'apps', 'remote-control')
+ const appProcess = childProcess.spawn(process.execPath, [appPath])
+ appProcess.stderr.on('data', (d) => {
+ process.stderr.write(d)
+ })
+ const port = await new Promise((resolve) => {
+ appProcess.stdout.on('data', (d) => {
+ const m = /Listening: (\d+)/.exec(d.toString())
+ if (m && m[1] != null) {
+ resolve(Number(m[1]))
+ }
+ })
+ })
+ defer(() => {
+ appProcess.kill('SIGINT')
+ })
+ return new RemoteControlApp(appProcess, port)
+}
+
+export async function getFiles(directoryPath: string, { filter = null }: any = {}) {
+ const files: string[] = []
+ const walker = require('walkdir').walk(directoryPath, {
+ no_recurse: true,
+ })
+ walker.on('file', (file: string) => {
+ if (!filter || filter(file)) {
+ files.push(file)
+ }
+ })
+ await new Promise((resolve) => walker.on('end', resolve))
+ return files
+}
+
+export const uuid = () => nodeCrypto.randomUUID()
diff --git a/packages/electron-chrome-extensions/spec/window-helpers.ts b/packages/electron-chrome-extensions/spec/window-helpers.ts
new file mode 100644
index 0000000..6a04329
--- /dev/null
+++ b/packages/electron-chrome-extensions/spec/window-helpers.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2013-2020 GitHub Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+import { expect } from 'chai'
+import { BrowserWindow } from 'electron/main'
+import { emittedOnce } from './events-helpers'
+
+async function ensureWindowIsClosed(window: BrowserWindow | null) {
+ if (window && !window.isDestroyed()) {
+ if (window.webContents && !window.webContents.isDestroyed()) {
+ // If a window isn't destroyed already, and it has non-destroyed WebContents,
+ // then calling destroy() won't immediately destroy it, as it may have
+ // children which need to be destroyed first. In that case, we
+ // await the 'closed' event which signals the complete shutdown of the
+ // window.
+ const isClosed = emittedOnce(window, 'closed')
+ window.destroy()
+ await isClosed
+ } else {
+ // If there's no WebContents or if the WebContents is already destroyed,
+ // then the 'closed' event has already been emitted so there's nothing to
+ // wait for.
+ window.destroy()
+ }
+ }
+}
+
+export const closeWindow = async (
+ window: BrowserWindow | null = null,
+ { assertNotWindows } = { assertNotWindows: true },
+) => {
+ await ensureWindowIsClosed(window)
+
+ if (assertNotWindows) {
+ const windows = BrowserWindow.getAllWindows()
+ try {
+ expect(windows).to.have.lengthOf(0)
+ } finally {
+ for (const win of windows) {
+ await ensureWindowIsClosed(win)
+ }
+ }
+ }
+}
+
+export async function closeAllWindows() {
+ for (const w of BrowserWindow.getAllWindows()) {
+ await closeWindow(w, { assertNotWindows: false })
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser-action.ts b/packages/electron-chrome-extensions/src/browser-action.ts
new file mode 100644
index 0000000..4729475
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser-action.ts
@@ -0,0 +1,485 @@
+import { ipcRenderer, contextBridge, webFrame } from 'electron'
+
+/**
+ * Injects `` custom element into the current webpage.
+ */
+export const injectBrowserAction = () => {
+ const actionMap = new Map()
+ const observerCounts = new Map()
+
+ // Reuse `process` to avoid bundling custom EventEmitter
+ const internalEmitter = process as NodeJS.EventEmitter
+
+ const invoke = (name: string, partition: string, ...args: any[]): Promise => {
+ return ipcRenderer.invoke('crx-msg-remote', partition, name, ...args)
+ }
+
+ interface ActivateDetails {
+ eventType: string
+ extensionId: string
+ tabId: number
+ anchorRect: { x: number; y: number; width: number; height: number }
+ alignment?: string
+ }
+
+ const __browserAction__ = {
+ addEventListener(name: string, listener: (...args: any[]) => void) {
+ internalEmitter.addListener(`-actions-${name}`, listener)
+ },
+ removeEventListener(name: string, listener: (...args: any[]) => void) {
+ internalEmitter.removeListener(`-actions-${name}`, listener)
+ },
+
+ getAction(extensionId: string) {
+ return actionMap.get(extensionId)
+ },
+ async getState(partition: string): Promise<{ activeTabId?: number; actions: any[] }> {
+ const state = await invoke('browserAction.getState', partition)
+ for (const action of state.actions) {
+ actionMap.set(action.id, action)
+ }
+ queueMicrotask(() => internalEmitter.emit('-actions-update', state))
+ return state
+ },
+
+ activate: (partition: string, details: ActivateDetails) => {
+ return invoke('browserAction.activate', partition, details)
+ },
+
+ addObserver(partition: string) {
+ let count = observerCounts.has(partition) ? observerCounts.get(partition)! : 0
+ count = count + 1
+ observerCounts.set(partition, count)
+
+ if (count === 1) {
+ invoke('browserAction.addObserver', partition)
+ }
+ },
+ removeObserver(partition: string) {
+ let count = observerCounts.has(partition) ? observerCounts.get(partition)! : 0
+ count = Math.max(count - 1, 0)
+ observerCounts.set(partition, count)
+
+ if (count === 0) {
+ invoke('browserAction.removeObserver', partition)
+ observerCounts.delete(partition)
+ }
+ },
+ }
+
+ ipcRenderer.on('browserAction.update', () => {
+ for (const partition of observerCounts.keys()) {
+ __browserAction__.getState(partition)
+ }
+ })
+
+ // Function body to run in the main world.
+ // IMPORTANT: This must be self-contained, no closure variables can be used!
+ function mainWorldScript() {
+ const DEFAULT_PARTITION = '_self'
+
+ // Access from globalThis to prevent accessing incorrect minified variable.
+ // Fallback to `__browserAction__` when context isolation is disabled.
+ const browserAction: typeof __browserAction__ =
+ (globalThis as any).browserAction || __browserAction__
+
+ class BrowserActionElement extends HTMLButtonElement {
+ private updateId?: number
+ private badge?: HTMLDivElement
+ private pendingIcon?: HTMLImageElement
+
+ get id(): string {
+ return this.getAttribute('id') || ''
+ }
+
+ set id(id: string) {
+ this.setAttribute('id', id)
+ }
+
+ get tab(): number {
+ const tabId = parseInt(this.getAttribute('tab') || '', 10)
+ return typeof tabId === 'number' && !isNaN(tabId) ? tabId : -1
+ }
+
+ set tab(tab: number) {
+ this.setAttribute('tab', `${tab}`)
+ }
+
+ get partition(): string | null {
+ return this.getAttribute('partition')
+ }
+
+ set partition(partition: string | null) {
+ if (partition) {
+ this.setAttribute('partition', partition)
+ } else {
+ this.removeAttribute('partition')
+ }
+ }
+
+ get alignment(): string {
+ return this.getAttribute('alignment') || ''
+ }
+
+ set alignment(alignment: string) {
+ this.setAttribute('alignment', alignment)
+ }
+
+ static get observedAttributes() {
+ return ['id', 'tab', 'partition', 'alignment']
+ }
+
+ constructor() {
+ super()
+
+ // TODO: event delegation
+ this.addEventListener('click', this.onClick.bind(this))
+ this.addEventListener('contextmenu', this.onContextMenu.bind(this))
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.update()
+ }
+ }
+
+ disconnectedCallback() {
+ if (this.updateId) {
+ cancelAnimationFrame(this.updateId)
+ this.updateId = undefined
+ }
+ if (this.pendingIcon) {
+ this.pendingIcon = undefined
+ }
+ }
+
+ attributeChangedCallback() {
+ if (this.isConnected) {
+ this.update()
+ }
+ }
+
+ private activate(event: Event) {
+ const rect = this.getBoundingClientRect()
+
+ browserAction.activate(this.partition || DEFAULT_PARTITION, {
+ eventType: event.type,
+ extensionId: this.id,
+ tabId: this.tab,
+ alignment: this.alignment,
+ anchorRect: {
+ x: rect.left,
+ y: rect.top,
+ width: rect.width,
+ height: rect.height,
+ },
+ })
+ }
+
+ private onClick(event: MouseEvent) {
+ this.activate(event)
+ }
+
+ private onContextMenu(event: MouseEvent) {
+ event.stopImmediatePropagation()
+ event.preventDefault()
+
+ this.activate(event)
+ }
+
+ private getBadge() {
+ let badge = this.badge
+ if (!badge) {
+ this.badge = badge = document.createElement('div')
+ badge.className = 'badge'
+ ;(badge as any).part = 'badge'
+ this.appendChild(badge)
+ }
+ return badge
+ }
+
+ private update() {
+ if (this.updateId) return
+ this.updateId = requestAnimationFrame(this.updateCallback.bind(this))
+ }
+
+ private updateIcon(info: any) {
+ const iconSize = 32
+ const resizeType = 2
+ const searchParams = new URLSearchParams({
+ tabId: `${this.tab}`,
+ partition: `${this.partition || DEFAULT_PARTITION}`,
+ })
+ if (info.iconModified) {
+ searchParams.append('t', info.iconModified)
+ }
+ const iconUrl = `crx://extension-icon/${this.id}/${iconSize}/${resizeType}?${searchParams.toString()}`
+ const bgImage = `url(${iconUrl})`
+
+ if (this.pendingIcon) {
+ this.pendingIcon.onload = this.pendingIcon.onerror = () => {}
+ this.pendingIcon = undefined
+ }
+
+ // Preload icon to prevent it from blinking
+ const img = (this.pendingIcon = new Image())
+ img.onerror = () => {
+ if (this.isConnected) {
+ this.classList.toggle('no-icon', true)
+ if (this.title) {
+ this.dataset.letter = this.title.charAt(0)
+ }
+ this.pendingIcon = undefined
+ }
+ }
+ img.onload = () => {
+ if (this.isConnected) {
+ this.classList.toggle('no-icon', false)
+ this.style.backgroundImage = bgImage
+ this.pendingIcon = undefined
+ }
+ }
+ img.src = iconUrl
+ }
+
+ private updateCallback() {
+ this.updateId = undefined
+
+ const action = browserAction.getAction(this.id)
+
+ const activeTabId = this.tab
+ const tabInfo = activeTabId > -1 ? action.tabs[activeTabId] : {}
+ const info = { ...tabInfo, ...action }
+
+ this.title = typeof info.title === 'string' ? info.title : ''
+
+ this.updateIcon(info)
+
+ if (info.text) {
+ const badge = this.getBadge()
+ badge.textContent = info.text
+ badge.style.color = '#fff' // TODO: determine bg lightness?
+ badge.style.backgroundColor = info.color
+ } else if (this.badge) {
+ this.badge.remove()
+ this.badge = undefined
+ }
+ }
+ }
+
+ customElements.define('browser-action', BrowserActionElement, { extends: 'button' })
+
+ class BrowserActionListElement extends HTMLElement {
+ private observing: boolean = false
+
+ get tab(): number | null {
+ const tabId = parseInt(this.getAttribute('tab') || '', 10)
+ return typeof tabId === 'number' && !isNaN(tabId) ? tabId : null
+ }
+
+ set tab(tab: number | null) {
+ if (typeof tab === 'number') {
+ this.setAttribute('tab', `${tab}`)
+ } else {
+ this.removeAttribute('tab')
+ }
+ }
+
+ get partition(): string | null {
+ return this.getAttribute('partition')
+ }
+
+ set partition(partition: string | null) {
+ if (partition) {
+ this.setAttribute('partition', partition)
+ } else {
+ this.removeAttribute('partition')
+ }
+ }
+
+ get alignment(): string {
+ return this.getAttribute('alignment') || ''
+ }
+
+ set alignment(alignment: string) {
+ this.setAttribute('alignment', alignment)
+ }
+
+ static get observedAttributes() {
+ return ['tab', 'partition', 'alignment']
+ }
+
+ constructor() {
+ super()
+
+ const shadowRoot = this.attachShadow({ mode: 'open' })
+
+ const style = document.createElement('style')
+ style.textContent = `
+:host {
+ display: flex;
+ flex-direction: row;
+ gap: 5px;
+}
+
+.action {
+ width: 28px;
+ height: 28px;
+ background-color: transparent;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 70%;
+ border: none;
+ border-radius: 4px;
+ padding: 0;
+ position: relative;
+ outline: none;
+}
+
+.action:hover {
+ background-color: var(--browser-action-hover-bg, rgba(255, 255, 255, 0.3));
+}
+
+.action.no-icon::after {
+ content: attr(data-letter);
+ text-transform: uppercase;
+ font-size: .7rem;
+ background-color: #757575;
+ color: white;
+ border-radius: 4px;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 80%;
+ height: 80%;
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+}
+
+.badge {
+ box-shadow: 0px 0px 1px 1px var(--browser-action-badge-outline, #444);
+ box-sizing: border-box;
+ max-width: 100%;
+ height: 12px;
+ padding: 0 2px;
+ border-radius: 2px;
+ position: absolute;
+ bottom: 1px;
+ right: 0;
+ pointer-events: none;
+ line-height: 1.5;
+ font-size: 9px;
+ font-weight: 400;
+ overflow: hidden;
+ white-space: nowrap;
+}`
+ shadowRoot.appendChild(style)
+ }
+
+ connectedCallback() {
+ if (this.isConnected) {
+ this.startObserving()
+ this.fetchState()
+ }
+ }
+
+ disconnectedCallback() {
+ this.stopObserving()
+ }
+
+ attributeChangedCallback(name: string, oldValue: any, newValue: any) {
+ if (oldValue === newValue) return
+
+ if (this.isConnected) {
+ this.fetchState()
+ }
+ }
+
+ private startObserving() {
+ if (this.observing) return
+ browserAction.addEventListener('update', this.update)
+ browserAction.addObserver(this.partition || DEFAULT_PARTITION)
+ this.observing = true
+ }
+
+ private stopObserving() {
+ if (!this.observing) return
+ browserAction.removeEventListener('update', this.update)
+ browserAction.removeObserver(this.partition || DEFAULT_PARTITION)
+ this.observing = false
+ }
+
+ private fetchState = async () => {
+ try {
+ await browserAction.getState(this.partition || DEFAULT_PARTITION)
+ } catch {
+ console.error(
+ `browser-action-list failed to update [tab: ${this.tab}, partition: '${this.partition}']`,
+ )
+ }
+ }
+
+ private update = (state: any) => {
+ const tabId =
+ typeof this.tab === 'number' && this.tab >= 0 ? this.tab : state.activeTabId || -1
+
+ // Create or update action buttons
+ for (const action of state.actions) {
+ let browserActionNode = this.shadowRoot?.querySelector(
+ `[id=${action.id}]`,
+ ) as BrowserActionElement
+
+ if (!browserActionNode) {
+ const node = document.createElement('button', {
+ is: 'browser-action',
+ }) as BrowserActionElement
+ node.id = action.id
+ node.className = 'action'
+ ;(node as any).alignment = this.alignment
+ ;(node as any).part = 'action'
+ browserActionNode = node
+ this.shadowRoot?.appendChild(browserActionNode)
+ }
+
+ if (this.partition) browserActionNode.partition = this.partition
+ if (this.alignment) browserActionNode.alignment = this.alignment
+ browserActionNode.tab = tabId
+ }
+
+ // Remove any actions no longer in use
+ const actionNodes = Array.from(
+ this.shadowRoot?.querySelectorAll('.action') as any,
+ ) as BrowserActionElement[]
+ for (const actionNode of actionNodes) {
+ if (!state.actions.some((action: any) => action.id === actionNode.id)) {
+ actionNode.remove()
+ }
+ }
+ }
+ }
+
+ customElements.define('browser-action-list', BrowserActionListElement)
+ }
+
+ if (process.contextIsolated) {
+ contextBridge.exposeInMainWorld('browserAction', __browserAction__)
+
+ // Must execute script in main world to modify custom component registry.
+ if ('executeInMainWorld' in contextBridge) {
+ contextBridge.executeInMainWorld({
+ func: mainWorldScript,
+ })
+ } else {
+ // Deprecated electron@<35
+ webFrame.executeJavaScript(`(${mainWorldScript}());`)
+ }
+ } else {
+ // When contextIsolation is disabled, contextBridge will throw an error.
+ // If that's the case, we're in the main world so we can just execute our
+ // function.
+ mainWorldScript()
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/browser-action.ts b/packages/electron-chrome-extensions/src/browser/api/browser-action.ts
new file mode 100644
index 0000000..0592089
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/browser-action.ts
@@ -0,0 +1,536 @@
+import { Menu, MenuItem, protocol, nativeImage, app } from 'electron'
+import { ExtensionContext } from '../context'
+import { PopupView } from '../popup'
+import { ExtensionEvent } from '../router'
+import {
+ getExtensionUrl,
+ getExtensionManifest,
+ getIconPath,
+ resolveExtensionPath,
+ matchSize,
+ ResizeType,
+} from './common'
+import debug from 'debug'
+
+const d = debug('electron-chrome-extensions:browserAction')
+
+if (!app.isReady()) {
+ protocol.registerSchemesAsPrivileged([{ scheme: 'crx', privileges: { bypassCSP: true } }])
+}
+
+interface ExtensionAction {
+ color?: string
+ text?: string
+ title?: string
+ icon?: chrome.browserAction.TabIconDetails
+ popup?: string
+ /** Last modified date for icon. */
+ iconModified?: number
+}
+
+type ExtensionActionKey = keyof ExtensionAction
+
+interface ActivateDetails {
+ eventType: string
+ extensionId: string
+ tabId: number
+ anchorRect: { x: number; y: number; width: number; height: number }
+ alignment?: string
+}
+
+const getBrowserActionDefaults = (extension: Electron.Extension): ExtensionAction | undefined => {
+ const manifest = getExtensionManifest(extension)
+ const browserAction =
+ manifest.manifest_version === 3
+ ? manifest.action
+ : manifest.manifest_version === 2
+ ? manifest.browser_action
+ : undefined
+ if (typeof browserAction === 'object') {
+ const manifestAction: chrome.runtime.ManifestAction = browserAction
+ const action: ExtensionAction = {}
+
+ action.title = manifestAction.default_title || manifest.name
+
+ const iconPath = getIconPath(extension)
+ if (iconPath) action.icon = { path: iconPath }
+
+ if (manifestAction.default_popup) {
+ action.popup = manifestAction.default_popup
+ }
+
+ return action
+ }
+}
+
+interface ExtensionActionStore extends Partial {
+ tabs: { [key: string]: ExtensionAction }
+}
+
+export class BrowserActionAPI {
+ private actionMap = new Map* extensionId */ string, ExtensionActionStore>()
+ private popup?: PopupView
+
+ private observers: Set = new Set()
+ private queuedUpdate: boolean = false
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+
+ const getter =
+ (propName: ExtensionActionKey) =>
+ ({ extension }: ExtensionEvent, details: chrome.browserAction.TabDetails = {}) => {
+ const { tabId } = details
+ const action = this.getAction(extension.id)
+
+ let result
+
+ if (tabId) {
+ if (action.tabs[tabId]) {
+ result = action.tabs[tabId][propName]
+ } else {
+ result = action[propName]
+ }
+ } else {
+ result = action[propName]
+ }
+
+ return result
+ }
+
+ const setDetails = (
+ { extension }: ExtensionEvent,
+ details: any,
+ propName: ExtensionActionKey,
+ ) => {
+ const { tabId } = details
+ let value = details[propName]
+
+ if (typeof value === 'undefined' || value === null) {
+ const defaults = getBrowserActionDefaults(extension)
+ value = defaults ? defaults[propName] : value
+ }
+
+ const valueObj = { [propName]: value }
+ const action = this.getAction(extension.id)
+
+ if (tabId) {
+ const tabAction = action.tabs[tabId] || (action.tabs[tabId] = {})
+ Object.assign(tabAction, valueObj)
+ } else {
+ Object.assign(action, valueObj)
+ }
+
+ this.onUpdate()
+ }
+
+ const setter =
+ (propName: ExtensionActionKey) =>
+ (event: ExtensionEvent, details: chrome.browserAction.TabDetails) =>
+ setDetails(event, details, propName)
+
+ const handleProp = (prop: string, key: ExtensionActionKey) => {
+ handle(`browserAction.get${prop}`, getter(key))
+ handle(`browserAction.set${prop}`, setter(key))
+ }
+
+ handleProp('BadgeBackgroundColor', 'color')
+ handleProp('BadgeText', 'text')
+ handleProp('Title', 'title')
+ handleProp('Popup', 'popup')
+
+ handle('browserAction.getUserSettings', (): chrome.action.UserSettings => {
+ // TODO: allow extension pinning
+ return { isOnToolbar: true }
+ })
+
+ // setIcon is unique in that it can pass in a variety of properties. Here we normalize them
+ // to use 'icon'.
+ handle(
+ 'browserAction.setIcon',
+ (event, { tabId, ...details }: chrome.browserAction.TabIconDetails) => {
+ // TODO: icon paths need to be resolved relative to the sender url. In
+ // the case of service workers, we need an API to get the script url.
+ setDetails(event, { tabId, icon: details }, 'icon')
+ setDetails(event, { tabId, iconModified: Date.now() }, 'iconModified')
+ },
+ )
+
+ handle('browserAction.openPopup', this.openPopup)
+
+ // browserAction preload API
+ const preloadOpts = { allowRemote: true, extensionContext: false }
+ handle('browserAction.getState', this.getState.bind(this), preloadOpts)
+ handle('browserAction.activate', this.activate.bind(this), preloadOpts)
+ handle(
+ 'browserAction.addObserver',
+ (event) => {
+ if (event.type != 'frame') return
+ const observer = event.sender
+ this.observers.add(observer)
+ observer.once?.('destroyed', () => {
+ this.observers.delete(observer)
+ })
+ },
+ preloadOpts,
+ )
+ handle(
+ 'browserAction.removeObserver',
+ (event) => {
+ if (event.type != 'frame') return
+ const { sender: observer } = event
+ this.observers.delete(observer)
+ },
+ preloadOpts,
+ )
+
+ this.ctx.store.on('active-tab-changed', () => {
+ this.onUpdate()
+ })
+
+ // Clear out tab details when removed
+ this.ctx.store.on('tab-removed', (tabId: number) => {
+ for (const [, actionDetails] of this.actionMap) {
+ if (actionDetails.tabs[tabId]) {
+ delete actionDetails.tabs[tabId]
+ }
+ }
+ this.onUpdate()
+ })
+
+ this.setupSession(this.ctx.session)
+ }
+
+ private setupSession(session: Electron.Session) {
+ const sessionExtensions = session.extensions || session
+ sessionExtensions.on('extension-loaded', (event, extension) => {
+ this.processExtension(extension)
+ })
+
+ sessionExtensions.on('extension-unloaded', (event, extension) => {
+ this.removeActions(extension.id)
+ })
+ }
+
+ handleCRXRequest(request: GlobalRequest): GlobalResponse {
+ d('%s', request.url)
+
+ try {
+ const url = new URL(request.url)
+ const { hostname: requestType } = url
+
+ switch (requestType) {
+ case 'extension-icon': {
+ const tabId = url.searchParams.get('tabId')
+
+ const fragments = url.pathname.split('/')
+ const extensionId = fragments[1]
+ const imageSize = parseInt(fragments[2], 10)
+ const resizeType = parseInt(fragments[3], 10) || ResizeType.Up
+
+ const sessionExtensions = this.ctx.session.extensions || this.ctx.session
+ const extension = sessionExtensions.getExtension(extensionId)
+
+ let iconDetails: chrome.browserAction.TabIconDetails | undefined
+
+ const action = this.actionMap.get(extensionId)
+ if (action) {
+ iconDetails = (tabId && action.tabs[tabId]?.icon) || action.icon
+ }
+
+ let iconImage
+
+ if (extension && iconDetails) {
+ if (typeof iconDetails.path === 'string') {
+ const iconAbsPath = resolveExtensionPath(extension, iconDetails.path)
+ if (iconAbsPath) iconImage = nativeImage.createFromPath(iconAbsPath)
+ } else if (typeof iconDetails.path === 'object') {
+ const imagePath = matchSize(iconDetails.path, imageSize, resizeType)
+ const iconAbsPath = imagePath && resolveExtensionPath(extension, imagePath)
+ if (iconAbsPath) iconImage = nativeImage.createFromPath(iconAbsPath)
+ } else if (typeof iconDetails.imageData === 'string') {
+ iconImage = nativeImage.createFromDataURL(iconDetails.imageData)
+ } else if (typeof iconDetails.imageData === 'object') {
+ const imageData = matchSize(iconDetails.imageData as any, imageSize, resizeType)
+ iconImage = imageData ? nativeImage.createFromDataURL(imageData) : undefined
+ }
+
+ if (iconImage?.isEmpty()) {
+ d('crx: icon image is empty', iconDetails)
+ }
+ }
+
+ if (iconImage) {
+ return new Response(iconImage.toPNG(), {
+ status: 200,
+ headers: {
+ 'Content-Type': 'image/png',
+ },
+ })
+ }
+
+ d('crx: no icon image for %s', extensionId)
+ return new Response(null, { status: 400 })
+ }
+ default: {
+ d('crx: invalid request %s', requestType)
+ return new Response(null, { status: 400 })
+ }
+ }
+ } catch (e) {
+ console.error(e)
+ return new Response(null, { status: 500 })
+ }
+ }
+
+ private getAction(extensionId: string) {
+ let action = this.actionMap.get(extensionId)
+ if (!action) {
+ action = { tabs: {} }
+ this.actionMap.set(extensionId, action)
+ this.onUpdate()
+ }
+
+ return action
+ }
+
+ // TODO: Make private for v4 major release.
+ removeActions(extensionId: string) {
+ if (this.actionMap.has(extensionId)) {
+ this.actionMap.delete(extensionId)
+ }
+
+ this.onUpdate()
+ }
+
+ private getPopupUrl(extensionId: string, tabId: number) {
+ const action = this.getAction(extensionId)
+ const tabPopupValue = action.tabs[tabId]?.popup
+ const actionPopupValue = action.popup
+
+ let popupPath: string | undefined
+
+ if (typeof tabPopupValue !== 'undefined') {
+ popupPath = tabPopupValue
+ } else if (typeof actionPopupValue !== 'undefined') {
+ popupPath = actionPopupValue
+ }
+
+ let url: string | undefined
+
+ // Allow absolute URLs
+ try {
+ url = popupPath && new URL(popupPath).href
+ } catch {}
+
+ // Fallback to relative path
+ if (!url) {
+ try {
+ url = popupPath && new URL(popupPath, `chrome-extension://${extensionId}`).href
+ } catch {}
+ }
+
+ return url
+ }
+
+ // TODO: Make private for v4 major release.
+ processExtension(extension: Electron.Extension) {
+ const defaultAction = getBrowserActionDefaults(extension)
+ if (defaultAction) {
+ const action = this.getAction(extension.id)
+ Object.assign(action, defaultAction)
+ }
+ }
+
+ private getState() {
+ // Get state without icon data.
+ const actions = Array.from(this.actionMap.entries()).map(([id, details]) => {
+ const { icon, tabs, ...rest } = details
+
+ const tabsInfo: { [key: string]: any } = {}
+
+ for (const tabId of Object.keys(tabs)) {
+ const { icon, ...rest } = tabs[tabId]
+ tabsInfo[tabId] = rest
+ }
+
+ return {
+ id,
+ tabs: tabsInfo,
+ ...rest,
+ }
+ })
+
+ const activeTab = this.ctx.store.getActiveTabOfCurrentWindow()
+ return { activeTabId: activeTab?.id, actions }
+ }
+
+ private activate({ type, sender }: ExtensionEvent, details: ActivateDetails) {
+ if (type != 'frame') return
+ const { eventType, extensionId, tabId } = details
+
+ d(
+ `activate [eventType: ${eventType}, extensionId: '${extensionId}', tabId: ${tabId}, senderId: ${sender?.id}]`,
+ )
+
+ switch (eventType) {
+ case 'click':
+ this.activateClick(details)
+ break
+ case 'contextmenu':
+ this.activateContextMenu(details)
+ break
+ default:
+ console.debug(`Ignoring unknown browserAction.activate event '${eventType}'`)
+ }
+ }
+
+ private activateClick(details: ActivateDetails) {
+ const { extensionId, tabId, anchorRect, alignment } = details
+
+ if (this.popup) {
+ const toggleExtension = !this.popup.isDestroyed() && this.popup.extensionId === extensionId
+ this.popup.destroy()
+ this.popup = undefined
+ if (toggleExtension) {
+ d('skipping activate to close popup')
+ return
+ }
+ }
+
+ const tab =
+ tabId >= 0 ? this.ctx.store.getTabById(tabId) : this.ctx.store.getActiveTabOfCurrentWindow()
+ if (!tab) {
+ throw new Error(`Unable to get active tab`)
+ }
+
+ const popupUrl = this.getPopupUrl(extensionId, tab.id)
+
+ if (popupUrl) {
+ const win = this.ctx.store.tabToWindow.get(tab)
+ if (!win) {
+ throw new Error('Unable to get BrowserWindow from active tab')
+ }
+
+ this.popup = new PopupView({
+ extensionId,
+ session: this.ctx.session,
+ parent: win,
+ url: popupUrl,
+ anchorRect,
+ alignment,
+ })
+
+ d(`opened popup: ${popupUrl}`)
+
+ this.ctx.emit('browser-action-popup-created', this.popup)
+ } else {
+ d(`dispatching onClicked for ${extensionId}`)
+
+ const tabDetails = this.ctx.store.tabDetailsCache.get(tab.id)
+ this.ctx.router.sendEvent(extensionId, 'browserAction.onClicked', tabDetails)
+ }
+ }
+
+ private activateContextMenu(details: ActivateDetails) {
+ const { extensionId, anchorRect } = details
+
+ const sessionExtensions = this.ctx.session.extensions || this.ctx.session
+ const extension = sessionExtensions.getExtension(extensionId)
+ if (!extension) {
+ throw new Error(`Unregistered extension '${extensionId}'`)
+ }
+
+ const manifest = getExtensionManifest(extension)
+ const menu = new Menu()
+ const append = (opts: Electron.MenuItemConstructorOptions) => menu.append(new MenuItem(opts))
+ const appendSeparator = () => menu.append(new MenuItem({ type: 'separator' }))
+
+ append({
+ label: extension.name,
+ click: () => {
+ const homePageUrl =
+ manifest.homepage_url || `https://chrome.google.com/webstore/detail/${extension.id}`
+ this.ctx.store.createTab({ url: homePageUrl })
+ },
+ })
+
+ appendSeparator()
+
+ // TODO(mv3): need to build 'action' menu items?
+ const contextMenuItems: MenuItem[] = this.ctx.store.buildMenuItems(
+ extensionId,
+ 'browser_action',
+ )
+ if (contextMenuItems.length > 0) {
+ contextMenuItems.forEach((item) => menu.append(item))
+ appendSeparator()
+ }
+
+ const optionsPage = manifest.options_page || manifest.options_ui?.page
+ const optionsPageUrl = optionsPage ? getExtensionUrl(extension, optionsPage) : undefined
+
+ append({
+ label: 'Options',
+ enabled: typeof optionsPageUrl === 'string',
+ click: () => {
+ this.ctx.store.createTab({ url: optionsPageUrl })
+ },
+ })
+
+ if (process.env.NODE_ENV === 'development' && process.env.DEBUG) {
+ append({
+ label: 'Remove extension',
+ click: () => {
+ d(`removing extension "${extension.name}" (${extension.id})`)
+ sessionExtensions.removeExtension(extension.id)
+ },
+ })
+ }
+
+ menu.popup({
+ x: Math.floor(anchorRect.x),
+ y: Math.floor(anchorRect.y + anchorRect.height),
+ })
+ }
+
+ private openPopup = (event: ExtensionEvent, options?: chrome.action.OpenPopupOptions) => {
+ const window =
+ typeof options?.windowId === 'number'
+ ? this.ctx.store.getWindowById(options.windowId)
+ : this.ctx.store.getCurrentWindow()
+ if (!window || window.isDestroyed()) {
+ d('openPopup: window %d destroyed', window?.id)
+ return
+ }
+
+ const activeTab = this.ctx.store.getActiveTabFromWindow(window)
+ if (!activeTab) return
+
+ const [width] = window.getSize()
+ const anchorSize = 64
+
+ this.activateClick({
+ eventType: 'click',
+ extensionId: event.extension.id,
+ tabId: activeTab?.id,
+ // TODO(mv3): get anchor position
+ anchorRect: { x: width - anchorSize, y: 0, width: anchorSize, height: anchorSize },
+ })
+ }
+
+ private onUpdate() {
+ if (this.queuedUpdate) return
+ this.queuedUpdate = true
+ queueMicrotask(() => {
+ this.queuedUpdate = false
+ if (this.observers.size === 0) return
+ d(`dispatching update to ${this.observers.size} observer(s)`)
+ Array.from(this.observers).forEach((observer) => {
+ if (!observer.isDestroyed()) {
+ observer.send?.('browserAction.update')
+ }
+ })
+ })
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/commands.ts b/packages/electron-chrome-extensions/src/browser/api/commands.ts
new file mode 100644
index 0000000..352f5e8
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/commands.ts
@@ -0,0 +1,47 @@
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+
+export class CommandsAPI {
+ private commandMap = new Map* extensionId */ string, chrome.commands.Command[]>()
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('commands.getAll', this.getAll)
+
+ const sessionExtensions = ctx.session.extensions || ctx.session
+ sessionExtensions.on('extension-loaded', (_event, extension) => {
+ this.processExtension(extension)
+ })
+
+ sessionExtensions.on('extension-unloaded', (_event, extension) => {
+ this.removeCommands(extension)
+ })
+ }
+
+ private processExtension(extension: Electron.Extension) {
+ const manifest: chrome.runtime.Manifest = extension.manifest
+ if (!manifest.commands) return
+
+ if (!this.commandMap.has(extension.id)) {
+ this.commandMap.set(extension.id, [])
+ }
+ const commands = this.commandMap.get(extension.id)!
+
+ for (const [name, details] of Object.entries(manifest.commands!)) {
+ // TODO: attempt to register commands
+ commands.push({
+ name,
+ description: details.description,
+ shortcut: '',
+ })
+ }
+ }
+
+ private removeCommands(extension: Electron.Extension) {
+ this.commandMap.delete(extension.id)
+ }
+
+ private getAll = ({ extension }: ExtensionEvent): chrome.commands.Command[] => {
+ return this.commandMap.get(extension.id) || []
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/common.ts b/packages/electron-chrome-extensions/src/browser/api/common.ts
new file mode 100644
index 0000000..998f8e6
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/common.ts
@@ -0,0 +1,130 @@
+import { promises as fs } from 'node:fs'
+import * as path from 'node:path'
+import { BaseWindow, BrowserWindow, nativeImage } from 'electron'
+
+export interface TabContents extends Electron.WebContents {
+ favicon?: string
+}
+
+export type ContextMenuType =
+ | 'all'
+ | 'page'
+ | 'frame'
+ | 'selection'
+ | 'link'
+ | 'editable'
+ | 'image'
+ | 'video'
+ | 'audio'
+ | 'launcher'
+ | 'browser_action'
+ | 'page_action'
+ | 'action'
+
+/**
+ * Get the extension's properly typed Manifest.
+ *
+ * I can't seem to get TS's merged type declarations working so I'm using this
+ * instead for now.
+ */
+export const getExtensionManifest = (extension: Electron.Extension): chrome.runtime.Manifest =>
+ extension.manifest
+
+export const getExtensionUrl = (extension: Electron.Extension, uri: string) => {
+ try {
+ return new URL(uri, extension.url).href
+ } catch {}
+}
+
+export const resolveExtensionPath = (
+ extension: Electron.Extension,
+ uri: string,
+ requestPath?: string,
+) => {
+ // Resolve any relative paths.
+ const relativePath = path.join(requestPath || '/', uri)
+ const resPath = path.join(extension.path, relativePath)
+
+ // prevent any parent traversals
+ if (!resPath.startsWith(extension.path)) return
+
+ return resPath
+}
+
+export const validateExtensionResource = async (extension: Electron.Extension, uri: string) => {
+ const resPath = resolveExtensionPath(extension, uri)
+ if (!resPath) return
+
+ try {
+ await fs.stat(resPath)
+ } catch {
+ return // doesn't exist
+ }
+
+ return resPath
+}
+
+export enum ResizeType {
+ Exact,
+ Up,
+ Down,
+}
+
+export const matchSize = (
+ imageSet: { [key: number]: string },
+ size: number,
+ match: ResizeType,
+): string | undefined => {
+ // TODO: match based on size
+ const first = parseInt(Object.keys(imageSet).pop()!, 10)
+ return imageSet[first]
+}
+
+/** Gets the relative path to the extension's default icon. */
+export const getIconPath = (
+ extension: Electron.Extension,
+ iconSize: number = 32,
+ resizeType = ResizeType.Up,
+) => {
+ const manifest = getExtensionManifest(extension)
+ const { icons } = manifest
+
+ const default_icon: chrome.runtime.ManifestIcons | undefined = (
+ manifest.manifest_version === 3 ? manifest.action : manifest.browser_action
+ )?.default_icon
+
+ if (typeof default_icon === 'string') {
+ const iconPath = default_icon
+ return iconPath
+ } else if (typeof default_icon === 'object') {
+ const iconPath = matchSize(default_icon, iconSize, resizeType)
+ return iconPath
+ } else if (typeof icons === 'object') {
+ const iconPath = matchSize(icons, iconSize, resizeType)
+ return iconPath
+ }
+}
+
+export const getIconImage = (extension: Electron.Extension) => {
+ const iconPath = getIconPath(extension)
+ const iconAbsolutePath = iconPath && resolveExtensionPath(extension, iconPath)
+ return iconAbsolutePath ? nativeImage.createFromPath(iconAbsolutePath) : undefined
+}
+
+const escapePattern = (pattern: string) => pattern.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')
+
+/**
+ * @see https://developer.chrome.com/extensions/match_patterns
+ */
+export const matchesPattern = (pattern: string, url: string) => {
+ if (pattern === '') return true
+ const regexp = new RegExp(`^${pattern.split('*').map(escapePattern).join('.*')}$`)
+ return url.match(regexp)
+}
+
+export const matchesTitlePattern = (pattern: string, title: string) => {
+ const regexp = new RegExp(`^${pattern.split('*').map(escapePattern).join('.*')}$`)
+ return title.match(regexp)
+}
+
+export const getAllWindows = () => [...BaseWindow.getAllWindows(), ...BrowserWindow.getAllWindows()]
diff --git a/packages/electron-chrome-extensions/src/browser/api/context-menus.ts b/packages/electron-chrome-extensions/src/browser/api/context-menus.ts
new file mode 100644
index 0000000..d6dfc21
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/context-menus.ts
@@ -0,0 +1,349 @@
+import { Menu, MenuItem } from 'electron'
+import { MenuItemConstructorOptions } from 'electron/main'
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+import { ContextMenuType, getIconImage, matchesPattern } from './common'
+
+type ContextItemProps = chrome.contextMenus.CreateProperties & { id: string }
+
+type ContextItemConstructorOptions = {
+ extension: Electron.Extension
+ props: ContextItemProps
+ webContents: Electron.WebContents
+ params?: Electron.ContextMenuParams
+ showIcon?: boolean
+}
+
+const DEFAULT_CONTEXTS = ['page']
+
+const getContextTypesFromParams = (params: Electron.ContextMenuParams): Set => {
+ const contexts = new Set(['all'])
+
+ switch (params.mediaType) {
+ case 'audio':
+ case 'video':
+ case 'image':
+ contexts.add(params.mediaType)
+ }
+
+ if (params.pageURL) contexts.add('page')
+ if (params.linkURL) contexts.add('link')
+ if (params.frameURL) contexts.add('frame')
+ if (params.selectionText) contexts.add('selection')
+ if (params.isEditable) contexts.add('editable')
+
+ return contexts
+}
+
+const formatTitle = (title: string, params: Electron.ContextMenuParams) => {
+ if (params.selectionText && title.includes('%s')) {
+ title = title.split('%s').join(params.selectionText)
+ }
+ return title
+}
+
+const matchesConditions = (
+ props: ContextItemProps,
+ conditions: {
+ contextTypes: Set
+ targetUrl?: string
+ documentUrl?: string
+ },
+) => {
+ if (props.visible === false) return false
+
+ const { contextTypes, targetUrl, documentUrl } = conditions
+
+ const contexts = props.contexts
+ ? Array.isArray(props.contexts)
+ ? props.contexts
+ : [props.contexts]
+ : DEFAULT_CONTEXTS
+ const inContext = contexts.some((context) => contextTypes.has(context as ContextMenuType))
+ if (!inContext) return false
+
+ if (props.targetUrlPatterns && props.targetUrlPatterns.length > 0 && targetUrl) {
+ if (!props.targetUrlPatterns.some((pattern) => matchesPattern(pattern, targetUrl))) {
+ return false
+ }
+ }
+
+ if (props.documentUrlPatterns && props.documentUrlPatterns.length > 0 && documentUrl) {
+ if (!props.documentUrlPatterns.some((pattern) => matchesPattern(pattern, documentUrl))) {
+ return false
+ }
+ }
+
+ return true
+}
+
+export class ContextMenusAPI {
+ private menus = new Map<
+ /* extensionId */ string,
+ Map* menuItemId */ string, ContextItemProps>
+ >()
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('contextMenus.create', this.create)
+ handle('contextMenus.remove', this.remove)
+ handle('contextMenus.removeAll', this.removeAll)
+
+ const sessionExtensions = ctx.session.extensions || ctx.session
+ sessionExtensions.on('extension-unloaded', (event, extension) => {
+ if (this.menus.has(extension.id)) {
+ this.menus.delete(extension.id)
+ }
+ })
+
+ this.ctx.store.buildMenuItems = this.buildMenuItemsForExtension.bind(this)
+ }
+
+ private addContextItem(extensionId: string, props: ContextItemProps) {
+ let contextItems = this.menus.get(extensionId)
+ if (!contextItems) {
+ contextItems = new Map()
+ this.menus.set(extensionId, contextItems)
+ }
+ contextItems.set(props.id, props)
+ }
+
+ private buildMenuItem = (opts: ContextItemConstructorOptions) => {
+ const { extension, props, webContents, params } = opts
+
+ // TODO: try to get the appropriately sized image before resizing
+ let icon = opts.showIcon ? getIconImage(extension) : undefined
+ if (icon) {
+ icon = icon.resize({ width: 16, height: 16 })
+ }
+
+ const menuItemOptions: MenuItemConstructorOptions = {
+ id: props.id,
+ type: props.type as any,
+ label: params ? formatTitle(props.title || '', params) : props.title || '',
+ icon,
+ enabled: props.enabled,
+ click: () => {
+ this.onClicked(extension.id, props.id, webContents, params)
+ },
+ }
+
+ return menuItemOptions
+ }
+
+ private buildMenuItemsFromTemplate = (menuItemTemplates: ContextItemConstructorOptions[]) => {
+ const itemMap = new Map()
+
+ // Group by ID
+ for (const item of menuItemTemplates) {
+ const menuItem = this.buildMenuItem(item)
+ itemMap.set(item.props.id, menuItem)
+ }
+
+ // Organize in tree
+ for (const item of menuItemTemplates) {
+ const menuItem = itemMap.get(item.props.id)
+ if (item.props.parentId) {
+ const parentMenuItem = itemMap.get(`${item.props.parentId}`)
+ if (parentMenuItem) {
+ const submenu = (parentMenuItem.submenu || []) as Electron.MenuItemConstructorOptions[]
+ submenu.push(menuItem!)
+ parentMenuItem.submenu = submenu
+ }
+ }
+ }
+
+ const menuItems: Electron.MenuItem[] = []
+
+ const buildFromTemplate = (opts: Electron.MenuItemConstructorOptions) => {
+ if (Array.isArray(opts.submenu)) {
+ const submenu = new Menu()
+ opts.submenu.forEach((item) => submenu.append(buildFromTemplate(item)))
+ opts.submenu = submenu
+ }
+ return new MenuItem({
+ ...opts,
+ // Force submenu type when submenu items are present
+ type: opts.type === 'normal' && opts.submenu ? 'submenu' : opts.type,
+ })
+ }
+
+ // Build all final MenuItems in-order
+ for (const item of menuItemTemplates) {
+ // Items with parents will be handled recursively
+ if (item.props.parentId) continue
+
+ const menuItem = itemMap.get(item.props.id)!
+ menuItems.push(buildFromTemplate(menuItem))
+ }
+
+ return menuItems
+ }
+
+ buildMenuItemsForParams(
+ webContents: Electron.WebContents,
+ params: Electron.ContextMenuParams,
+ ): Electron.MenuItem[] {
+ if (webContents.session !== this.ctx.session) return []
+
+ let menuItemOptions: ContextItemConstructorOptions[] = []
+
+ const conditions = {
+ contextTypes: getContextTypesFromParams(params),
+ targetUrl: params.srcURL || params.linkURL,
+ documentUrl: params.frameURL || params.pageURL,
+ }
+
+ const sessionExtensions = this.ctx.session.extensions || this.ctx.session
+
+ for (const [extensionId, propItems] of this.menus) {
+ const extension = sessionExtensions.getExtension(extensionId)
+ if (!extension) continue
+
+ const extensionMenuItemOptions: ContextItemConstructorOptions[] = []
+
+ for (const [, props] of propItems) {
+ if (matchesConditions(props, conditions)) {
+ const menuItem = {
+ extension,
+ props,
+ webContents,
+ params,
+ }
+ extensionMenuItemOptions.push(menuItem)
+ }
+ }
+
+ const topLevelItems = extensionMenuItemOptions.filter((opt) => !opt.props.parentId)
+
+ if (topLevelItems.length > 1) {
+ // Create new top-level item to group children
+ const groupId = `group${extension.id}`
+ const groupMenuItemOptions: ContextItemConstructorOptions = {
+ extension,
+ webContents,
+ props: {
+ id: groupId,
+ title: extension.name,
+ },
+ params,
+ showIcon: true,
+ }
+
+ // Reassign children to group item
+ const children = extensionMenuItemOptions.map((opt) =>
+ opt.props.parentId
+ ? opt
+ : {
+ ...opt,
+ props: {
+ ...opt.props,
+ parentId: groupId,
+ },
+ },
+ )
+
+ menuItemOptions = [...menuItemOptions, groupMenuItemOptions, ...children]
+ } else if (extensionMenuItemOptions.length > 0) {
+ // Set all top-level children to show icon
+ const children = extensionMenuItemOptions.map((opt) => ({
+ ...opt,
+ showIcon: !opt.props.parentId,
+ }))
+ menuItemOptions = [...menuItemOptions, ...children]
+ }
+ }
+
+ return this.buildMenuItemsFromTemplate(menuItemOptions)
+ }
+
+ private buildMenuItemsForExtension(
+ extensionId: string,
+ menuType: ContextMenuType,
+ ): Electron.MenuItem[] {
+ const extensionItems = this.menus.get(extensionId)
+ const sessionExtensions = this.ctx.session.extensions || this.ctx.session
+ const extension = sessionExtensions.getExtension(extensionId)
+ const activeTab = this.ctx.store.getActiveTabOfCurrentWindow()
+
+ const menuItemOptions = []
+
+ if (extensionItems && extension && activeTab) {
+ const conditions = {
+ contextTypes: new Set(['all', menuType]),
+ }
+
+ for (const [, props] of extensionItems) {
+ if (matchesConditions(props, conditions)) {
+ const menuItem = { extension, props, webContents: activeTab }
+ menuItemOptions.push(menuItem)
+ }
+ }
+ }
+
+ return this.buildMenuItemsFromTemplate(menuItemOptions)
+ }
+
+ private create = ({ extension }: ExtensionEvent, createProperties: ContextItemProps) => {
+ const { id, type, title } = createProperties
+
+ if (this.menus.has(id)) {
+ // TODO: duplicate error
+ return
+ }
+
+ if (!title && type !== 'separator') {
+ // TODO: error
+ return
+ }
+
+ this.addContextItem(extension.id, createProperties)
+ }
+
+ private remove = ({ extension }: ExtensionEvent, menuItemId: string) => {
+ const items = this.menus.get(extension.id)
+ if (items && items.has(menuItemId)) {
+ items.delete(menuItemId)
+ if (items.size === 0) {
+ this.menus.delete(extension.id)
+ }
+ }
+ }
+
+ private removeAll = ({ extension }: ExtensionEvent) => {
+ this.menus.delete(extension.id)
+ }
+
+ private onClicked(
+ extensionId: string,
+ menuItemId: string,
+ webContents: Electron.WebContents,
+ params?: Electron.ContextMenuParams,
+ ) {
+ if (webContents.isDestroyed()) return
+
+ const tab = this.ctx.store.tabDetailsCache.get(webContents.id)
+ if (!tab) {
+ console.error(`[Extensions] Unable to find tab for id=${webContents.id}`)
+ return
+ }
+
+ const data: chrome.contextMenus.OnClickData = {
+ selectionText: params?.selectionText,
+ checked: false, // TODO
+ menuItemId,
+ frameId: -1, // TODO: match frameURL with webFrameMain in Electron 12
+ frameUrl: params?.frameURL,
+ editable: params?.isEditable || false,
+ // TODO(mv3): limit possible string enums
+ mediaType: params?.mediaType as any,
+ wasChecked: false, // TODO
+ pageUrl: params?.pageURL as any, // types are inaccurate
+ linkUrl: params?.linkURL,
+ parentMenuItemId: -1, // TODO
+ srcUrl: params?.srcURL,
+ }
+
+ this.ctx.router.sendEvent(extensionId, 'contextMenus.onClicked', data, tab)
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/cookies.ts b/packages/electron-chrome-extensions/src/browser/api/cookies.ts
new file mode 100644
index 0000000..49b4cab
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/cookies.ts
@@ -0,0 +1,115 @@
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+
+enum CookieStoreID {
+ Default = '0',
+ Incognito = '1',
+}
+
+const onChangedCauseTranslation: { [key: string]: string } = {
+ 'expired-overwrite': 'expired_overwrite',
+}
+
+const createCookieDetails = (cookie: Electron.Cookie): chrome.cookies.Cookie => ({
+ ...cookie,
+ domain: cookie.domain || '',
+ hostOnly: Boolean(cookie.hostOnly),
+ session: Boolean(cookie.session),
+ path: cookie.path || '',
+ httpOnly: Boolean(cookie.httpOnly),
+ secure: Boolean(cookie.secure),
+ storeId: CookieStoreID.Default,
+})
+
+export class CookiesAPI {
+ private get cookies() {
+ return this.ctx.session.cookies
+ }
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('cookies.get', this.get.bind(this))
+ handle('cookies.getAll', this.getAll.bind(this))
+ handle('cookies.set', this.set.bind(this))
+ handle('cookies.remove', this.remove.bind(this))
+ handle('cookies.getAllCookieStores', this.getAllCookieStores.bind(this))
+
+ this.cookies.addListener('changed', this.onChanged)
+ }
+
+ private async get(
+ event: ExtensionEvent,
+ details: chrome.cookies.CookieDetails,
+ ): Promise {
+ // TODO: storeId
+ const cookies = await this.cookies.get({
+ url: details.url,
+ name: details.name,
+ })
+
+ // TODO: If more than one cookie of the same name exists for the given URL,
+ // the one with the longest path will be returned. For cookies with the
+ // same path length, the cookie with the earliest creation time will be returned.
+ return cookies.length > 0 ? createCookieDetails(cookies[0]) : null
+ }
+
+ private async getAll(
+ event: ExtensionEvent,
+ details: chrome.cookies.GetAllDetails,
+ ): Promise {
+ // TODO: storeId
+ const cookies = await this.cookies.get({
+ url: details.url,
+ name: details.name,
+ domain: details.domain,
+ path: details.path,
+ secure: details.secure,
+ session: details.session,
+ })
+
+ return cookies.map(createCookieDetails)
+ }
+
+ private async set(
+ event: ExtensionEvent,
+ details: chrome.cookies.SetDetails,
+ ): Promise {
+ await this.cookies.set(details)
+ const cookies = await this.cookies.get(details)
+ return cookies.length > 0 ? createCookieDetails(cookies[0]) : null
+ }
+
+ private async remove(
+ event: ExtensionEvent,
+ details: chrome.cookies.CookieDetails,
+ ): Promise {
+ try {
+ await this.cookies.remove(details.url, details.name)
+ } catch {
+ return null
+ }
+ return details
+ }
+
+ private async getAllCookieStores(event: ExtensionEvent): Promise {
+ const tabIds = Array.from(this.ctx.store.tabs)
+ .map((tab) => (tab.isDestroyed() ? undefined : tab.id))
+ .filter(Boolean) as number[]
+ return [{ id: CookieStoreID.Default, tabIds }]
+ }
+
+ private onChanged = (
+ event: Electron.Event,
+ cookie: Electron.Cookie,
+ cause: string,
+ removed: boolean,
+ ) => {
+ const changeInfo: chrome.cookies.CookieChangeInfo = {
+ cause: onChangedCauseTranslation[cause] || cause,
+ cookie: createCookieDetails(cookie),
+ removed,
+ }
+
+ this.ctx.router.broadcastEvent('cookies.onChanged', changeInfo)
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts b/packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts
new file mode 100644
index 0000000..1fcbc61
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts
@@ -0,0 +1,233 @@
+import { spawn } from 'node:child_process'
+import { promises as fs } from 'node:fs'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import { app } from 'electron'
+import debug from 'debug'
+import { ExtensionSender, IpcEvent } from '../../router'
+import { readRegistryKey } from './winreg'
+
+const d = debug('electron-chrome-extensions:nativeMessaging')
+
+interface NativeConfig {
+ name: string
+ description: string
+ path: string
+ type: 'stdio'
+ allowed_origins: string[]
+}
+
+function isValidConfig(config: any): config is NativeConfig {
+ return (
+ typeof config === 'object' &&
+ config !== null &&
+ typeof config.name === 'string' &&
+ typeof config.description === 'string' &&
+ typeof config.path === 'string' &&
+ config.type === 'stdio' &&
+ Array.isArray(config.allowed_origins)
+ )
+}
+
+async function getConfigSearchPaths(application: string) {
+ const appJson = `${application}.json`
+ let searchPaths: string[]
+ switch (process.platform) {
+ case 'darwin':
+ searchPaths = [
+ path.join('/Library/Google/Chrome/NativeMessagingHosts', appJson),
+ // Also look under Chrome's directory since some apps only install their
+ // config there
+ path.join(
+ os.homedir(),
+ 'Library',
+ 'Application Support',
+ 'Google/Chrome/NativeMessagingHosts',
+ appJson,
+ ),
+ path.join(app.getPath('userData'), 'NativeMessagingHosts', appJson),
+ ]
+ break
+ case 'linux':
+ searchPaths = [
+ path.join('/etc/opt/chrome/native-messaging-hosts/', appJson),
+ path.join(os.homedir(), '.config/google-chrome/NativeMessagingHosts/', appJson),
+ path.join(app.getPath('userData'), 'NativeMessagingHosts', appJson),
+ ]
+ break
+ case 'win32': {
+ searchPaths = (
+ await Promise.allSettled([
+ readRegistryKey('HKLM', `Software\\Google\\Chrome\\NativeMessagingHosts\\${application}`),
+ readRegistryKey('HKCU', `Software\\Google\\Chrome\\NativeMessagingHosts\\${application}`),
+ ])
+ )
+ .map((result) => (result.status === 'fulfilled' ? result.value : undefined))
+ .filter(Boolean) as string[]
+ break
+ }
+ default:
+ throw new Error('Unsupported platform')
+ }
+ return searchPaths
+}
+
+async function readNativeMessagingHostConfig(
+ application: string,
+): Promise {
+ const searchPaths = await getConfigSearchPaths(application)
+ d('searching', searchPaths)
+ for (const filePath of searchPaths) {
+ try {
+ const data = await fs.readFile(filePath)
+ const config = JSON.parse(data.toString())
+ if (isValidConfig(config)) {
+ d('read config in %s', filePath, config)
+ return config
+ } else {
+ d('%s contained invalid config', filePath, config)
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
+ d('unable to read %s', filePath)
+ } else {
+ d('unknown error', error)
+ }
+ continue
+ }
+ }
+}
+export class NativeMessagingHost {
+ private process?: ReturnType
+ private sender?: ExtensionSender
+ private connectionId: string
+ private connected: boolean = false
+ private pending?: any[]
+ private keepAlive: boolean
+ private resolveResponse?: (message: any) => void
+
+ ready?: Promise
+
+ constructor(
+ extensionId: string,
+ sender: ExtensionSender,
+ connectionId: string,
+ application: string,
+ keepAlive: boolean = true,
+ ) {
+ this.keepAlive = keepAlive
+ this.sender = sender
+ if (keepAlive) {
+ this.sender.ipc.on(`crx-native-msg-${connectionId}`, this.receiveExtensionMessage)
+ }
+ this.connectionId = connectionId
+ this.ready = this.launch(application, extensionId)
+ }
+
+ destroy() {
+ this.connected = false
+ if (this.process) {
+ this.process.kill()
+ this.process = undefined
+ }
+ if (this.keepAlive && this.sender) {
+ this.sender.ipc.off(`crx-native-msg-${this.connectionId}`, this.receiveExtensionMessage)
+ this.sender.send(`crx-native-msg-${this.connectionId}-disconnect`)
+ }
+ this.sender = undefined
+ }
+
+ private async launch(application: string, extensionId: string) {
+ const config = await readNativeMessagingHostConfig(application)
+ if (!config) {
+ d('launch: unable to find %s for %s', application, extensionId)
+ this.destroy()
+ return
+ }
+
+ const extensionUrl = `chrome-extension://${extensionId}/`
+ if (!config.allowed_origins?.includes(extensionUrl)) {
+ d('launch: %s not in allowed origins', extensionId)
+ this.destroy()
+ return
+ }
+
+ let isFile = false
+ try {
+ const stat = await fs.stat(config.path)
+ isFile = stat.isFile()
+ } catch (error) {
+ d('launch: unable to find %s', config.path, error)
+ }
+
+ if (!isFile) {
+ this.destroy()
+ return
+ }
+
+ d('launch: spawning %s for %s', config.path, extensionId)
+ this.process = spawn(config.path, [extensionUrl], {
+ shell: false,
+ })
+
+ this.process.stdout!.on('data', this.receive)
+ this.process.stderr!.on('data', (data) => {
+ d('stderr: %s', data.toString())
+ })
+ this.process.on('error', (err) => {
+ d('error: %s', err)
+ this.destroy()
+ })
+ this.process.on('exit', (code) => {
+ d('exited %d', code)
+ this.destroy()
+ })
+
+ this.connected = true
+
+ if (this.pending && this.pending.length > 0) {
+ d('sending %d pending messages', this.pending.length)
+ this.pending.forEach((msg) => this.send(msg))
+ this.pending = []
+ }
+ }
+
+ private receiveExtensionMessage = (_event: IpcEvent, message: any) => {
+ this.send(message)
+ }
+
+ private send(json: any) {
+ d('send', json)
+
+ if (!this.connected) {
+ const pending = this.pending || (this.pending = [])
+ pending.push(json)
+ d('send: pending')
+ return
+ }
+
+ const message = JSON.stringify(json)
+ const buffer = Buffer.alloc(4 + message.length)
+ buffer.writeUInt32LE(message.length, 0)
+ buffer.write(message, 4)
+ this.process!.stdin!.write(buffer)
+ }
+
+ private receive = (data: Buffer) => {
+ const length = data.readUInt32LE(0)
+ const message = JSON.parse(data.subarray(4, 4 + length).toString())
+ d('receive: %s', message)
+ if (this.keepAlive && this.sender) {
+ this.sender.send(`crx-native-msg-${this.connectionId}`, message)
+ } else {
+ this.resolveResponse?.(message)
+ }
+ }
+
+ sendAndReceive(message: any) {
+ this.send(message)
+ return new Promise((resolve) => {
+ this.resolveResponse = resolve
+ })
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/lib/winreg.ts b/packages/electron-chrome-extensions/src/browser/api/lib/winreg.ts
new file mode 100644
index 0000000..47fd872
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/lib/winreg.ts
@@ -0,0 +1,45 @@
+import { spawn } from 'child_process'
+import debug from 'debug'
+
+const d = debug('electron-chrome-extensions:winreg')
+
+export function readRegistryKey(hive: string, path: string, key?: string) {
+ if (process.platform !== 'win32') {
+ return Promise.reject('Unsupported platform')
+ }
+
+ return new Promise((resolve, reject) => {
+ const args = ['query', `${hive}\\${path}`, ...(key ? ['/v', key] : [])]
+ d('reg %s', args.join(' '))
+ const child = spawn('reg', args)
+
+ let output = ''
+ let error = ''
+
+ child.stdout.on('data', (data) => {
+ output += data.toString()
+ })
+
+ child.stderr.on('data', (data) => {
+ error += data.toString()
+ })
+
+ child.on('close', (code) => {
+ if (code !== 0 || error) {
+ return reject(new Error(`Failed to read registry: ${error}`))
+ }
+
+ const lines = output.trim().split('\n')
+ const resultLine = lines.find((line) =>
+ key ? line.includes(key) : line.includes('(Default)'),
+ )
+
+ if (resultLine) {
+ const parts = resultLine.trim().split(/\s{2,}/)
+ resolve(parts.pop() || null)
+ } else {
+ resolve(null)
+ }
+ })
+ })
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/notifications.ts b/packages/electron-chrome-extensions/src/browser/api/notifications.ts
new file mode 100644
index 0000000..462d953
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/notifications.ts
@@ -0,0 +1,176 @@
+import { app, Extension, Notification } from 'electron'
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+import { validateExtensionResource } from './common'
+
+enum TemplateType {
+ Basic = 'basic',
+ Image = 'image',
+ List = 'list',
+ Progress = 'progress',
+}
+
+const getBody = (opts: chrome.notifications.NotificationOptions) => {
+ const { type = TemplateType.Basic } = opts
+
+ switch (type) {
+ case TemplateType.List: {
+ if (!Array.isArray(opts.items)) {
+ throw new Error('List items must be provided for list type')
+ }
+ return opts.items.map((item) => `${item.title} - ${item.message}`).join('\n')
+ }
+ default:
+ return opts.message || ''
+ }
+}
+
+const getUrgency = (
+ priority?: number,
+): Required['urgency'] => {
+ if (typeof priority !== 'number') {
+ return 'normal'
+ } else if (priority >= 2) {
+ return 'critical'
+ } else if (priority < 0) {
+ return 'low'
+ } else {
+ return 'normal'
+ }
+}
+
+const createScopedIdentifier = (extension: Extension, id: string) => `${extension.id}-${id}`
+const stripScopeFromIdentifier = (id: string) => {
+ const index = id.indexOf('-')
+ return id.substr(index + 1)
+}
+
+export class NotificationsAPI {
+ private registry = new Map()
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('notifications.clear', this.clear)
+ handle('notifications.create', this.create)
+ handle('notifications.getAll', this.getAll)
+ handle('notifications.getPermissionLevel', this.getPermissionLevel)
+ handle('notifications.update', this.update)
+
+ const sessionExtensions = ctx.session.extensions || ctx.session
+ sessionExtensions.on('extension-unloaded', (event, extension) => {
+ for (const [key, notification] of this.registry) {
+ if (key.startsWith(extension.id)) {
+ notification.close()
+ }
+ }
+ })
+ }
+
+ private clear = ({ extension }: ExtensionEvent, id: string) => {
+ const notificationId = createScopedIdentifier(extension, id)
+ if (this.registry.has(notificationId)) {
+ this.registry.get(notificationId)?.close()
+ }
+ }
+
+ private create = async ({ extension }: ExtensionEvent, arg1: unknown, arg2?: unknown) => {
+ let id: string
+ let opts: chrome.notifications.NotificationOptions
+
+ if (typeof arg1 === 'object') {
+ id = 'guid' // TODO: generate uuid
+ opts = arg1 as chrome.notifications.NotificationOptions
+ } else if (typeof arg1 === 'string') {
+ id = arg1
+ opts = arg2 as chrome.notifications.NotificationOptions
+ } else {
+ throw new Error('Invalid arguments')
+ }
+
+ if (typeof opts !== 'object' || !opts.type || !opts.iconUrl || !opts.title || !opts.message) {
+ throw new Error('Missing required notification options')
+ }
+
+ const notificationId = createScopedIdentifier(extension, id)
+
+ if (this.registry.has(notificationId)) {
+ this.registry.get(notificationId)?.close()
+ }
+
+ let icon
+
+ if (opts.iconUrl) {
+ let url
+ try {
+ url = new URL(opts.iconUrl)
+ } catch {}
+
+ if (url?.protocol === 'data:') {
+ icon = opts.iconUrl
+ } else {
+ icon = await validateExtensionResource(extension, opts.iconUrl)
+ }
+
+ if (!icon) {
+ throw new Error('Invalid iconUrl')
+ }
+ }
+
+ // TODO: buttons, template types
+
+ const notification = new Notification({
+ title: opts.title,
+ subtitle: app.name,
+ body: getBody(opts),
+ silent: opts.silent,
+ icon,
+ urgency: getUrgency(opts.priority),
+ timeoutType: opts.requireInteraction ? 'never' : 'default',
+ })
+
+ this.registry.set(notificationId, notification)
+
+ notification.on('click', () => {
+ this.ctx.router.sendEvent(extension.id, 'notifications.onClicked', id)
+ })
+
+ notification.once('close', () => {
+ const byUser = true // TODO
+ this.ctx.router.sendEvent(extension.id, 'notifications.onClosed', id, byUser)
+ this.registry.delete(notificationId)
+ })
+
+ notification.show()
+
+ return id
+ }
+
+ private getAll = ({ extension }: ExtensionEvent) => {
+ return Array.from(this.registry.keys())
+ .filter((key) => key.startsWith(extension.id))
+ .map(stripScopeFromIdentifier)
+ }
+
+ private getPermissionLevel = (event: ExtensionEvent) => {
+ return Notification.isSupported() ? 'granted' : 'denied'
+ }
+
+ private update = (
+ { extension }: ExtensionEvent,
+ id: string,
+ opts: chrome.notifications.NotificationOptions,
+ ) => {
+ const notificationId = createScopedIdentifier(extension, id)
+
+ const notification = this.registry.get(notificationId)
+
+ if (!notification) {
+ return false
+ }
+
+ // TODO: remaining opts
+
+ if (opts.priority) notification.urgency = getUrgency(opts.priority)
+ if (opts.silent) notification.silent = opts.silent
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/permissions.ts b/packages/electron-chrome-extensions/src/browser/api/permissions.ts
new file mode 100644
index 0000000..2886b9f
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/permissions.ts
@@ -0,0 +1,102 @@
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+
+/**
+ * This is a very basic implementation of the permissions API. Likely
+ * more work will be needed to integrate with the native permissions.
+ */
+export class PermissionsAPI {
+ private permissionMap = new Map<
+ /* extensionId */ string,
+ {
+ permissions: chrome.runtime.ManifestPermissions[]
+ origins: string[]
+ }
+ >()
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('permissions.contains', this.contains)
+ handle('permissions.getAll', this.getAll)
+ handle('permissions.remove', this.remove)
+ handle('permissions.request', this.request)
+
+ const sessionExtensions = ctx.session.extensions || ctx.session
+ sessionExtensions.getAllExtensions().forEach((ext) => this.processExtension(ext))
+
+ sessionExtensions.on('extension-loaded', (_event, extension) => {
+ this.processExtension(extension)
+ })
+
+ sessionExtensions.on('extension-unloaded', (_event, extension) => {
+ this.permissionMap.delete(extension.id)
+ })
+ }
+
+ private processExtension(extension: Electron.Extension) {
+ const manifest: chrome.runtime.Manifest = extension.manifest
+ this.permissionMap.set(extension.id, {
+ permissions: (manifest.permissions || []) as chrome.runtime.ManifestPermissions[],
+ origins: manifest.host_permissions || [],
+ })
+ }
+
+ private contains = (
+ { extension }: ExtensionEvent,
+ permissions: chrome.permissions.Permissions,
+ ) => {
+ const currentPermissions = this.permissionMap.get(extension.id)!
+ const hasPermissions = permissions.permissions
+ ? permissions.permissions.every((permission) =>
+ currentPermissions.permissions.includes(permission),
+ )
+ : true
+ const hasOrigins = permissions.origins
+ ? permissions.origins.every((origin) => currentPermissions.origins.includes(origin))
+ : true
+ return hasPermissions && hasOrigins
+ }
+
+ private getAll = ({ extension }: ExtensionEvent) => {
+ return this.permissionMap.get(extension.id)
+ }
+
+ private remove = ({ extension }: ExtensionEvent, permissions: chrome.permissions.Permissions) => {
+ // TODO
+ return true
+ }
+
+ private request = async (
+ { extension }: ExtensionEvent,
+ request: chrome.permissions.Permissions,
+ ) => {
+ const declaredPermissions = new Set([
+ ...(extension.manifest.permissions || []),
+ ...(extension.manifest.optional_permissions || []),
+ ])
+
+ if (request.permissions && !request.permissions.every((p) => declaredPermissions.has(p))) {
+ throw new Error('Permissions request includes undeclared permission')
+ }
+
+ const granted = await this.ctx.store.requestPermissions(extension, request)
+ if (!granted) return false
+
+ const permissions = this.permissionMap.get(extension.id)!
+ if (request.origins) {
+ for (const origin of request.origins) {
+ if (!permissions.origins.includes(origin)) {
+ permissions.origins.push(origin)
+ }
+ }
+ }
+ if (request.permissions) {
+ for (const permission of request.permissions) {
+ if (!permissions.permissions.includes(permission)) {
+ permissions.permissions.push(permission)
+ }
+ }
+ }
+ return true
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/runtime.ts b/packages/electron-chrome-extensions/src/browser/api/runtime.ts
new file mode 100644
index 0000000..418dfd8
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/runtime.ts
@@ -0,0 +1,68 @@
+import { randomUUID } from 'node:crypto'
+import { EventEmitter } from 'node:events'
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+import { getExtensionManifest } from './common'
+import { NativeMessagingHost } from './lib/native-messaging-host'
+
+export class RuntimeAPI extends EventEmitter {
+ private hostMap: Record = {}
+
+ constructor(private ctx: ExtensionContext) {
+ super()
+
+ const handle = this.ctx.router.apiHandler()
+ handle('runtime.connectNative', this.connectNative, { permission: 'nativeMessaging' })
+ handle('runtime.disconnectNative', this.disconnectNative, { permission: 'nativeMessaging' })
+ handle('runtime.openOptionsPage', this.openOptionsPage)
+ handle('runtime.sendNativeMessage', this.sendNativeMessage, { permission: 'nativeMessaging' })
+ }
+
+ private connectNative = async (
+ event: ExtensionEvent,
+ connectionId: string,
+ application: string,
+ ) => {
+ const host = new NativeMessagingHost(
+ event.extension.id,
+ event.sender!,
+ connectionId,
+ application,
+ )
+ this.hostMap[connectionId] = host
+ }
+
+ private disconnectNative = (event: ExtensionEvent, connectionId: string) => {
+ this.hostMap[connectionId]?.destroy()
+ this.hostMap[connectionId] = undefined
+ }
+
+ private sendNativeMessage = async (event: ExtensionEvent, application: string, message: any) => {
+ const connectionId = randomUUID()
+ const host = new NativeMessagingHost(
+ event.extension.id,
+ event.sender!,
+ connectionId,
+ application,
+ false,
+ )
+ await host.ready
+ return await host.sendAndReceive(message)
+ }
+
+ private openOptionsPage = async ({ extension }: ExtensionEvent) => {
+ // TODO: options page shouldn't appear in Tabs API
+ // https://developer.chrome.com/extensions/options#tabs-api
+
+ const manifest = getExtensionManifest(extension)
+
+ if (manifest.options_ui) {
+ // Embedded option not support (!options_ui.open_in_new_tab)
+ const url = `chrome-extension://${extension.id}/${manifest.options_ui.page}`
+ await this.ctx.store.createTab({ url, active: true })
+ } else if (manifest.options_page) {
+ const url = `chrome-extension://${extension.id}/${manifest.options_page}`
+ await this.ctx.store.createTab({ url, active: true })
+ }
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/tabs.ts b/packages/electron-chrome-extensions/src/browser/api/tabs.ts
new file mode 100644
index 0000000..59afa87
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/tabs.ts
@@ -0,0 +1,405 @@
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+import { getAllWindows, matchesPattern, matchesTitlePattern, TabContents } from './common'
+import { WindowsAPI } from './windows'
+import debug from 'debug'
+
+const d = debug('electron-chrome-extensions:tabs')
+
+const validateExtensionUrl = (url: string, extension: Electron.Extension) => {
+ // Convert relative URLs to absolute if needed
+ try {
+ url = new URL(url, extension.url).href
+ } catch (e) {
+ throw new Error('Invalid URL')
+ }
+
+ // Prevent creating chrome://kill or other debug commands
+ if (url.startsWith('chrome:') || url.startsWith('javascript:')) {
+ throw new Error('Invalid URL')
+ }
+
+ return url
+}
+
+export class TabsAPI {
+ static TAB_ID_NONE = -1
+ static WINDOW_ID_NONE = -1
+ static WINDOW_ID_CURRENT = -2
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('tabs.get', this.get.bind(this))
+ handle('tabs.getAllInWindow', this.getAllInWindow.bind(this))
+ handle('tabs.getCurrent', this.getCurrent.bind(this))
+ handle('tabs.create', this.create.bind(this))
+ handle('tabs.insertCSS', this.insertCSS.bind(this))
+ handle('tabs.query', this.query.bind(this))
+ handle('tabs.reload', this.reload.bind(this))
+ handle('tabs.update', this.update.bind(this))
+ handle('tabs.remove', this.remove.bind(this))
+ handle('tabs.goForward', this.goForward.bind(this))
+ handle('tabs.goBack', this.goBack.bind(this))
+
+ this.ctx.store.on('tab-added', this.observeTab.bind(this))
+ }
+
+ private observeTab(tab: TabContents) {
+ const tabId = tab.id
+
+ const updateEvents = [
+ 'page-title-updated', // title
+ 'did-start-loading', // status
+ 'did-stop-loading', // status
+ 'media-started-playing', // audible
+ 'media-paused', // audible
+ 'did-start-navigation', // url
+ 'did-redirect-navigation', // url
+ 'did-navigate-in-page', // url
+
+ // Listen for 'tab-updated' to handle all other cases which don't have
+ // an official Electron API such as discarded tabs. App developers can
+ // emit this event to trigger chrome.tabs.onUpdated if a property has
+ // changed.
+ 'tab-updated',
+ ]
+
+ const updateHandler = () => {
+ this.onUpdated(tabId)
+ }
+
+ updateEvents.forEach((eventName) => {
+ tab.on(eventName as any, updateHandler)
+ })
+
+ const faviconHandler = (event: Electron.Event, favicons: string[]) => {
+ ;(tab as TabContents).favicon = favicons[0]
+ this.onUpdated(tabId)
+ }
+ tab.on('page-favicon-updated', faviconHandler)
+
+ tab.once('destroyed', () => {
+ updateEvents.forEach((eventName) => {
+ tab.off(eventName as any, updateHandler)
+ })
+ tab.off('page-favicon-updated', faviconHandler)
+
+ this.ctx.store.removeTab(tab)
+ this.onRemoved(tabId)
+ })
+
+ this.onCreated(tabId)
+ this.onActivated(tabId)
+
+ d(`Observing tab[${tabId}][${tab.getType()}] ${tab.getURL()}`)
+ }
+
+ private createTabDetails(tab: TabContents) {
+ const tabId = tab.id
+ const activeTab = this.ctx.store.getActiveTabFromWebContents(tab)
+ let win = this.ctx.store.tabToWindow.get(tab)
+ if (win?.isDestroyed()) win = undefined
+ const [width = 0, height = 0] = win ? win.getSize() : []
+
+ const details: chrome.tabs.Tab = {
+ active: activeTab?.id === tabId,
+ audible: tab.isCurrentlyAudible(),
+ autoDiscardable: true,
+ discarded: false,
+ favIconUrl: tab.favicon || undefined,
+ frozen: false,
+ height,
+ highlighted: false,
+ id: tabId,
+ incognito: false,
+ index: -1, // TODO
+ groupId: -1, // TODO(mv3): implement?
+ mutedInfo: { muted: tab.audioMuted },
+ pinned: false,
+ selected: true,
+ status: tab.isLoading() ? 'loading' : 'complete',
+ title: tab.getTitle(),
+ url: tab.getURL(), // TODO: tab.mainFrame.url (Electron 12)
+ width,
+ windowId: win ? win.id : -1,
+ }
+
+ if (typeof this.ctx.store.impl.assignTabDetails === 'function') {
+ this.ctx.store.impl.assignTabDetails(details, tab)
+ }
+
+ this.ctx.store.tabDetailsCache.set(tab.id, details)
+ return details
+ }
+
+ private getTabDetails(tab: TabContents) {
+ if (this.ctx.store.tabDetailsCache.has(tab.id)) {
+ return this.ctx.store.tabDetailsCache.get(tab.id)
+ }
+ const details = this.createTabDetails(tab)
+ return details
+ }
+
+ private get(event: ExtensionEvent, tabId: number) {
+ const tab = this.ctx.store.getTabById(tabId)
+ if (!tab) return { id: TabsAPI.TAB_ID_NONE }
+ return this.getTabDetails(tab)
+ }
+
+ private getAllInWindow(event: ExtensionEvent, windowId: number = TabsAPI.WINDOW_ID_CURRENT) {
+ if (windowId === TabsAPI.WINDOW_ID_CURRENT) windowId = this.ctx.store.lastFocusedWindowId!
+
+ const tabs = Array.from(this.ctx.store.tabs).filter((tab) => {
+ if (tab.isDestroyed()) return false
+
+ const browserWindow = this.ctx.store.tabToWindow.get(tab)
+ if (!browserWindow || browserWindow.isDestroyed()) return
+
+ return browserWindow.id === windowId
+ })
+
+ return tabs.map(this.getTabDetails.bind(this))
+ }
+
+ private getCurrent(event: ExtensionEvent) {
+ const tab = this.ctx.store.getActiveTabOfCurrentWindow()
+ return tab ? this.getTabDetails(tab) : undefined
+ }
+
+ private async create(event: ExtensionEvent, details: chrome.tabs.CreateProperties = {}) {
+ const url = details.url ? validateExtensionUrl(details.url, event.extension) : undefined
+ const tab = await this.ctx.store.createTab({ ...details, url })
+ const tabDetails = this.getTabDetails(tab)
+ if (details.active) {
+ queueMicrotask(() => this.onActivated(tab.id))
+ }
+ return tabDetails
+ }
+
+ private insertCSS(event: ExtensionEvent, tabId: number, details: chrome.tabs.InjectDetails) {
+ const tab = this.ctx.store.getTabById(tabId)
+ if (!tab) return
+
+ // TODO: move to webFrame in renderer?
+ if (details.code) {
+ tab.insertCSS(details.code)
+ }
+ }
+
+ private query(event: ExtensionEvent, info: chrome.tabs.QueryInfo = {}) {
+ const isSet = (value: any) => typeof value !== 'undefined'
+
+ const filteredTabs = Array.from(this.ctx.store.tabs)
+ .map(this.getTabDetails.bind(this))
+ .filter((tab) => {
+ if (!tab) return false
+ if (isSet(info.active) && info.active !== tab.active) return false
+ if (isSet(info.pinned) && info.pinned !== tab.pinned) return false
+ if (isSet(info.audible) && info.audible !== tab.audible) return false
+ if (isSet(info.muted) && info.muted !== tab.mutedInfo?.muted) return false
+ if (isSet(info.highlighted) && info.highlighted !== tab.highlighted) return false
+ if (isSet(info.discarded) && info.discarded !== tab.discarded) return false
+ if (isSet(info.autoDiscardable) && info.autoDiscardable !== tab.autoDiscardable)
+ return false
+ // if (isSet(info.currentWindow)) return false
+ // if (isSet(info.lastFocusedWindow)) return false
+ if (isSet(info.frozen) && info.frozen !== tab.frozen) return false
+ if (isSet(info.groupId) && info.groupId !== tab.groupId) return false
+ if (isSet(info.status) && info.status !== tab.status) return false
+ if (isSet(info.title) && typeof info.title === 'string' && typeof tab.title === 'string') {
+ if (!matchesTitlePattern(info.title, tab.title)) return false
+ }
+ if (isSet(info.url) && typeof tab.url === 'string') {
+ if (typeof info.url === 'string' && !matchesPattern(info.url, tab.url!)) {
+ return false
+ } else if (
+ Array.isArray(info.url) &&
+ !info.url.some((pattern) => matchesPattern(pattern, tab.url!))
+ ) {
+ return false
+ }
+ }
+ if (isSet(info.windowId)) {
+ if (info.windowId === TabsAPI.WINDOW_ID_CURRENT) {
+ if (this.ctx.store.lastFocusedWindowId !== tab.windowId) return false
+ } else if (info.windowId !== tab.windowId) {
+ return false
+ }
+ }
+ // if (isSet(info.windowType) && info.windowType !== tab.windowType) return false
+ // if (isSet(info.index) && info.index !== tab.index) return false
+ return true
+ })
+ .map((tab, index) => {
+ if (tab) {
+ tab.index = index
+ }
+ return tab
+ })
+ return filteredTabs
+ }
+
+ private reload(event: ExtensionEvent, arg1?: unknown, arg2?: unknown) {
+ const tabId: number | undefined = typeof arg1 === 'number' ? arg1 : undefined
+ const reloadProperties: chrome.tabs.ReloadProperties | null =
+ typeof arg1 === 'object' ? arg1 : typeof arg2 === 'object' ? arg2 : {}
+
+ const tab = tabId
+ ? this.ctx.store.getTabById(tabId)
+ : this.ctx.store.getActiveTabOfCurrentWindow()
+ if (!tab) return
+
+ if (reloadProperties?.bypassCache) {
+ tab.reloadIgnoringCache()
+ } else {
+ tab.reload()
+ }
+ }
+
+ private async update(event: ExtensionEvent, arg1?: unknown, arg2?: unknown) {
+ let tabId = typeof arg1 === 'number' ? arg1 : undefined
+ const updateProperties: chrome.tabs.UpdateProperties =
+ (typeof arg1 === 'object' ? (arg1 as any) : (arg2 as any)) || {}
+
+ const tab = tabId
+ ? this.ctx.store.getTabById(tabId)
+ : this.ctx.store.getActiveTabOfCurrentWindow()
+ if (!tab) return
+
+ tabId = tab.id
+
+ const props = updateProperties
+
+ const url = props.url ? validateExtensionUrl(props.url, event.extension) : undefined
+ if (url) await tab.loadURL(url)
+
+ if (typeof props.muted === 'boolean') tab.setAudioMuted(props.muted)
+
+ if (props.active) this.onActivated(tabId)
+
+ this.onUpdated(tabId)
+
+ return this.createTabDetails(tab)
+ }
+
+ private remove(event: ExtensionEvent, id: number | number[]) {
+ const ids = Array.isArray(id) ? id : [id]
+
+ ids.forEach((tabId) => {
+ const tab = this.ctx.store.getTabById(tabId)
+ if (tab) this.ctx.store.removeTab(tab)
+ this.onRemoved(tabId)
+ })
+ }
+
+ private goForward(event: ExtensionEvent, arg1?: unknown) {
+ const tabId = typeof arg1 === 'number' ? arg1 : undefined
+ const tab = tabId
+ ? this.ctx.store.getTabById(tabId)
+ : this.ctx.store.getActiveTabOfCurrentWindow()
+ if (!tab) return
+ tab.navigationHistory.goForward()
+ }
+
+ private goBack(event: ExtensionEvent, arg1?: unknown) {
+ const tabId = typeof arg1 === 'number' ? arg1 : undefined
+ const tab = tabId
+ ? this.ctx.store.getTabById(tabId)
+ : this.ctx.store.getActiveTabOfCurrentWindow()
+ if (!tab) return
+ tab.navigationHistory.goBack()
+ }
+
+ onCreated(tabId: number) {
+ const tab = this.ctx.store.getTabById(tabId)
+ if (!tab) return
+ const tabDetails = this.getTabDetails(tab)
+ this.ctx.router.broadcastEvent('tabs.onCreated', tabDetails)
+ }
+
+ onUpdated(tabId: number) {
+ const tab = this.ctx.store.getTabById(tabId)
+ if (!tab) return
+
+ let prevDetails
+ if (this.ctx.store.tabDetailsCache.has(tab.id)) {
+ prevDetails = this.ctx.store.tabDetailsCache.get(tab.id)
+ }
+ if (!prevDetails) return
+
+ const details = this.createTabDetails(tab)
+
+ const compareProps: (keyof chrome.tabs.Tab)[] = [
+ 'audible',
+ 'autoDiscardable',
+ 'discarded',
+ 'favIconUrl',
+ 'frozen',
+ 'groupId',
+ 'pinned',
+ 'status',
+ 'title',
+ 'url',
+ ]
+
+ let didUpdate = false
+ const changeInfo: chrome.tabs.TabChangeInfo = {}
+
+ for (const prop of compareProps) {
+ if (details[prop] !== prevDetails[prop]) {
+ ;(changeInfo as any)[prop] = details[prop]
+ didUpdate = true
+ }
+ }
+
+ if (details.mutedInfo?.muted !== prevDetails.mutedInfo?.muted) {
+ changeInfo.mutedInfo = details.mutedInfo
+ didUpdate = true
+ }
+
+ if (!didUpdate) return
+
+ this.ctx.router.broadcastEvent('tabs.onUpdated', tab.id, changeInfo, details)
+ }
+
+ onRemoved(tabId: number) {
+ const details = this.ctx.store.tabDetailsCache.has(tabId)
+ ? this.ctx.store.tabDetailsCache.get(tabId)
+ : null
+ this.ctx.store.tabDetailsCache.delete(tabId)
+
+ const windowId = details ? details.windowId : WindowsAPI.WINDOW_ID_NONE
+ const win =
+ typeof windowId !== 'undefined' && windowId > -1
+ ? getAllWindows().find((win) => win.id === windowId)
+ : null
+
+ this.ctx.router.broadcastEvent('tabs.onRemoved', tabId, {
+ windowId,
+ isWindowClosing: win ? win.isDestroyed() : false,
+ })
+ }
+
+ onActivated(tabId: number) {
+ const tab = this.ctx.store.getTabById(tabId)
+ if (!tab) return
+
+ const activeTab = this.ctx.store.getActiveTabFromWebContents(tab)
+ const activeChanged = activeTab?.id !== tabId
+ if (!activeChanged) return
+
+ const win = this.ctx.store.tabToWindow.get(tab)
+
+ this.ctx.store.setActiveTab(tab)
+
+ // invalidate cache since 'active' has changed
+ this.ctx.store.tabDetailsCache.forEach((tabInfo, cacheTabId) => {
+ tabInfo.active = tabId === cacheTabId
+ })
+
+ this.ctx.router.broadcastEvent('tabs.onActivated', {
+ tabId,
+ windowId: win?.id,
+ })
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/web-navigation.ts b/packages/electron-chrome-extensions/src/browser/api/web-navigation.ts
new file mode 100644
index 0000000..190044f
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/web-navigation.ts
@@ -0,0 +1,253 @@
+import * as electron from 'electron'
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+import debug from 'debug'
+
+const d = debug('electron-chrome-extensions:webNavigation')
+
+type DocumentLifecycle = 'prerender' | 'active' | 'cached' | 'pending_deletion'
+
+const getFrame = (frameProcessId: number, frameRoutingId: number) =>
+ electron.webFrameMain.fromId(frameProcessId, frameRoutingId)
+
+const getFrameId = (frame: Electron.WebFrameMain) =>
+ frame === frame.top ? 0 : frame.frameTreeNodeId
+
+const getParentFrameId = (frame: Electron.WebFrameMain) => {
+ const parentFrame = frame?.parent
+ return parentFrame ? getFrameId(parentFrame) : -1
+}
+
+// TODO(mv3): fenced_frame getter API needed
+const getFrameType = (frame: Electron.WebFrameMain) =>
+ !frame.parent ? 'outermost_frame' : 'sub_frame'
+
+// TODO(mv3): add WebFrameMain API to retrieve this
+const getDocumentLifecycle = (frame: Electron.WebFrameMain): DocumentLifecycle => 'active' as const
+
+const getFrameDetails = (
+ frame: Electron.WebFrameMain,
+): chrome.webNavigation.GetFrameResultDetails => ({
+ // TODO(mv3): implement new properties
+ url: frame.url,
+ documentId: 'not-implemented',
+ documentLifecycle: getDocumentLifecycle(frame),
+ errorOccurred: false,
+ frameType: getFrameType(frame),
+ // FIXME: frameId is missing from @types/chrome
+ ...{
+ frameId: getFrameId(frame),
+ },
+ parentDocumentId: undefined,
+ parentFrameId: getParentFrameId(frame),
+})
+
+export class WebNavigationAPI {
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('webNavigation.getFrame', this.getFrame.bind(this))
+ handle('webNavigation.getAllFrames', this.getAllFrames.bind(this))
+
+ this.ctx.store.on('tab-added', this.observeTab.bind(this))
+ }
+
+ private observeTab(tab: Electron.WebContents) {
+ tab.once('will-navigate', this.onCreatedNavigationTarget.bind(this, tab))
+ tab.on('did-start-navigation', this.onBeforeNavigate.bind(this, tab))
+ tab.on('did-frame-finish-load', this.onFinishLoad.bind(this, tab))
+ tab.on('did-frame-navigate', this.onCommitted.bind(this, tab))
+ tab.on('did-navigate-in-page', this.onHistoryStateUpdated.bind(this, tab))
+
+ tab.on('frame-created', (_e, { frame }) => {
+ if (!frame || frame.top === frame) return
+
+ frame.on('dom-ready', () => {
+ this.onDOMContentLoaded(tab, frame)
+ })
+ })
+
+ // Main frame dom-ready event
+ tab.on('dom-ready', () => {
+ if ('mainFrame' in tab) {
+ this.onDOMContentLoaded(tab, tab.mainFrame)
+ }
+ })
+ }
+
+ private getFrame(
+ event: ExtensionEvent,
+ details: chrome.webNavigation.GetFrameDetails,
+ ): chrome.webNavigation.GetFrameResultDetails | null {
+ const tab = this.ctx.store.getTabById(details.tabId)
+ if (!tab) return null
+
+ let targetFrame: Electron.WebFrameMain | undefined
+
+ if (typeof details.frameId === 'number') {
+ const mainFrame = tab.mainFrame
+ targetFrame = mainFrame.framesInSubtree.find((frame: any) => {
+ const isMainFrame = frame === frame.top
+ return isMainFrame ? details.frameId === 0 : details.frameId === frame.frameTreeNodeId
+ })
+ }
+
+ return targetFrame ? getFrameDetails(targetFrame) : null
+ }
+
+ private getAllFrames(
+ event: ExtensionEvent,
+ details: chrome.webNavigation.GetFrameDetails,
+ ): chrome.webNavigation.GetAllFrameResultDetails[] | null {
+ const tab = this.ctx.store.getTabById(details.tabId)
+ if (!tab || !('mainFrame' in tab)) return []
+ return (tab as any).mainFrame.framesInSubtree.map(getFrameDetails)
+ }
+
+ private sendNavigationEvent = (eventName: string, details: { url: string }) => {
+ d(`${eventName} [url: ${details.url}]`)
+ this.ctx.router.broadcastEvent(`webNavigation.${eventName}`, details)
+ }
+
+ private onCreatedNavigationTarget = (
+ tab: Electron.WebContents,
+ { url, frame }: Electron.Event,
+ ) => {
+ if (!frame) return
+
+ const details: chrome.webNavigation.WebNavigationSourceCallbackDetails = {
+ sourceTabId: tab.id,
+ sourceProcessId: frame ? frame.processId : -1,
+ sourceFrameId: getFrameId(frame),
+ url,
+ tabId: tab.id,
+ timeStamp: Date.now(),
+ }
+ this.sendNavigationEvent('onCreatedNavigationTarget', details)
+ }
+
+ private onBeforeNavigate = (
+ tab: Electron.WebContents,
+ {
+ url,
+ isSameDocument,
+ frame,
+ }: Electron.Event,
+ ) => {
+ if (isSameDocument) return
+ if (!frame) return
+
+ const details: chrome.webNavigation.WebNavigationParentedCallbackDetails = {
+ frameId: getFrameId(frame),
+ frameType: getFrameType(frame),
+ documentLifecycle: getDocumentLifecycle(frame),
+ parentFrameId: getParentFrameId(frame),
+ processId: frame ? frame.processId : -1,
+ tabId: tab.id,
+ timeStamp: Date.now(),
+ url,
+ }
+
+ this.sendNavigationEvent('onBeforeNavigate', details)
+ }
+
+ private onCommitted = (
+ tab: Electron.WebContents,
+ _event: Electron.Event,
+ url: string,
+ _httpResponseCode: number,
+ _httpStatusText: string,
+ _isMainFrame: boolean,
+ frameProcessId: number,
+ frameRoutingId: number,
+ ) => {
+ const frame = getFrame(frameProcessId, frameRoutingId)
+ if (!frame) return
+
+ const details: chrome.webNavigation.WebNavigationTransitionCallbackDetails = {
+ frameId: getFrameId(frame),
+ // NOTE: workaround for property missing in type
+ ...{
+ parentFrameId: getParentFrameId(frame),
+ },
+ frameType: getFrameType(frame),
+ transitionType: '', // TODO(mv3)
+ transitionQualifiers: [], // TODO(mv3)
+ documentLifecycle: getDocumentLifecycle(frame),
+ processId: frameProcessId,
+ tabId: tab.id,
+ timeStamp: Date.now(),
+ url,
+ }
+ this.sendNavigationEvent('onCommitted', details)
+ }
+
+ private onHistoryStateUpdated = (
+ tab: Electron.WebContents,
+ event: Electron.Event,
+ url: string,
+ isMainFrame: boolean,
+ frameProcessId: number,
+ frameRoutingId: number,
+ ) => {
+ const frame = getFrame(frameProcessId, frameRoutingId)
+ if (!frame) return
+
+ const details: chrome.webNavigation.WebNavigationTransitionCallbackDetails & {
+ parentFrameId: number
+ } = {
+ transitionType: '', // TODO
+ transitionQualifiers: [], // TODO
+ frameId: getFrameId(frame),
+ parentFrameId: getParentFrameId(frame),
+ frameType: getFrameType(frame),
+ documentLifecycle: getDocumentLifecycle(frame),
+ processId: frameProcessId,
+ tabId: tab.id,
+ timeStamp: Date.now(),
+ url,
+ }
+ this.sendNavigationEvent('onHistoryStateUpdated', details)
+ }
+
+ private onDOMContentLoaded = (tab: Electron.WebContents, frame: Electron.WebFrameMain) => {
+ const details: chrome.webNavigation.WebNavigationParentedCallbackDetails = {
+ frameId: getFrameId(frame),
+ parentFrameId: getParentFrameId(frame),
+ frameType: getFrameType(frame),
+ documentLifecycle: getDocumentLifecycle(frame),
+ processId: frame.processId,
+ tabId: tab.id,
+ timeStamp: Date.now(),
+ url: frame.url,
+ }
+ this.sendNavigationEvent('onDOMContentLoaded', details)
+
+ if (!tab.isLoadingMainFrame()) {
+ this.sendNavigationEvent('onCompleted', details)
+ }
+ }
+
+ private onFinishLoad = (
+ tab: Electron.WebContents,
+ event: Electron.Event,
+ isMainFrame: boolean,
+ frameProcessId: number,
+ frameRoutingId: number,
+ ) => {
+ const frame = getFrame(frameProcessId, frameRoutingId)
+ if (!frame) return
+
+ const url = tab.getURL()
+ const details: chrome.webNavigation.WebNavigationParentedCallbackDetails = {
+ frameId: getFrameId(frame),
+ parentFrameId: getParentFrameId(frame),
+ frameType: getFrameType(frame),
+ documentLifecycle: getDocumentLifecycle(frame),
+ processId: frameProcessId,
+ tabId: tab.id,
+ timeStamp: Date.now(),
+ url,
+ }
+ this.sendNavigationEvent('onCompleted', details)
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/api/windows.ts b/packages/electron-chrome-extensions/src/browser/api/windows.ts
new file mode 100644
index 0000000..76ed533
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/api/windows.ts
@@ -0,0 +1,178 @@
+import { ExtensionContext } from '../context'
+import { ExtensionEvent } from '../router'
+import debug from 'debug'
+
+const d = debug('electron-chrome-extensions:windows')
+
+const getWindowState = (win: Electron.BaseWindow): chrome.windows.Window['state'] => {
+ if (win.isMaximized()) return 'maximized'
+ if (win.isMinimized()) return 'minimized'
+ if (win.isFullScreen()) return 'fullscreen'
+ return 'normal'
+}
+
+export class WindowsAPI {
+ static WINDOW_ID_NONE = -1
+ static WINDOW_ID_CURRENT = -2
+
+ constructor(private ctx: ExtensionContext) {
+ const handle = this.ctx.router.apiHandler()
+ handle('windows.get', this.get.bind(this))
+ // TODO: how does getCurrent differ from getLastFocused?
+ handle('windows.getCurrent', this.getLastFocused.bind(this))
+ handle('windows.getLastFocused', this.getLastFocused.bind(this))
+ handle('windows.getAll', this.getAll.bind(this))
+ handle('windows.create', this.create.bind(this))
+ handle('windows.update', this.update.bind(this))
+ handle('windows.remove', this.remove.bind(this))
+
+ this.ctx.store.on('window-added', this.observeWindow.bind(this))
+ }
+
+ private observeWindow(window: Electron.BrowserWindow) {
+ const windowId = window.id
+
+ window.on('focus', () => {
+ this.onFocusChanged(windowId)
+ })
+
+ window.on('resized', () => {
+ this.onBoundsChanged(windowId)
+ })
+
+ window.once('closed', () => {
+ this.ctx.store.windowDetailsCache.delete(windowId)
+ this.ctx.store.removeWindow(window)
+ this.onRemoved(windowId)
+ })
+
+ this.onCreated(windowId)
+
+ d(`Observing window[${windowId}]`)
+ }
+
+ private createWindowDetails(win: Electron.BaseWindow) {
+ const details: Partial = {
+ id: win.id,
+ focused: win.isFocused(),
+ top: win.getPosition()[1],
+ left: win.getPosition()[0],
+ width: win.getSize()[0],
+ height: win.getSize()[1],
+ tabs: Array.from(this.ctx.store.tabs)
+ .filter((tab) => {
+ const ownerWindow = this.ctx.store.tabToWindow.get(tab)
+ return ownerWindow?.isDestroyed() ? false : ownerWindow?.id === win.id
+ })
+ .map((tab) => this.ctx.store.tabDetailsCache.get(tab.id) as chrome.tabs.Tab)
+ .filter(Boolean),
+ incognito: !this.ctx.session.isPersistent(),
+ type: 'normal', // TODO
+ state: getWindowState(win),
+ alwaysOnTop: win.isAlwaysOnTop(),
+ sessionId: 'default', // TODO
+ }
+
+ this.ctx.store.windowDetailsCache.set(win.id, details)
+ return details
+ }
+
+ private getWindowDetails(win: Electron.BaseWindow) {
+ if (this.ctx.store.windowDetailsCache.has(win.id)) {
+ return this.ctx.store.windowDetailsCache.get(win.id)
+ }
+ const details = this.createWindowDetails(win)
+ return details
+ }
+
+ private getWindowFromId(id: number) {
+ if (id === WindowsAPI.WINDOW_ID_CURRENT) {
+ return this.ctx.store.getCurrentWindow()
+ } else {
+ return this.ctx.store.getWindowById(id)
+ }
+ }
+
+ private get(event: ExtensionEvent, windowId: number) {
+ const win = this.getWindowFromId(windowId)
+ if (!win) return { id: WindowsAPI.WINDOW_ID_NONE }
+ return this.getWindowDetails(win)
+ }
+
+ private getLastFocused(event: ExtensionEvent) {
+ const win = this.ctx.store.getLastFocusedWindow()
+ return win ? this.getWindowDetails(win) : null
+ }
+
+ private getAll(event: ExtensionEvent) {
+ return Array.from(this.ctx.store.windows).map(this.getWindowDetails.bind(this))
+ }
+
+ private async create(event: ExtensionEvent, details: chrome.windows.CreateData) {
+ const win = await this.ctx.store.createWindow(event, details)
+ return this.getWindowDetails(win)
+ }
+
+ private async update(
+ event: ExtensionEvent,
+ windowId: number,
+ updateProperties: chrome.windows.UpdateInfo = {},
+ ) {
+ const win = this.getWindowFromId(windowId)
+ if (!win) return
+
+ const props = updateProperties
+
+ if (props.state) {
+ switch (props.state) {
+ case 'maximized':
+ win.maximize()
+ break
+ case 'minimized':
+ win.minimize()
+ break
+ case 'normal': {
+ if (win.isMinimized() || win.isMaximized()) {
+ win.restore()
+ }
+ break
+ }
+ }
+ }
+
+ return this.createWindowDetails(win)
+ }
+
+ private async remove(event: ExtensionEvent, windowId: number = WindowsAPI.WINDOW_ID_CURRENT) {
+ const win = this.getWindowFromId(windowId)
+ if (!win) return
+ const removedWindowId = win.id
+ await this.ctx.store.removeWindow(win)
+ this.onRemoved(removedWindowId)
+ }
+
+ onCreated(windowId: number) {
+ const window = this.ctx.store.getWindowById(windowId)
+ if (!window) return
+ const windowDetails = this.getWindowDetails(window)
+ this.ctx.router.broadcastEvent('windows.onCreated', windowDetails)
+ }
+
+ onRemoved(windowId: number) {
+ this.ctx.router.broadcastEvent('windows.onRemoved', windowId)
+ }
+
+ onFocusChanged(windowId: number) {
+ if (this.ctx.store.lastFocusedWindowId === windowId) return
+
+ this.ctx.store.lastFocusedWindowId = windowId
+ this.ctx.router.broadcastEvent('windows.onFocusChanged', windowId)
+ }
+
+ onBoundsChanged(windowId: number) {
+ const window = this.ctx.store.getWindowById(windowId)
+ if (!window) return
+ const windowDetails = this.createWindowDetails(window)
+ this.ctx.router.broadcastEvent('windows.onBoundsChanged', windowDetails)
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/context.ts b/packages/electron-chrome-extensions/src/browser/context.ts
new file mode 100644
index 0000000..2816cc8
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/context.ts
@@ -0,0 +1,11 @@
+import { EventEmitter } from 'node:events'
+import { ExtensionRouter } from './router'
+import { ExtensionStore } from './store'
+
+/** Shared context for extensions in a session. */
+export interface ExtensionContext {
+ emit: (typeof EventEmitter)['prototype']['emit']
+ router: ExtensionRouter
+ session: Electron.Session
+ store: ExtensionStore
+}
diff --git a/packages/electron-chrome-extensions/src/browser/deps.d.ts b/packages/electron-chrome-extensions/src/browser/deps.d.ts
new file mode 100644
index 0000000..1811932
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/deps.d.ts
@@ -0,0 +1 @@
+declare module 'debug'
diff --git a/packages/electron-chrome-extensions/src/browser/impl.ts b/packages/electron-chrome-extensions/src/browser/impl.ts
new file mode 100644
index 0000000..e6db60b
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/impl.ts
@@ -0,0 +1,22 @@
+/** App-specific implementation details for extensions. */
+export interface ChromeExtensionImpl {
+ createTab?(
+ details: chrome.tabs.CreateProperties,
+ ): Promise<[Electron.WebContents, Electron.BaseWindow]>
+ selectTab?(tab: Electron.WebContents, window: Electron.BaseWindow): void
+ removeTab?(tab: Electron.WebContents, window: Electron.BaseWindow): void
+
+ /**
+ * Populate additional details to a tab descriptor which gets passed back to
+ * background pages and content scripts.
+ */
+ assignTabDetails?(details: chrome.tabs.Tab, tab: Electron.WebContents): void
+
+ createWindow?(details: chrome.windows.CreateData): Promise
+ removeWindow?(window: Electron.BaseWindow): void
+
+ requestPermissions?(
+ extension: Electron.Extension,
+ permissions: chrome.permissions.Permissions,
+ ): Promise
+}
diff --git a/packages/electron-chrome-extensions/src/browser/index.ts b/packages/electron-chrome-extensions/src/browser/index.ts
new file mode 100644
index 0000000..d2e6a0a
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/index.ts
@@ -0,0 +1,308 @@
+import { session as electronSession } from 'electron'
+import { EventEmitter } from 'node:events'
+import path from 'node:path'
+import { existsSync } from 'node:fs'
+import { createRequire } from 'node:module'
+
+import { BrowserActionAPI } from './api/browser-action'
+import { TabsAPI } from './api/tabs'
+import { WindowsAPI } from './api/windows'
+import { WebNavigationAPI } from './api/web-navigation'
+import { ExtensionStore } from './store'
+import { ContextMenusAPI } from './api/context-menus'
+import { RuntimeAPI } from './api/runtime'
+import { CookiesAPI } from './api/cookies'
+import { NotificationsAPI } from './api/notifications'
+import { ChromeExtensionImpl } from './impl'
+import { CommandsAPI } from './api/commands'
+import { ExtensionContext } from './context'
+import { ExtensionRouter } from './router'
+import { checkLicense, License } from './license'
+import { readLoadedExtensionManifest } from './manifest'
+import { PermissionsAPI } from './api/permissions'
+import { resolvePartition } from './partition'
+
+function checkVersion() {
+ const electronVersion = process.versions.electron
+ if (electronVersion && parseInt(electronVersion.split('.')[0], 10) < 35) {
+ console.warn('electron-chrome-extensions requires electron@>=35.0.0')
+ }
+}
+
+function resolvePreloadPath(modulePath?: string) {
+ // Attempt to resolve preload path from module exports
+ try {
+ return createRequire(__dirname).resolve('electron-chrome-extensions/preload')
+ } catch (error) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(error)
+ }
+ }
+
+ const preloadFilename = 'chrome-extension-api.preload.js'
+
+ // Deprecated: use modulePath if provided
+ if (modulePath) {
+ process.emitWarning(
+ 'electron-chrome-extensions: "modulePath" is deprecated and will be removed in future versions.',
+ { type: 'DeprecationWarning' },
+ )
+ return path.join(modulePath, 'dist', preloadFilename)
+ }
+
+ // Fallback to preload relative to entrypoint directory
+ return path.join(__dirname, preloadFilename)
+}
+
+export interface ChromeExtensionOptions extends ChromeExtensionImpl {
+ /**
+ * License used to distribute electron-chrome-extensions.
+ *
+ * See LICENSE.md for more details.
+ */
+ license: License
+
+ /**
+ * Session to add Chrome extension support in.
+ * Defaults to `session.defaultSession`.
+ */
+ session?: Electron.Session
+
+ /**
+ * Path to electron-chrome-extensions module files. Might be needed if
+ * JavaScript bundlers like Webpack are used in your build process.
+ *
+ * @deprecated See "Packaging the preload script" in the readme.
+ */
+ modulePath?: string
+}
+
+const sessionMap = new WeakMap()
+
+/**
+ * Provides an implementation of various Chrome extension APIs to a session.
+ */
+export class ElectronChromeExtensions extends EventEmitter {
+ /** Retrieve an instance of this class associated with the given session. */
+ static fromSession(session: Electron.Session) {
+ return sessionMap.get(session)
+ }
+
+ /**
+ * Handles the 'crx://' protocol in the session.
+ *
+ * This is required to display extension icons.
+ */
+ static handleCRXProtocol(session: Electron.Session) {
+ if (session.protocol.isProtocolHandled('crx')) {
+ session.protocol.unhandle('crx')
+ }
+ session.protocol.handle('crx', function handleCRXRequest(request) {
+ let url
+ try {
+ url = new URL(request.url)
+ } catch {
+ return new Response('Invalid URL', { status: 404 })
+ }
+
+ const partition = url?.searchParams.get('partition') || '_self'
+ const remoteSession = partition === '_self' ? session : resolvePartition(partition)
+ const extensions = ElectronChromeExtensions.fromSession(remoteSession)
+ if (!extensions) {
+ return new Response(`ElectronChromeExtensions not found for "${partition}"`, {
+ status: 404,
+ })
+ }
+
+ return extensions.api.browserAction.handleCRXRequest(request)
+ })
+ }
+
+ private ctx: ExtensionContext
+
+ private api: {
+ browserAction: BrowserActionAPI
+ contextMenus: ContextMenusAPI
+ commands: CommandsAPI
+ cookies: CookiesAPI
+ notifications: NotificationsAPI
+ permissions: PermissionsAPI
+ runtime: RuntimeAPI
+ tabs: TabsAPI
+ webNavigation: WebNavigationAPI
+ windows: WindowsAPI
+ }
+
+ constructor(opts: ChromeExtensionOptions) {
+ super()
+
+ const { license, session = electronSession.defaultSession, ...impl } = opts || {}
+
+ checkVersion()
+ checkLicense(license)
+
+ if (sessionMap.has(session)) {
+ throw new Error(`Extensions instance already exists for the given session`)
+ }
+
+ sessionMap.set(session, this)
+
+ const router = new ExtensionRouter(session)
+ const store = new ExtensionStore(impl)
+
+ this.ctx = {
+ emit: this.emit.bind(this),
+ router,
+ session,
+ store,
+ }
+
+ this.api = {
+ browserAction: new BrowserActionAPI(this.ctx),
+ contextMenus: new ContextMenusAPI(this.ctx),
+ commands: new CommandsAPI(this.ctx),
+ cookies: new CookiesAPI(this.ctx),
+ notifications: new NotificationsAPI(this.ctx),
+ permissions: new PermissionsAPI(this.ctx),
+ runtime: new RuntimeAPI(this.ctx),
+ tabs: new TabsAPI(this.ctx),
+ webNavigation: new WebNavigationAPI(this.ctx),
+ windows: new WindowsAPI(this.ctx),
+ }
+
+ this.listenForExtensions()
+ this.prependPreload(opts.modulePath)
+ }
+
+ private listenForExtensions() {
+ const sessionExtensions = this.ctx.session.extensions || this.ctx.session
+ sessionExtensions.addListener('extension-loaded', (_event, extension) => {
+ readLoadedExtensionManifest(this.ctx, extension)
+ })
+ }
+
+ private async prependPreload(modulePath?: string) {
+ const { session } = this.ctx
+
+ const preloadPath = resolvePreloadPath(modulePath)
+
+ if ('registerPreloadScript' in session) {
+ session.registerPreloadScript({
+ id: 'crx-mv2-preload',
+ type: 'frame',
+ filePath: preloadPath,
+ })
+ session.registerPreloadScript({
+ id: 'crx-mv3-preload',
+ type: 'service-worker',
+ filePath: preloadPath,
+ })
+ } else {
+ // @ts-expect-error Deprecated electron@<35
+ session.setPreloads([...session.getPreloads(), preloadPath])
+ }
+
+ if (!existsSync(preloadPath)) {
+ console.error(
+ new Error(
+ `electron-chrome-extensions: Preload file not found at "${preloadPath}". ` +
+ 'See "Packaging the preload script" in the readme.',
+ ),
+ )
+ }
+ }
+
+ private checkWebContentsArgument(wc: Electron.WebContents) {
+ if (this.ctx.session !== wc.session) {
+ throw new TypeError(
+ 'Invalid WebContents argument. Its session must match the session provided to ElectronChromeExtensions constructor options.',
+ )
+ }
+ }
+
+ /** Add webContents to be tracked as a tab. */
+ addTab(tab: Electron.WebContents, window: Electron.BaseWindow) {
+ this.checkWebContentsArgument(tab)
+ this.ctx.store.addTab(tab, window)
+ }
+
+ /** Remove webContents from being tracked as a tab. */
+ removeTab(tab: Electron.WebContents) {
+ this.checkWebContentsArgument(tab)
+ this.ctx.store.removeTab(tab)
+ }
+
+ /** Notify extension system that the active tab has changed. */
+ selectTab(tab: Electron.WebContents) {
+ this.checkWebContentsArgument(tab)
+ if (this.ctx.store.tabs.has(tab)) {
+ this.api.tabs.onActivated(tab.id)
+ }
+ }
+
+ /**
+ * Add webContents to be tracked as an extension host which will receive
+ * extension events when a chrome-extension:// resource is loaded.
+ *
+ * This is usually reserved for extension background pages and popups, but
+ * can also be used in other special cases.
+ *
+ * @deprecated Extension hosts are now tracked lazily when they send
+ * extension IPCs to the main process.
+ */
+ addExtensionHost(host: Electron.WebContents) {
+ console.warn('ElectronChromeExtensions.addExtensionHost() is deprecated')
+ }
+
+ /**
+ * Get collection of menu items managed by the `chrome.contextMenus` API.
+ * @see https://developer.chrome.com/extensions/contextMenus
+ */
+ getContextMenuItems(webContents: Electron.WebContents, params: Electron.ContextMenuParams) {
+ this.checkWebContentsArgument(webContents)
+ return this.api.contextMenus.buildMenuItemsForParams(webContents, params)
+ }
+
+ /**
+ * Gets map of special pages to extension override URLs.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides
+ */
+ getURLOverrides(): Record {
+ return this.ctx.store.urlOverrides
+ }
+
+ /**
+ * Handles the 'crx://' protocol in the session.
+ *
+ * @deprecated Call `ElectronChromeExtensions.handleCRXProtocol(session)`
+ * instead. The CRX protocol is no longer one-to-one with
+ * ElectronChromeExtensions instances. Instead, it should now be handled only
+ * on the sessions where extension icons will be shown.
+ */
+ handleCRXProtocol(session: Electron.Session) {
+ throw new Error(
+ 'extensions.handleCRXProtocol(session) is deprecated, call ElectronChromeExtensions.handleCRXProtocol(session) instead.',
+ )
+ }
+
+ /**
+ * Add extensions to be visible as an extension action button.
+ *
+ * @deprecated Not needed in Electron >=12.
+ */
+ addExtension(extension: Electron.Extension) {
+ console.warn('ElectronChromeExtensions.addExtension() is deprecated')
+ this.api.browserAction.processExtension(extension)
+ }
+
+ /**
+ * Remove extensions from the list of visible extension action buttons.
+ *
+ * @deprecated Not needed in Electron >=12.
+ */
+ removeExtension(extension: Electron.Extension) {
+ console.warn('ElectronChromeExtensions.removeExtension() is deprecated')
+ this.api.browserAction.removeActions(extension.id)
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/license.ts b/packages/electron-chrome-extensions/src/browser/license.ts
new file mode 100644
index 0000000..9b25a73
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/license.ts
@@ -0,0 +1,69 @@
+import { app } from 'electron'
+import * as nodeCrypto from 'node:crypto'
+import * as fs from 'node:fs'
+import * as path from 'node:path'
+
+const INTERNAL_LICENSE = 'internal-license-do-not-use'
+const VALID_LICENSES_CONST = ['GPL-3.0', 'Patron-License-2020-11-19'] as const
+const VALID_LICENSES = new Set(VALID_LICENSES_CONST)
+export type License = (typeof VALID_LICENSES_CONST)[number]
+
+/**
+ * The following projects are not in compliance with the Patron license.
+ *
+ * This is included in the module as an offline check to block these projects
+ * from freely consuming updates.
+ */
+const NONCOMPLIANT_PROJECTS = new Set([
+ '9588cd7085bc3ae89f2c9cf8b7dee35a77a6747b4717be3d7b6b8f395c9ca1d8',
+ '8cf1d008c4c5d4e8a6f32de274359cf4ac02fcb82aeffae10ff0b99553c9d745',
+])
+
+const getLicenseNotice =
+ () => `Please select a distribution license compatible with your application.
+Valid licenses include: ${Array.from(VALID_LICENSES).join(', ')}
+See LICENSE.md for more details.`
+
+function readPackageJson() {
+ const appPath = app.getAppPath()
+ const packageJsonPath = path.join(appPath, 'package.json')
+ const rawData = fs.readFileSync(packageJsonPath, 'utf-8')
+ return JSON.parse(rawData)
+}
+
+function generateHash(input: string) {
+ const hash = nodeCrypto.createHash('sha256')
+ hash.update('crx' + input)
+ return hash.digest('hex')
+}
+
+/**
+ * Check to ensure a valid license is provided.
+ * @see LICENSE.md
+ */
+export function checkLicense(license?: unknown) {
+ // License must be set
+ if (!license || typeof license !== 'string') {
+ throw new Error(`ElectronChromeExtensions: Missing 'license' property.\n${getLicenseNotice()}`)
+ }
+
+ // License must be valid
+ if (!VALID_LICENSES.has(license as any) && (license as any) !== INTERNAL_LICENSE) {
+ throw new Error(
+ `ElectronChromeExtensions: Invalid 'license' property: ${license}\n${getLicenseNotice()}`,
+ )
+ }
+
+ // Project must be in compliance with license
+ let projectNameHash: string | undefined
+ try {
+ const packageJson = readPackageJson()
+ const projectName = packageJson.name.toLowerCase()
+ projectNameHash = generateHash(projectName)
+ } catch {}
+ if (projectNameHash && NONCOMPLIANT_PROJECTS.has(projectNameHash)) {
+ throw new Error(
+ `ElectronChromeExtensions: This application is using a non-compliant license. Contact sam@samuelmaddock.com if you wish to reinstate your license.`,
+ )
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/manifest.ts b/packages/electron-chrome-extensions/src/browser/manifest.ts
new file mode 100644
index 0000000..1add262
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/manifest.ts
@@ -0,0 +1,35 @@
+import { getExtensionUrl, validateExtensionResource } from './api/common'
+import { ExtensionContext } from './context'
+
+export async function readUrlOverrides(ctx: ExtensionContext, extension: Electron.Extension) {
+ const manifest = extension.manifest as chrome.runtime.Manifest
+ const urlOverrides = ctx.store.urlOverrides
+ let updated = false
+
+ if (typeof manifest.chrome_url_overrides === 'object') {
+ for (const [name, uri] of Object.entries(manifest.chrome_url_overrides!)) {
+ const validatedPath = await validateExtensionResource(extension, uri)
+ if (!validatedPath) {
+ console.error(
+ `Extension ${extension.id} attempted to override ${name} with invalid resource: ${uri}`,
+ )
+ continue
+ }
+
+ const url = getExtensionUrl(extension, uri)!
+ const currentUrl = urlOverrides[name]
+ if (currentUrl !== url) {
+ urlOverrides[name] = url
+ updated = true
+ }
+ }
+ }
+
+ if (updated) {
+ ctx.emit('url-overrides-updated', urlOverrides)
+ }
+}
+
+export function readLoadedExtensionManifest(ctx: ExtensionContext, extension: Electron.Extension) {
+ readUrlOverrides(ctx, extension)
+}
diff --git a/packages/electron-chrome-extensions/src/browser/partition.ts b/packages/electron-chrome-extensions/src/browser/partition.ts
new file mode 100644
index 0000000..92dd00a
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/partition.ts
@@ -0,0 +1,19 @@
+import { session } from 'electron'
+
+type SessionPartitionResolver = (partition: string) => Electron.Session
+
+let resolvePartitionImpl: SessionPartitionResolver = (partition) => session.fromPartition(partition)
+
+/**
+ * Overrides the default `session.fromPartition()` behavior for retrieving Electron Sessions.
+ * This allows using custom identifiers (e.g., profile IDs) to find sessions, enabling features like
+ * `` to work with non-standard session management schemes.
+ * @param handler A function that receives a string identifier and returns the corresponding Electron `Session`.
+ */
+export function setSessionPartitionResolver(resolver: SessionPartitionResolver) {
+ resolvePartitionImpl = resolver
+}
+
+export function resolvePartition(partition: string) {
+ return resolvePartitionImpl(partition)
+}
diff --git a/packages/electron-chrome-extensions/src/browser/popup.ts b/packages/electron-chrome-extensions/src/browser/popup.ts
new file mode 100644
index 0000000..e32de6c
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/popup.ts
@@ -0,0 +1,280 @@
+import { EventEmitter } from 'node:events'
+import { BrowserWindow, Session } from 'electron'
+import { getAllWindows } from './api/common'
+import debug from 'debug'
+
+const d = debug('electron-chrome-extensions:popup')
+
+export interface PopupAnchorRect {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+interface PopupViewOptions {
+ extensionId: string
+ session: Session
+ parent: Electron.BaseWindow
+ url: string
+ anchorRect: PopupAnchorRect
+ alignment?: string
+}
+
+const supportsPreferredSize = () => {
+ const major = parseInt(process.versions.electron.split('.').shift() || '', 10)
+ return major >= 12
+}
+
+export class PopupView extends EventEmitter {
+ static POSITION_PADDING = 5
+
+ static BOUNDS = {
+ minWidth: 25,
+ minHeight: 25,
+ maxWidth: 800,
+ maxHeight: 600,
+ }
+
+ browserWindow?: BrowserWindow
+ parent?: Electron.BaseWindow
+ extensionId: string
+
+ private anchorRect: PopupAnchorRect
+ private destroyed: boolean = false
+ private hidden: boolean = true
+ private alignment?: string
+
+ /** Preferred size changes are only received in Electron v12+ */
+ private usingPreferredSize = supportsPreferredSize()
+
+ private readyPromise: Promise
+
+ constructor(opts: PopupViewOptions) {
+ super()
+
+ this.parent = opts.parent
+ this.extensionId = opts.extensionId
+ this.anchorRect = opts.anchorRect
+ this.alignment = opts.alignment
+
+ this.browserWindow = new BrowserWindow({
+ show: false,
+ frame: false,
+ parent: opts.parent,
+ movable: false,
+ maximizable: false,
+ minimizable: false,
+ // https://github.com/electron/electron/issues/47579
+ fullscreenable: false,
+ resizable: false,
+ skipTaskbar: true,
+ backgroundColor: '#ffffff',
+ roundedCorners: false,
+ webPreferences: {
+ session: opts.session,
+ sandbox: true,
+ nodeIntegration: false,
+ nodeIntegrationInWorker: false,
+ contextIsolation: true,
+ enablePreferredSizeMode: true,
+ },
+ })
+
+ const untypedWebContents = this.browserWindow.webContents as any
+ untypedWebContents.on('preferred-size-changed', this.updatePreferredSize)
+
+ this.browserWindow.webContents.on('devtools-closed', this.maybeClose)
+ this.browserWindow.on('blur', this.maybeClose)
+ this.browserWindow.on('closed', this.destroy)
+ this.parent.once('closed', this.destroy)
+
+ this.readyPromise = this.load(opts.url)
+ }
+
+ private show() {
+ this.hidden = false
+ this.browserWindow?.show()
+ }
+
+ private async load(url: string): Promise {
+ const win = this.browserWindow!
+
+ try {
+ await win.webContents.loadURL(url)
+ } catch (e) {
+ console.error(e)
+ }
+
+ if (this.destroyed) return
+
+ if (this.usingPreferredSize) {
+ // Set small initial size so the preferred size grows to what's needed
+ this.setSize({ width: PopupView.BOUNDS.minWidth, height: PopupView.BOUNDS.minHeight })
+ } else {
+ // Set large initial size to avoid overflow
+ this.setSize({ width: PopupView.BOUNDS.maxWidth, height: PopupView.BOUNDS.maxHeight })
+
+ // Wait for content and layout to load
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ if (this.destroyed) return
+
+ await this.queryPreferredSize()
+ if (this.destroyed) return
+
+ this.show()
+ }
+ }
+
+ destroy = () => {
+ if (this.destroyed) return
+
+ this.destroyed = true
+
+ d(`destroying ${this.extensionId}`)
+
+ if (this.parent) {
+ if (!this.parent.isDestroyed()) {
+ this.parent.off('closed', this.destroy)
+ }
+ this.parent = undefined
+ }
+
+ if (this.browserWindow) {
+ if (!this.browserWindow.isDestroyed()) {
+ const { webContents } = this.browserWindow
+
+ if (!webContents.isDestroyed() && webContents.isDevToolsOpened()) {
+ webContents.closeDevTools()
+ }
+
+ this.browserWindow.off('closed', this.destroy)
+ this.browserWindow.destroy()
+ }
+
+ this.browserWindow = undefined
+ }
+ }
+
+ isDestroyed() {
+ return this.destroyed
+ }
+
+ /** Resolves when the popup finishes loading. */
+ whenReady() {
+ return this.readyPromise
+ }
+
+ setSize(rect: Partial) {
+ if (!this.browserWindow || !this.parent) return
+
+ const width = Math.floor(
+ Math.min(PopupView.BOUNDS.maxWidth, Math.max(rect.width || 0, PopupView.BOUNDS.minWidth)),
+ )
+
+ const height = Math.floor(
+ Math.min(PopupView.BOUNDS.maxHeight, Math.max(rect.height || 0, PopupView.BOUNDS.minHeight)),
+ )
+
+ const size = { width, height }
+ d(`setSize`, size)
+
+ this.emit('will-resize', size)
+
+ this.browserWindow?.setBounds({
+ ...this.browserWindow.getBounds(),
+ ...size,
+ })
+
+ this.emit('resized')
+ }
+
+ private maybeClose = () => {
+ // Keep open if webContents is being inspected
+ if (!this.browserWindow?.isDestroyed() && this.browserWindow?.webContents.isDevToolsOpened()) {
+ d('preventing close due to DevTools being open')
+ return
+ }
+
+ // For extension popups with a login form, the user may need to access a
+ // program outside of the app. Closing the popup would then add
+ // inconvenience.
+ if (!getAllWindows().some((win) => win.isFocused())) {
+ d('preventing close due to focus residing outside of the app')
+ return
+ }
+
+ this.destroy()
+ }
+
+ private updatePosition() {
+ if (!this.browserWindow || !this.parent) return
+
+ const winBounds = this.parent.getBounds()
+ const winContentBounds = this.parent.getContentBounds()
+ const nativeTitlebarHeight = winBounds.height - winContentBounds.height
+
+ const viewBounds = this.browserWindow.getBounds()
+
+ let x = winBounds.x + this.anchorRect.x + this.anchorRect.width - viewBounds.width
+ let y =
+ winBounds.y +
+ nativeTitlebarHeight +
+ this.anchorRect.y +
+ this.anchorRect.height +
+ PopupView.POSITION_PADDING
+
+ // If aligned to a differently then we need to offset the popup position
+ if (this.alignment?.includes('right')) x = winBounds.x + this.anchorRect.x
+ if (this.alignment?.includes('top'))
+ y =
+ winBounds.y +
+ nativeTitlebarHeight -
+ viewBounds.height +
+ this.anchorRect.y -
+ PopupView.POSITION_PADDING
+
+ // Convert to ints
+ x = Math.floor(x)
+ y = Math.floor(y)
+
+ const position = { x, y }
+ d(`updatePosition`, position)
+
+ this.emit('will-move', position)
+
+ this.browserWindow.setBounds({
+ ...this.browserWindow.getBounds(),
+ ...position,
+ })
+
+ this.emit('moved')
+ }
+
+ /** Backwards compat for Electron <12 */
+ private async queryPreferredSize() {
+ if (this.usingPreferredSize || this.destroyed) return
+
+ const rect = await this.browserWindow!.webContents.executeJavaScript(
+ `((${() => {
+ const rect = document.body.getBoundingClientRect()
+ return { width: rect.width, height: rect.height }
+ }})())`,
+ )
+
+ if (this.destroyed) return
+
+ this.setSize({ width: rect.width, height: rect.height })
+ this.updatePosition()
+ }
+
+ private updatePreferredSize = (event: Electron.Event, size: Electron.Size) => {
+ d('updatePreferredSize', size)
+ this.usingPreferredSize = true
+ this.setSize(size)
+ this.updatePosition()
+
+ // Wait to reveal popup until it's sized and positioned correctly
+ if (this.hidden) this.show()
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/router.ts b/packages/electron-chrome-extensions/src/browser/router.ts
new file mode 100644
index 0000000..49e9292
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/router.ts
@@ -0,0 +1,463 @@
+import { app, ipcMain, Session } from 'electron'
+import debug from 'debug'
+
+import { resolvePartition } from './partition'
+
+// Shorten base64 encoded icons
+const shortenValues = (k: string, v: any) =>
+ typeof v === 'string' && v.length > 128 ? v.substr(0, 128) + '...' : v
+
+debug.formatters.r = (value: any) => {
+ return value ? JSON.stringify(value, shortenValues, ' ') : value
+}
+
+export type IpcEvent = Electron.IpcMainEvent | Electron.IpcMainServiceWorkerEvent
+export type IpcInvokeEvent = Electron.IpcMainInvokeEvent | Electron.IpcMainServiceWorkerInvokeEvent
+export type IpcAnyEvent = IpcEvent | IpcInvokeEvent
+
+const getSessionFromEvent = (event: IpcAnyEvent): Electron.Session => {
+ if (event.type === 'service-worker') {
+ return event.session
+ } else {
+ return event.sender.session
+ }
+}
+
+const getHostFromEvent = (event: IpcAnyEvent) => {
+ if (event.type === 'service-worker') {
+ return event.serviceWorker
+ } else {
+ return event.sender
+ }
+}
+
+const d = debug('electron-chrome-extensions:router')
+
+const DEFAULT_SESSION = '_self'
+
+interface RoutingDelegateObserver {
+ session: Electron.Session
+ onExtensionMessage(
+ event: Electron.IpcMainInvokeEvent,
+ extensionId: string | undefined,
+ handlerName: string,
+ ...args: any[]
+ ): Promise
+ addListener(listener: EventListener, extensionId: string, eventName: string): void
+ removeListener(listener: EventListener, extensionId: string, eventName: string): void
+}
+
+let gRoutingDelegate: RoutingDelegate
+
+/**
+ * Handles event routing IPCs and delivers them to the observer with the
+ * associated session.
+ */
+class RoutingDelegate {
+ static get() {
+ return gRoutingDelegate || (gRoutingDelegate = new RoutingDelegate())
+ }
+
+ private sessionMap: WeakMap = new WeakMap()
+ private workers: WeakSet = new WeakSet()
+
+ private constructor() {
+ ipcMain.handle('crx-msg', this.onRouterMessage)
+ ipcMain.handle('crx-msg-remote', this.onRemoteMessage)
+ ipcMain.on('crx-add-listener', this.onAddListener)
+ ipcMain.on('crx-remove-listener', this.onRemoveListener)
+ }
+
+ addObserver(observer: RoutingDelegateObserver) {
+ this.sessionMap.set(observer.session, observer)
+
+ const maybeListenForWorkerEvents = ({
+ runningStatus,
+ versionId,
+ }: Electron.Event) => {
+ if (runningStatus !== 'starting') return
+
+ const serviceWorker = (observer.session as any).serviceWorkers.getWorkerFromVersionID(
+ versionId,
+ )
+ if (
+ serviceWorker?.scope?.startsWith('chrome-extension://') &&
+ !this.workers.has(serviceWorker)
+ ) {
+ d(`listening to service worker [versionId:${versionId}, scope:${serviceWorker.scope}]`)
+ this.workers.add(serviceWorker)
+ serviceWorker.ipc.handle('crx-msg', this.onRouterMessage)
+ serviceWorker.ipc.handle('crx-msg-remote', this.onRemoteMessage)
+ serviceWorker.ipc.on('crx-add-listener', this.onAddListener)
+ serviceWorker.ipc.on('crx-remove-listener', this.onRemoveListener)
+ }
+ }
+ observer.session.serviceWorkers.on('running-status-changed', maybeListenForWorkerEvents)
+ }
+
+ private onRouterMessage = async (
+ event: Electron.IpcMainInvokeEvent,
+ extensionId: string,
+ handlerName: string,
+ ...args: any[]
+ ) => {
+ d(`received '${handlerName}'`, args)
+
+ const observer = this.sessionMap.get(getSessionFromEvent(event))
+
+ return observer?.onExtensionMessage(event, extensionId, handlerName, ...args)
+ }
+
+ private onRemoteMessage = async (
+ event: Electron.IpcMainInvokeEvent,
+ sessionPartition: string,
+ handlerName: string,
+ ...args: any[]
+ ) => {
+ d(`received remote '${handlerName}' for '${sessionPartition}'`, args)
+
+ const ses =
+ sessionPartition === DEFAULT_SESSION
+ ? getSessionFromEvent(event)
+ : resolvePartition(sessionPartition)
+
+ const observer = this.sessionMap.get(ses)
+
+ return observer?.onExtensionMessage(event, undefined, handlerName, ...args)
+ }
+
+ private onAddListener = (event: IpcAnyEvent, extensionId: string, eventName: string) => {
+ const observer = this.sessionMap.get(getSessionFromEvent(event))
+ const listener: EventListener =
+ event.type === 'frame'
+ ? {
+ type: event.type,
+ extensionId,
+ host: event.sender,
+ }
+ : {
+ type: event.type,
+ extensionId,
+ }
+ return observer?.addListener(listener, extensionId, eventName)
+ }
+
+ private onRemoveListener = (
+ event: Electron.IpcMainInvokeEvent,
+ extensionId: string,
+ eventName: string,
+ ) => {
+ const observer = this.sessionMap.get(getSessionFromEvent(event))
+ const listener: EventListener =
+ event.type === 'frame'
+ ? {
+ type: event.type,
+ extensionId,
+ host: event.sender,
+ }
+ : {
+ type: event.type,
+ extensionId,
+ }
+ return observer?.removeListener(listener, extensionId, eventName)
+ }
+}
+
+export type ExtensionSender = Electron.WebContents | Electron.ServiceWorkerMain
+// export interface ExtensionSender {
+// id?: number
+// ipc: Electron.IpcMain | Electron.IpcMainServiceWorker
+// send: Electron.WebFrameMain['send']
+// }
+
+type ExtendedExtension = Omit & {
+ manifest: chrome.runtime.Manifest
+}
+
+export type ExtensionEvent =
+ | { type: 'frame'; sender: Electron.WebContents; extension: ExtendedExtension }
+ | { type: 'service-worker'; sender: Electron.ServiceWorkerMain; extension: ExtendedExtension }
+
+export type HandlerCallback = (event: ExtensionEvent, ...args: any[]) => any
+
+export interface HandlerOptions {
+ /** Whether the handler can be invoked on behalf of a different session. */
+ allowRemote?: boolean
+ /** Whether an extension context is required to invoke the handler. */
+ extensionContext: boolean
+ /** Required extension permission to run the handler. */
+ permission?: chrome.runtime.ManifestPermissions
+}
+
+interface Handler extends HandlerOptions {
+ callback: HandlerCallback
+}
+
+/** e.g. 'tabs.query' */
+type EventName = string
+
+type HandlerMap = Map
+
+type FrameEventListener = { type: 'frame'; host: Electron.WebContents; extensionId: string }
+type SWEventListener = { type: 'service-worker'; extensionId: string }
+type EventListener = FrameEventListener | SWEventListener
+
+const getHostId = (host: FrameEventListener['host']) => host.id
+const getHostUrl = (host: FrameEventListener['host']) => host.getURL?.()
+
+const eventListenerEquals = (a: EventListener) => (b: EventListener) => {
+ if (a === b) return true
+ if (a.extensionId !== b.extensionId) return false
+ if (a.type !== b.type) return false
+ if (a.type === 'frame' && b.type === 'frame') {
+ return a.host === b.host
+ }
+ return true
+}
+
+export class ExtensionRouter {
+ private handlers: HandlerMap = new Map()
+ private listeners: Map = new Map()
+
+ /**
+ * Collection of all extension hosts in the session.
+ *
+ * Currently the router has no ability to wake up non-persistent background
+ * scripts to deliver events. For now we just hold a reference to them to
+ * prevent them from being terminated.
+ */
+ private extensionHosts: Set = new Set()
+
+ private extensionWorkers: Set = new Set()
+
+ constructor(
+ public session: Electron.Session,
+ private delegate: RoutingDelegate = RoutingDelegate.get(),
+ ) {
+ this.delegate.addObserver(this)
+
+ const sessionExtensions = session.extensions || session
+ sessionExtensions.on('extension-unloaded', (event, extension) => {
+ this.filterListeners((listener) => listener.extensionId !== extension.id)
+ })
+
+ app.on('web-contents-created', (event, webContents) => {
+ if (webContents.session === this.session && webContents.getType() === 'backgroundPage') {
+ d(`storing reference to background host [url:'${webContents.getURL()}']`)
+ this.extensionHosts.add(webContents)
+ }
+ })
+
+ session.serviceWorkers.on(
+ 'running-status-changed' as any,
+ ({ runningStatus, versionId }: any) => {
+ if (runningStatus !== 'starting') return
+
+ const serviceWorker = (session as any).serviceWorkers.getWorkerFromVersionID(versionId)
+ if (!serviceWorker) return
+
+ const { scope } = serviceWorker
+ if (!scope.startsWith('chrome-extension:')) return
+
+ if (this.extensionHosts.has(serviceWorker)) {
+ d('%s running status changed to %s', scope, runningStatus)
+ } else {
+ d(`storing reference to background service worker [url:'${scope}']`)
+ this.extensionWorkers.add(serviceWorker)
+ }
+ },
+ )
+ }
+
+ private filterListeners(predicate: (listener: EventListener) => boolean) {
+ for (const [eventName, listeners] of this.listeners) {
+ const filteredListeners = listeners.filter(predicate)
+ const delta = listeners.length - filteredListeners.length
+
+ if (filteredListeners.length > 0) {
+ this.listeners.set(eventName, filteredListeners)
+ } else {
+ this.listeners.delete(eventName)
+ }
+
+ if (delta > 0) {
+ d(`removed ${delta} listener(s) for '${eventName}'`)
+ }
+ }
+ }
+
+ private observeListenerHost(host: FrameEventListener['host']) {
+ const hostId = getHostId(host)
+ d(`observing listener [id:${hostId}, url:'${getHostUrl(host)}']`)
+ host.once('destroyed', () => {
+ d(`extension host destroyed [id:${hostId}]`)
+ this.filterListeners((listener) => listener.type !== 'frame' || listener.host !== host)
+ })
+ }
+
+ addListener(listener: EventListener, extensionId: string, eventName: string) {
+ const { listeners, session } = this
+
+ const sessionExtensions = session.extensions || session
+ const extension = sessionExtensions.getExtension(extensionId)
+ if (!extension) {
+ throw new Error(`extension not registered in session [extensionId:${extensionId}]`)
+ }
+
+ if (!listeners.has(eventName)) {
+ listeners.set(eventName, [])
+ }
+
+ const eventListeners = listeners.get(eventName)!
+ const existingEventListener = eventListeners.find(eventListenerEquals(listener))
+
+ if (existingEventListener) {
+ d(`ignoring existing '${eventName}' event listener for ${extensionId}`)
+ } else {
+ d(`adding '${eventName}' event listener for ${extensionId}`)
+ eventListeners.push(listener)
+ if (listener.type === 'frame' && listener.host) {
+ this.observeListenerHost(listener.host)
+ }
+ }
+ }
+
+ removeListener(listener: EventListener, extensionId: string, eventName: string) {
+ const { listeners } = this
+
+ const eventListeners = listeners.get(eventName)
+ if (!eventListeners) {
+ console.error(`event listener not registered for '${eventName}'`)
+ return
+ }
+
+ const index = eventListeners.findIndex(eventListenerEquals(listener))
+
+ if (index >= 0) {
+ d(`removing '${eventName}' event listener for ${extensionId}`)
+ eventListeners.splice(index, 1)
+ }
+
+ if (eventListeners.length === 0) {
+ listeners.delete(eventName)
+ }
+ }
+
+ private getHandler(handlerName: string) {
+ const handler = this.handlers.get(handlerName)
+ if (!handler) {
+ throw new Error(`${handlerName} is not a registered handler`)
+ }
+
+ return handler
+ }
+
+ async onExtensionMessage(
+ event: IpcInvokeEvent,
+ extensionId: string | undefined,
+ handlerName: string,
+ ...args: any[]
+ ) {
+ const { session } = this
+ const eventSession = getSessionFromEvent(event)
+ const eventSessionExtensions = eventSession.extensions || eventSession
+ const handler = this.getHandler(handlerName)
+
+ if (eventSession !== session && !handler.allowRemote) {
+ throw new Error(`${handlerName} does not support calling from a remote session`)
+ }
+
+ const extension = extensionId ? eventSessionExtensions.getExtension(extensionId) : undefined
+ if (!extension && handler.extensionContext) {
+ throw new Error(`${handlerName} was sent from an unknown extension context`)
+ }
+
+ if (handler.permission) {
+ const manifest: chrome.runtime.Manifest = extension?.manifest
+ if (!extension || !manifest.permissions?.includes(handler.permission)) {
+ throw new Error(
+ `${handlerName} requires an extension with ${handler.permission} permissions`,
+ )
+ }
+ }
+
+ const extEvent: ExtensionEvent =
+ event.type === 'frame'
+ ? { type: event.type, sender: event.sender, extension: extension! }
+ : { type: event.type, sender: event.serviceWorker, extension: extension! }
+
+ const result = await handler.callback(extEvent, ...args)
+
+ d(`${handlerName} result: %r`, result)
+
+ return result
+ }
+
+ private handle(name: string, callback: HandlerCallback, opts?: Partial): void {
+ this.handlers.set(name, {
+ callback,
+ extensionContext: typeof opts?.extensionContext === 'boolean' ? opts.extensionContext : true,
+ allowRemote: typeof opts?.allowRemote === 'boolean' ? opts.allowRemote : false,
+ permission: typeof opts?.permission === 'string' ? opts.permission : undefined,
+ })
+ }
+
+ /** Returns a callback to register API handlers for the given context. */
+ apiHandler() {
+ return (name: string, callback: HandlerCallback, opts?: Partial) => {
+ this.handle(name, callback, opts)
+ }
+ }
+
+ /**
+ * Sends extension event to the host for the given extension ID if it
+ * registered a listener for it.
+ */
+ sendEvent(targetExtensionId: string | undefined, eventName: string, ...args: any[]) {
+ const { listeners } = this
+ let eventListeners = listeners.get(eventName)
+ const ipcName = `crx-${eventName}`
+
+ if (!eventListeners || eventListeners.length === 0) {
+ // Ignore events with no listeners
+ return
+ }
+
+ let sentCount = 0
+ for (const listener of eventListeners) {
+ const { type, extensionId } = listener
+
+ if (targetExtensionId && targetExtensionId !== extensionId) {
+ continue
+ }
+
+ if (type === 'service-worker') {
+ const scope = `chrome-extension://${extensionId}/`
+ this.session.serviceWorkers
+ .startWorkerForScope(scope)
+ .then((serviceWorker) => {
+ serviceWorker.send(ipcName, ...args)
+ })
+ .catch((error) => {
+ d('failed to send %s to %s', eventName, extensionId)
+ console.error(error)
+ })
+ } else {
+ if (listener.host.isDestroyed()) {
+ console.error(`Unable to send '${eventName}' to extension host for ${extensionId}`)
+ return
+ }
+ listener.host.send(ipcName, ...args)
+ }
+
+ sentCount++
+ }
+
+ d(`sent '${eventName}' event to ${sentCount} listeners`)
+ }
+
+ /** Broadcasts extension event to all extension hosts listening for it. */
+ broadcastEvent(eventName: string, ...args: any[]) {
+ this.sendEvent(undefined, eventName, ...args)
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/browser/store.ts b/packages/electron-chrome-extensions/src/browser/store.ts
new file mode 100644
index 0000000..6ef9d31
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/browser/store.ts
@@ -0,0 +1,209 @@
+import { BrowserWindow, webContents } from 'electron'
+import { EventEmitter } from 'node:events'
+import { ContextMenuType } from './api/common'
+import { ChromeExtensionImpl } from './impl'
+import { ExtensionEvent } from './router'
+
+export class ExtensionStore extends EventEmitter {
+ /** Tabs observed by the extensions system. */
+ tabs = new Set()
+
+ /** Windows observed by the extensions system. */
+ windows = new Set()
+
+ lastFocusedWindowId?: number
+
+ /**
+ * Map of tabs to their parent window.
+ *
+ * It's not possible to access the parent of a BrowserView so we must manage
+ * this ourselves.
+ */
+ tabToWindow = new WeakMap()
+
+ /** Map of windows to their active tab. */
+ private windowToActiveTab = new WeakMap()
+
+ tabDetailsCache = new Map>()
+ windowDetailsCache = new Map>()
+
+ urlOverrides: Record = {}
+
+ constructor(public impl: ChromeExtensionImpl) {
+ super()
+ }
+
+ getWindowById(windowId: number) {
+ return Array.from(this.windows).find(
+ (window) => !window.isDestroyed() && window.id === windowId,
+ )
+ }
+
+ getLastFocusedWindow() {
+ return this.lastFocusedWindowId ? this.getWindowById(this.lastFocusedWindowId) : null
+ }
+
+ getCurrentWindow() {
+ return this.getLastFocusedWindow()
+ }
+
+ addWindow(window: Electron.BaseWindow) {
+ if (this.windows.has(window)) return
+
+ this.windows.add(window)
+
+ if (typeof this.lastFocusedWindowId !== 'number') {
+ this.lastFocusedWindowId = window.id
+ }
+
+ this.emit('window-added', window)
+ }
+
+ async createWindow(event: ExtensionEvent, details: chrome.windows.CreateData) {
+ if (typeof this.impl.createWindow !== 'function') {
+ throw new Error('createWindow is not implemented')
+ }
+
+ const win = await this.impl.createWindow(details)
+
+ this.addWindow(win)
+
+ return win
+ }
+
+ async removeWindow(window: Electron.BaseWindow) {
+ if (!this.windows.has(window)) return
+
+ this.windows.delete(window)
+
+ if (typeof this.impl.removeWindow === 'function') {
+ await this.impl.removeWindow(window)
+ } else {
+ window.destroy()
+ }
+ }
+
+ getTabById(tabId: number) {
+ return Array.from(this.tabs).find((tab) => !tab.isDestroyed() && tab.id === tabId)
+ }
+
+ addTab(tab: Electron.WebContents, window: Electron.BaseWindow) {
+ if (this.tabs.has(tab)) return
+
+ this.tabs.add(tab)
+ this.tabToWindow.set(tab, window)
+ this.addWindow(window)
+
+ const activeTab = this.getActiveTabFromWebContents(tab)
+ if (!activeTab) {
+ this.setActiveTab(tab)
+ }
+
+ this.emit('tab-added', tab)
+ }
+
+ removeTab(tab: Electron.WebContents) {
+ if (!this.tabs.has(tab)) return
+
+ const tabId = tab.id
+ const win = this.tabToWindow.get(tab)!
+
+ this.tabs.delete(tab)
+ this.tabToWindow.delete(tab)
+
+ // TODO: clear active tab
+
+ // Clear window if it has no remaining tabs
+ const windowHasTabs = Array.from(this.tabs).find((tab) => this.tabToWindow.get(tab) === win)
+ if (!windowHasTabs) {
+ this.windows.delete(win)
+ }
+
+ if (typeof this.impl.removeTab === 'function') {
+ this.impl.removeTab(tab, win)
+ }
+
+ this.emit('tab-removed', tabId)
+ }
+
+ async createTab(details: chrome.tabs.CreateProperties) {
+ if (typeof this.impl.createTab !== 'function') {
+ throw new Error('createTab is not implemented')
+ }
+
+ // Fallback to current window
+ if (!details.windowId) {
+ details.windowId = this.lastFocusedWindowId
+ }
+
+ const result = await this.impl.createTab(details)
+
+ if (!Array.isArray(result)) {
+ throw new Error('createTab must return an array of [tab, window]')
+ }
+
+ const [tab, window] = result
+
+ if (typeof tab !== 'object' || !webContents.fromId(tab.id)) {
+ throw new Error('createTab must return a WebContents')
+ } else if (typeof window !== 'object') {
+ throw new Error('createTab must return a BrowserWindow')
+ }
+
+ this.addTab(tab, window)
+
+ return tab
+ }
+
+ getActiveTabFromWindow(win: Electron.BaseWindow) {
+ const activeTab = win && !win.isDestroyed() && this.windowToActiveTab.get(win)
+ return (activeTab && !activeTab.isDestroyed() && activeTab) || undefined
+ }
+
+ getActiveTabFromWebContents(wc: Electron.WebContents): Electron.WebContents | undefined {
+ const win = this.tabToWindow.get(wc) || BrowserWindow.fromWebContents(wc)
+ const activeTab = win ? this.getActiveTabFromWindow(win) : undefined
+ return activeTab
+ }
+
+ getActiveTabOfCurrentWindow() {
+ const win = this.getCurrentWindow()
+ return win ? this.getActiveTabFromWindow(win) : undefined
+ }
+
+ setActiveTab(tab: Electron.WebContents) {
+ const win = this.tabToWindow.get(tab)
+ if (!win) {
+ throw new Error('Active tab has no parent window')
+ }
+
+ const prevActiveTab = this.getActiveTabFromWebContents(tab)
+
+ this.windowToActiveTab.set(win, tab)
+
+ if (tab.id !== prevActiveTab?.id) {
+ this.emit('active-tab-changed', tab, win)
+
+ if (typeof this.impl.selectTab === 'function') {
+ this.impl.selectTab(tab, win)
+ }
+ }
+ }
+
+ buildMenuItems(extensionId: string, menuType: ContextMenuType): Electron.MenuItem[] {
+ // This function is overwritten by ContextMenusAPI
+ return []
+ }
+
+ async requestPermissions(
+ extension: Electron.Extension,
+ permissions: chrome.permissions.Permissions,
+ ) {
+ if (typeof this.impl.requestPermissions !== 'function') {
+ // Default to allowed.
+ return true
+ }
+ const result: unknown = await this.impl.requestPermissions(extension, permissions)
+ return typeof result === 'boolean' ? result : false
+ }
+}
diff --git a/packages/electron-chrome-extensions/src/index.ts b/packages/electron-chrome-extensions/src/index.ts
new file mode 100644
index 0000000..cd0580a
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/index.ts
@@ -0,0 +1,2 @@
+export * from './browser'
+export { setSessionPartitionResolver } from './browser/partition'
diff --git a/packages/electron-chrome-extensions/src/preload.ts b/packages/electron-chrome-extensions/src/preload.ts
new file mode 100644
index 0000000..9547089
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/preload.ts
@@ -0,0 +1,6 @@
+import { injectExtensionAPIs } from './renderer'
+
+// Only load within extension page context
+if (process.type === 'service-worker' || location.href.startsWith('chrome-extension://')) {
+ injectExtensionAPIs()
+}
diff --git a/packages/electron-chrome-extensions/src/renderer/event.ts b/packages/electron-chrome-extensions/src/renderer/event.ts
new file mode 100644
index 0000000..e066964
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/renderer/event.ts
@@ -0,0 +1,39 @@
+import { ipcRenderer } from 'electron'
+
+const formatIpcName = (name: string) => `crx-${name}`
+
+const listenerMap = new Map()
+
+export const addExtensionListener = (extensionId: string, name: string, callback: Function) => {
+ const listenerCount = listenerMap.get(name) || 0
+
+ if (listenerCount === 0) {
+ // TODO: should these IPCs be batched in a microtask?
+ ipcRenderer.send('crx-add-listener', extensionId, name)
+ }
+
+ listenerMap.set(name, listenerCount + 1)
+
+ ipcRenderer.addListener(formatIpcName(name), function (event, ...args) {
+ if (process.env.NODE_ENV === 'development') {
+ console.log(name, '(result)', ...args)
+ }
+ callback(...args)
+ })
+}
+
+export const removeExtensionListener = (extensionId: string, name: string, callback: any) => {
+ if (listenerMap.has(name)) {
+ const listenerCount = listenerMap.get(name) || 0
+
+ if (listenerCount <= 1) {
+ listenerMap.delete(name)
+
+ ipcRenderer.send('crx-remove-listener', extensionId, name)
+ } else {
+ listenerMap.set(name, listenerCount - 1)
+ }
+ }
+
+ ipcRenderer.removeListener(formatIpcName(name), callback)
+}
diff --git a/packages/electron-chrome-extensions/src/renderer/index.ts b/packages/electron-chrome-extensions/src/renderer/index.ts
new file mode 100644
index 0000000..78770e5
--- /dev/null
+++ b/packages/electron-chrome-extensions/src/renderer/index.ts
@@ -0,0 +1,692 @@
+import { ipcRenderer, contextBridge, webFrame } from 'electron'
+import { addExtensionListener, removeExtensionListener } from './event'
+
+export const injectExtensionAPIs = () => {
+ interface ExtensionMessageOptions {
+ noop?: boolean
+ defaultResponse?: any
+ serialize?: (...args: any[]) => any[]
+ }
+
+ const invokeExtension = async function (
+ extensionId: string,
+ fnName: string,
+ options: ExtensionMessageOptions = {},
+ ...args: any[]
+ ) {
+ const callback = typeof args[args.length - 1] === 'function' ? args.pop() : undefined
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log(fnName, args)
+ }
+
+ if (options.noop) {
+ console.warn(`${fnName} is not yet implemented.`)
+ if (callback) callback(options.defaultResponse)
+ return Promise.resolve(options.defaultResponse)
+ }
+
+ if (options.serialize) {
+ args = options.serialize(...args)
+ }
+
+ let result
+
+ try {
+ result = await ipcRenderer.invoke('crx-msg', extensionId, fnName, ...args)
+ } catch (e) {
+ // TODO: Set chrome.runtime.lastError?
+ console.error(e)
+ result = undefined
+ }
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log(fnName, '(result)', result)
+ }
+
+ if (callback) {
+ callback(result)
+ } else {
+ return result
+ }
+ }
+
+ type ConnectNativeCallback = (connectionId: string, send: (message: any) => void) => void
+ const connectNative = (
+ extensionId: string,
+ application: string,
+ receive: (message: any) => void,
+ disconnect: () => void,
+ callback: ConnectNativeCallback,
+ ) => {
+ const connectionId = (contextBridge as any).executeInMainWorld({
+ func: () => crypto.randomUUID(),
+ })
+ invokeExtension(extensionId, 'runtime.connectNative', {}, connectionId, application)
+ const onMessage = (_event: Electron.IpcRendererEvent, message: any) => {
+ receive(message)
+ }
+ ipcRenderer.on(`crx-native-msg-${connectionId}`, onMessage)
+ ipcRenderer.once(`crx-native-msg-${connectionId}-disconnect`, () => {
+ ipcRenderer.off(`crx-native-msg-${connectionId}`, onMessage)
+ disconnect()
+ })
+ const send = (message: any) => {
+ ipcRenderer.send(`crx-native-msg-${connectionId}`, message)
+ }
+ callback(connectionId, send)
+ }
+
+ const disconnectNative = (extensionId: string, connectionId: string) => {
+ invokeExtension(extensionId, 'runtime.disconnectNative', {}, connectionId)
+ }
+
+ const electronContext = {
+ invokeExtension,
+ addExtensionListener,
+ removeExtensionListener,
+ connectNative,
+ disconnectNative,
+ }
+
+ // Function body to run in the main world.
+ // IMPORTANT: This must be self-contained, no closure variable will be included!
+ function mainWorldScript() {
+ // Use context bridge API or closure variable when context isolation is disabled.
+ const electron = ((globalThis as any).electron as typeof electronContext) || electronContext
+
+ const chrome = globalThis.chrome || {}
+ const extensionId = chrome.runtime?.id
+
+ // NOTE: This uses a synchronous IPC to get the extension manifest.
+ // To avoid this, JS bindings for RendererExtensionRegistry would be
+ // required.
+ // OFFSCREEN_DOCUMENT contexts do not have this function defined.
+ const manifest: chrome.runtime.Manifest =
+ (extensionId && chrome.runtime.getManifest?.()) || ({} as any)
+
+ const invokeExtension =
+ (fnName: string, opts: ExtensionMessageOptions = {}) =>
+ (...args: any[]) =>
+ electron.invokeExtension(extensionId, fnName, opts, ...args)
+
+ function imageData2base64(imageData: ImageData) {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return null
+
+ canvas.width = imageData.width
+ canvas.height = imageData.height
+ ctx.putImageData(imageData, 0, 0)
+
+ return canvas.toDataURL()
+ }
+
+ class ExtensionEvent implements chrome.events.Event {
+ constructor(private name: string) {}
+
+ addListener(callback: T) {
+ electron.addExtensionListener(extensionId, this.name, callback)
+ }
+ removeListener(callback: T) {
+ electron.removeExtensionListener(extensionId, this.name, callback)
+ }
+
+ getRules(callback: (rules: chrome.events.Rule[]) => void): void
+ getRules(ruleIdentifiers: string[], callback: (rules: chrome.events.Rule[]) => void): void
+ getRules(ruleIdentifiers: any, callback?: any) {
+ throw new Error('Method not implemented.')
+ }
+ hasListener(callback: T): boolean {
+ throw new Error('Method not implemented.')
+ }
+ removeRules(ruleIdentifiers?: string[] | undefined, callback?: (() => void) | undefined): void
+ removeRules(callback?: (() => void) | undefined): void
+ removeRules(ruleIdentifiers?: any, callback?: any) {
+ throw new Error('Method not implemented.')
+ }
+ addRules(
+ rules: chrome.events.Rule[],
+ callback?: ((rules: chrome.events.Rule[]) => void) | undefined,
+ ): void {
+ throw new Error('Method not implemented.')
+ }
+ hasListeners(): boolean {
+ throw new Error('Method not implemented.')
+ }
+ }
+
+ // chrome.types.ChromeSetting
+ class ChromeSetting {
+ set() {}
+ get() {}
+ clear() {}
+ onChange = {
+ addListener: () => {},
+ }
+ }
+
+ class Event implements Partial> {
+ private listeners: T[] = []
+
+ _emit(...args: any[]) {
+ this.listeners.forEach((listener) => {
+ listener(...args)
+ })
+ }
+
+ addListener(callback: T): void {
+ this.listeners.push(callback)
+ }
+ removeListener(callback: T): void {
+ const index = this.listeners.indexOf(callback)
+ if (index > -1) {
+ this.listeners.splice(index, 1)
+ }
+ }
+ }
+
+ class NativePort implements chrome.runtime.Port {
+ private connectionId: string = ''
+ private connected = false
+ private pending: any[] = []
+
+ name: string = ''
+
+ _init = (connectionId: string, send: (message: any) => void) => {
+ this.connected = true
+ this.connectionId = connectionId
+ this._send = send
+
+ this.pending.forEach((msg) => this.postMessage(msg))
+ this.pending = []
+
+ Object.defineProperty(this, '_init', { value: undefined })
+ }
+
+ _send(message: any) {
+ this.pending.push(message)
+ }
+
+ _receive(message: any) {
+ ;(this.onMessage as any)._emit(message)
+ }
+
+ _disconnect() {
+ this.disconnect()
+ }
+
+ postMessage(message: any) {
+ this._send(message)
+ }
+ disconnect() {
+ if (this.connected) {
+ electron.disconnectNative(extensionId, this.connectionId)
+ ;(this.onDisconnect as any)._emit()
+ this.connected = false
+ }
+ }
+ onMessage: chrome.runtime.PortMessageEvent = new Event() as any
+ onDisconnect: chrome.runtime.PortDisconnectEvent = new Event() as any
+ }
+
+ type DeepPartial = {
+ [P in keyof T]?: DeepPartial
+ }
+
+ type APIFactoryMap = {
+ [apiName in keyof typeof chrome]: {
+ shouldInject?: () => boolean
+ factory: (
+ base: DeepPartial<(typeof chrome)[apiName]>,
+ ) => DeepPartial<(typeof chrome)[apiName]>
+ }
+ }
+
+ const browserActionFactory = (base: DeepPartial) => {
+ const api = {
+ ...base,
+
+ setTitle: invokeExtension('browserAction.setTitle'),
+ getTitle: invokeExtension('browserAction.getTitle'),
+
+ setIcon: invokeExtension('browserAction.setIcon', {
+ serialize: (details: chrome.action.TabIconDetails) => {
+ if (details.imageData) {
+ if (manifest.manifest_version === 3) {
+ // TODO(mv3): might need to use offscreen document to serialize
+ console.warn(
+ 'action.setIcon with imageData is not yet supported by electron-chrome-extensions',
+ )
+ details.imageData = undefined
+ } else if (details.imageData instanceof ImageData) {
+ details.imageData = imageData2base64(details.imageData) as any
+ } else {
+ details.imageData = Object.entries(details.imageData).reduce(
+ (obj: any, pair: any[]) => {
+ obj[pair[0]] = imageData2base64(pair[1])
+ return obj
+ },
+ {},
+ )
+ }
+ }
+
+ return [details]
+ },
+ }),
+
+ setPopup: invokeExtension('browserAction.setPopup'),
+ getPopup: invokeExtension('browserAction.getPopup'),
+
+ setBadgeText: invokeExtension('browserAction.setBadgeText'),
+ getBadgeText: invokeExtension('browserAction.getBadgeText'),
+
+ setBadgeBackgroundColor: invokeExtension('browserAction.setBadgeBackgroundColor'),
+ getBadgeBackgroundColor: invokeExtension('browserAction.getBadgeBackgroundColor'),
+
+ getUserSettings: invokeExtension('browserAction.getUserSettings'),
+
+ enable: invokeExtension('browserAction.enable', { noop: true }),
+ disable: invokeExtension('browserAction.disable', { noop: true }),
+
+ openPopup: invokeExtension('browserAction.openPopup'),
+
+ onClicked: new ExtensionEvent('browserAction.onClicked'),
+ }
+
+ return api
+ }
+
+ /**
+ * Factories for each additional chrome.* API.
+ */
+ const apiDefinitions: Partial = {
+ action: {
+ shouldInject: () => manifest.manifest_version === 3 && !!manifest.action,
+ factory: browserActionFactory,
+ },
+
+ browserAction: {
+ shouldInject: () => manifest.manifest_version === 2 && !!manifest.browser_action,
+ factory: browserActionFactory,
+ },
+
+ commands: {
+ factory: (base) => {
+ return {
+ ...base,
+ getAll: invokeExtension('commands.getAll'),
+ onCommand: new ExtensionEvent('commands.onCommand'),
+ }
+ },
+ },
+
+ contextMenus: {
+ factory: (base) => {
+ let menuCounter = 0
+ const menuCallbacks: {
+ [key: string]: chrome.contextMenus.CreateProperties['onclick']
+ } = {}
+ const menuCreate = invokeExtension('contextMenus.create')
+
+ let hasInternalListener = false
+ const addInternalListener = () => {
+ api.onClicked.addListener((info, tab) => {
+ const callback = menuCallbacks[info.menuItemId]
+ if (callback && tab) callback(info, tab)
+ })
+ hasInternalListener = true
+ }
+
+ const api = {
+ ...base,
+ create: function (
+ createProperties: chrome.contextMenus.CreateProperties,
+ callback?: Function,
+ ) {
+ if (typeof createProperties.id === 'undefined') {
+ createProperties.id = `${++menuCounter}`
+ }
+ if (createProperties.onclick) {
+ if (!hasInternalListener) addInternalListener()
+ menuCallbacks[createProperties.id] = createProperties.onclick
+ delete createProperties.onclick
+ }
+ menuCreate(createProperties, callback)
+ return createProperties.id
+ },
+ update: invokeExtension('contextMenus.update', { noop: true }),
+ remove: invokeExtension('contextMenus.remove'),
+ removeAll: invokeExtension('contextMenus.removeAll'),
+ onClicked: new ExtensionEvent<
+ (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => void
+ >('contextMenus.onClicked'),
+ }
+
+ return api
+ },
+ },
+
+ cookies: {
+ factory: (base) => {
+ return {
+ ...base,
+ get: invokeExtension('cookies.get'),
+ getAll: invokeExtension('cookies.getAll'),
+ set: invokeExtension('cookies.set'),
+ remove: invokeExtension('cookies.remove'),
+ getAllCookieStores: invokeExtension('cookies.getAllCookieStores'),
+ onChanged: new ExtensionEvent('cookies.onChanged'),
+ }
+ },
+ },
+
+ // TODO: implement
+ downloads: {
+ factory: (base) => {
+ return {
+ ...base,
+ acceptDanger: invokeExtension('downloads.acceptDanger', { noop: true }),
+ cancel: invokeExtension('downloads.cancel', { noop: true }),
+ download: invokeExtension('downloads.download', { noop: true }),
+ erase: invokeExtension('downloads.erase', { noop: true }),
+ getFileIcon: invokeExtension('downloads.getFileIcon', { noop: true }),
+ open: invokeExtension('downloads.open', { noop: true }),
+ pause: invokeExtension('downloads.pause', { noop: true }),
+ removeFile: invokeExtension('downloads.removeFile', { noop: true }),
+ resume: invokeExtension('downloads.resume', { noop: true }),
+ search: invokeExtension('downloads.search', { noop: true }),
+ setUiOptions: invokeExtension('downloads.setUiOptions', { noop: true }),
+ show: invokeExtension('downloads.show', { noop: true }),
+ showDefaultFolder: invokeExtension('downloads.showDefaultFolder', { noop: true }),
+ onChanged: new ExtensionEvent('downloads.onChanged'),
+ onCreated: new ExtensionEvent('downloads.onCreated'),
+ onDeterminingFilename: new ExtensionEvent('downloads.onDeterminingFilename'),
+ onErased: new ExtensionEvent('downloads.onErased'),
+ }
+ },
+ },
+
+ extension: {
+ factory: (base) => {
+ return {
+ ...base,
+ isAllowedFileSchemeAccess: invokeExtension('extension.isAllowedFileSchemeAccess', {
+ noop: true,
+ defaultResponse: false,
+ }),
+ isAllowedIncognitoAccess: invokeExtension('extension.isAllowedIncognitoAccess', {
+ noop: true,
+ defaultResponse: false,
+ }),
+ // TODO: Add native implementation
+ getViews: () => [],
+ }
+ },
+ },
+
+ i18n: {
+ shouldInject: () => manifest.manifest_version === 3,
+ factory: (base) => {
+ // Electron configuration prevented this API from being available.
+ // https://github.com/electron/electron/pull/45031
+ if (base.getMessage) {
+ return base
+ }
+
+ return {
+ ...base,
+ getUILanguage: () => 'en-US',
+ getAcceptLanguages: (callback: any) => {
+ const results = ['en-US']
+ if (callback) {
+ queueMicrotask(() => callback(results))
+ }
+ return Promise.resolve(results)
+ },
+ getMessage: (messageName: string) => messageName,
+ }
+ },
+ },
+
+ notifications: {
+ factory: (base) => {
+ return {
+ ...base,
+ clear: invokeExtension('notifications.clear'),
+ create: invokeExtension('notifications.create'),
+ getAll: invokeExtension('notifications.getAll'),
+ getPermissionLevel: invokeExtension('notifications.getPermissionLevel'),
+ update: invokeExtension('notifications.update'),
+ onClicked: new ExtensionEvent('notifications.onClicked'),
+ onButtonClicked: new ExtensionEvent('notifications.onButtonClicked'),
+ onClosed: new ExtensionEvent('notifications.onClosed'),
+ }
+ },
+ },
+
+ permissions: {
+ factory: (base) => {
+ return {
+ ...base,
+ contains: invokeExtension('permissions.contains'),
+ getAll: invokeExtension('permissions.getAll'),
+ remove: invokeExtension('permissions.remove'),
+ request: invokeExtension('permissions.request'),
+ onAdded: new ExtensionEvent('permissions.onAdded'),
+ onRemoved: new ExtensionEvent('permissions.onRemoved'),
+ }
+ },
+ },
+
+ privacy: {
+ factory: (base) => {
+ return {
+ ...base,
+ network: {
+ networkPredictionEnabled: new ChromeSetting(),
+ webRTCIPHandlingPolicy: new ChromeSetting(),
+ },
+ services: {
+ autofillAddressEnabled: new ChromeSetting(),
+ autofillCreditCardEnabled: new ChromeSetting(),
+ passwordSavingEnabled: new ChromeSetting(),
+ },
+ websites: {
+ hyperlinkAuditingEnabled: new ChromeSetting(),
+ },
+ }
+ },
+ },
+
+ runtime: {
+ factory: (base) => {
+ return {
+ ...base,
+ connectNative: (application: string) => {
+ const port = new NativePort()
+ const receive = port._receive.bind(port)
+ const disconnect = port._disconnect.bind(port)
+ const callback: ConnectNativeCallback = (connectionId, send) => {
+ port._init(connectionId, send)
+ }
+ electron.connectNative(extensionId, application, receive, disconnect, callback)
+ return port
+ },
+ openOptionsPage: invokeExtension('runtime.openOptionsPage'),
+ sendNativeMessage: invokeExtension('runtime.sendNativeMessage'),
+ }
+ },
+ },
+
+ storage: {
+ factory: (base) => {
+ const local = base && base.local
+ return {
+ ...base,
+ // TODO: provide a backend for browsers to opt-in to
+ managed: local,
+ sync: local,
+ }
+ },
+ },
+
+ tabs: {
+ factory: (base) => {
+ const api = {
+ ...base,
+ create: invokeExtension('tabs.create'),
+ executeScript: async function (
+ arg1: unknown,
+ arg2: unknown,
+ arg3: unknown,
+ ): Promise {
+ // Electron's implementation of chrome.tabs.executeScript is in
+ // C++, but it doesn't support implicit execution in the active
+ // tab. To handle this, we need to get the active tab ID and
+ // pass it into the C++ implementation ourselves.
+ if (typeof arg1 === 'object') {
+ const [activeTab] = await api.query({
+ active: true,
+ windowId: chrome.windows.WINDOW_ID_CURRENT,
+ })
+ return api.executeScript(activeTab.id, arg1, arg2)
+ } else {
+ return (base.executeScript as typeof chrome.tabs.executeScript)(
+ arg1 as number,
+ arg2 as chrome.tabs.InjectDetails,
+ arg3 as () => {},
+ )
+ }
+ },
+ get: invokeExtension('tabs.get'),
+ getCurrent: invokeExtension('tabs.getCurrent'),
+ getAllInWindow: invokeExtension('tabs.getAllInWindow'),
+ insertCSS: invokeExtension('tabs.insertCSS'),
+ query: invokeExtension('tabs.query'),
+ reload: invokeExtension('tabs.reload'),
+ update: invokeExtension('tabs.update'),
+ remove: invokeExtension('tabs.remove'),
+ goBack: invokeExtension('tabs.goBack'),
+ goForward: invokeExtension('tabs.goForward'),
+ onCreated: new ExtensionEvent('tabs.onCreated'),
+ onRemoved: new ExtensionEvent('tabs.onRemoved'),
+ onUpdated: new ExtensionEvent('tabs.onUpdated'),
+ onActivated: new ExtensionEvent('tabs.onActivated'),
+ onReplaced: new ExtensionEvent('tabs.onReplaced'),
+ }
+ return api
+ },
+ },
+
+ topSites: {
+ factory: () => {
+ return {
+ get: invokeExtension('topSites.get', { noop: true, defaultResponse: [] }),
+ }
+ },
+ },
+
+ webNavigation: {
+ factory: (base) => {
+ return {
+ ...base,
+ getFrame: invokeExtension('webNavigation.getFrame'),
+ getAllFrames: invokeExtension('webNavigation.getAllFrames'),
+ onBeforeNavigate: new ExtensionEvent('webNavigation.onBeforeNavigate'),
+ onCommitted: new ExtensionEvent('webNavigation.onCommitted'),
+ onCompleted: new ExtensionEvent('webNavigation.onCompleted'),
+ onCreatedNavigationTarget: new ExtensionEvent(
+ 'webNavigation.onCreatedNavigationTarget',
+ ),
+ onDOMContentLoaded: new ExtensionEvent('webNavigation.onDOMContentLoaded'),
+ onErrorOccurred: new ExtensionEvent('webNavigation.onErrorOccurred'),
+ onHistoryStateUpdated: new ExtensionEvent('webNavigation.onHistoryStateUpdated'),
+ onReferenceFragmentUpdated: new ExtensionEvent(
+ 'webNavigation.onReferenceFragmentUpdated',
+ ),
+ onTabReplaced: new ExtensionEvent('webNavigation.onTabReplaced'),
+ }
+ },
+ },
+
+ webRequest: {
+ factory: (base) => {
+ return {
+ ...base,
+ onHeadersReceived: new ExtensionEvent('webRequest.onHeadersReceived'),
+ }
+ },
+ },
+
+ windows: {
+ factory: (base) => {
+ return {
+ ...base,
+ WINDOW_ID_NONE: -1,
+ WINDOW_ID_CURRENT: -2,
+ get: invokeExtension('windows.get'),
+ getCurrent: invokeExtension('windows.getCurrent'),
+ getLastFocused: invokeExtension('windows.getLastFocused'),
+ getAll: invokeExtension('windows.getAll'),
+ create: invokeExtension('windows.create'),
+ update: invokeExtension('windows.update'),
+ remove: invokeExtension('windows.remove'),
+ onCreated: new ExtensionEvent('windows.onCreated'),
+ onRemoved: new ExtensionEvent('windows.onRemoved'),
+ onFocusChanged: new ExtensionEvent('windows.onFocusChanged'),
+ onBoundsChanged: new ExtensionEvent('windows.onBoundsChanged'),
+ }
+ },
+ },
+ }
+
+ // Initialize APIs
+ Object.keys(apiDefinitions).forEach((key: any) => {
+ const apiName: keyof typeof chrome = key
+ const baseApi = chrome[apiName] as any
+ const api = apiDefinitions[apiName]!
+
+ // Allow APIs to opt-out of being available in this context.
+ if (api.shouldInject && !api.shouldInject()) return
+
+ Object.defineProperty(chrome, apiName, {
+ value: api.factory(baseApi),
+ enumerable: true,
+ configurable: true,
+ })
+ })
+
+ // Remove access to internals
+ delete (globalThis as any).electron
+
+ Object.freeze(chrome)
+
+ void 0 // no return
+ }
+
+ if (!process.contextIsolated) {
+ console.warn(`injectExtensionAPIs: context isolation disabled in ${location.href}`)
+ mainWorldScript()
+ return
+ }
+
+ try {
+ // Expose extension IPC to main world
+ contextBridge.exposeInMainWorld('electron', electronContext)
+
+ // Mutate global 'chrome' object with additional APIs in the main world.
+ if ('executeInMainWorld' in contextBridge) {
+ ;(contextBridge as any).executeInMainWorld({
+ func: mainWorldScript,
+ })
+ } else {
+ // TODO(mv3): remove webFrame usage
+ webFrame.executeJavaScript(`(${mainWorldScript}());`)
+ }
+ } catch (error) {
+ console.error(`injectExtensionAPIs error (${location.href})`)
+ console.error(error)
+ }
+}
diff --git a/packages/electron-chrome-extensions/tsconfig.json b/packages/electron-chrome-extensions/tsconfig.json
new file mode 100644
index 0000000..aa6b12c
--- /dev/null
+++ b/packages/electron-chrome-extensions/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+
+ "compilerOptions": {
+ "outDir": "dist/types",
+ "declaration": true,
+ "emitDeclarationOnly": true
+ },
+
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/electron-chrome-web-store/.gitignore b/packages/electron-chrome-web-store/.gitignore
new file mode 100644
index 0000000..0035fd1
--- /dev/null
+++ b/packages/electron-chrome-web-store/.gitignore
@@ -0,0 +1,2 @@
+dist
+*.preload.js
diff --git a/packages/electron-chrome-web-store/.npmignore b/packages/electron-chrome-web-store/.npmignore
new file mode 100644
index 0000000..beceb55
--- /dev/null
+++ b/packages/electron-chrome-web-store/.npmignore
@@ -0,0 +1,3 @@
+src
+tsconfig.json
+esbuild.config.js
diff --git a/packages/electron-chrome-web-store/README.md b/packages/electron-chrome-web-store/README.md
new file mode 100644
index 0000000..9adff48
--- /dev/null
+++ b/packages/electron-chrome-web-store/README.md
@@ -0,0 +1,143 @@
+# electron-chrome-web-store
+
+Install and update Chrome extensions from the Chrome Web Store for Electron.
+
+## Usage
+
+```
+npm install electron-chrome-web-store
+```
+
+> [!TIP]
+> To enable full support for Chrome extensions in Electron, install [electron-chrome-extensions](https://www.npmjs.com/package/electron-chrome-extensions).
+
+### Enable downloading extensions from the Chrome Web Store
+
+```js
+const { app, BrowserWindow, session } = require('electron')
+const { installChromeWebStore } = require('electron-chrome-web-store')
+
+app.whenReady().then(async () => {
+ const browserSession = session.defaultSession
+ const browserWindow = new BrowserWindow({
+ webPreferences: {
+ session: browserSession,
+ },
+ })
+
+ // Install Chrome web store and wait for extensions to load
+ await installChromeWebStore({ session: browserSession })
+
+ browserWindow.loadURL('https://chromewebstore.google.com/')
+})
+```
+
+### Install and update extensions programmatically
+
+```js
+const { app, session } = require('electron')
+const { installExtension, updateExtensions } = require('electron-chrome-web-store')
+
+app.whenReady().then(async () => {
+ // Install Dark Reader
+ await installExtension('eimadpbcbfnmbkopoojfekhnkhdbieeh')
+
+ // Install React Developer Tools with file:// access
+ await installExtension('fmkadmapgofadopljbjfkapdkoienihi', {
+ loadExtensionOptions: { allowFileAccess: true },
+ })
+
+ // Install uBlock Origin Lite to custom session
+ await installExtension('ddkjiahejlhfcafbddmgiahcphecmpfh', {
+ session: session.fromPartition('persist:browser'),
+ })
+
+ // Check and install updates for all loaded extensions
+ await updateExtensions()
+})
+```
+
+### Packaging the preload script
+
+This module uses a [preload script](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload#what-is-a-preload-script).
+When packaging your application, it's required that the preload script is included. This can be
+handled in two ways:
+
+1. Include `node_modules` in your packaged app. This allows `electron-chrome-web-store/preload` to
+ be resolved.
+2. In the case of using JavaScript bundlers, you may need to copy the preload script next to your
+ app's entry point script. You can try using
+ [copy-webpack-plugin](https://github.com/webpack-contrib/copy-webpack-plugin),
+ [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy),
+ or [rollup-plugin-copy](https://github.com/vladshcherbin/rollup-plugin-copy) depending on your app's
+ configuration.
+
+Here's an example for webpack configurations:
+
+```js
+module.exports = {
+ entry: './index.js',
+ plugins: [
+ new CopyWebpackPlugin({
+ patterns: [require.resolve('electron-chrome-web-store/preload')],
+ }),
+ ],
+}
+```
+
+## API
+
+### `installChromeWebStore`
+
+Installs Chrome Web Store support in the specified session.
+
+- `options`
+ - `session`: The Electron session to enable the Chrome Web Store in. Defaults to `session.defaultSession`.
+ - `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
+ - `autoUpdate`: Whether to auto-update web store extensions at startup and once every 5 hours. Defaults to true.
+ - `loadExtensions`: A boolean indicating whether to load extensions installed by Chrome Web Store. Defaults to true.
+ - `allowUnpackedExtensions`: A boolean indicating whether to allow loading unpacked extensions. Only loads if `loadExtensions` is also enabled. Defaults to false.
+ - `allowlist`: An array of allowed extension IDs to install.
+ - `denylist`: An array of denied extension IDs to install.
+ - `beforeInstall`: A function which receives install details and returns a promise. Allows for prompting prior to install.
+
+### `installExtension`
+
+Installs Chrome extension from the Chrome Web Store.
+
+- `extensionId`: The Chrome Web Store extension ID to install.
+- `options`
+ - `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`.
+ - `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
+ - `loadExtensionOptions`: Extension options passed into `session.extensions.loadExtension`.
+
+### `uninstallExtension`
+
+Uninstalls Chrome Web Store extension.
+
+- `extensionId`: The Chrome Web Store extension ID to uninstall.
+- `options`
+ - `session`: The Electron session where extensions are loaded. Defaults to `session.defaultSession`.
+ - `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path.
+
+### `updateExtensions`
+
+Checks loaded extensions for updates and installs any if available.
+
+- `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`.
+
+### `loadAllExtensions`
+
+Loads all extensions from the specified directory.
+
+- `session`: The Electron session to load extensions in.
+- `extensionsPath`: The path to the directory containing the extensions.
+- `options`: An object with the following property:
+ - `allowUnpacked`: A boolean indicating whether to allow loading unpacked extensions. Defaults to false.
+
+> [!NOTE]
+> The `installChromeWebStore` API will automatically load web store extensions by default.
+
+## License
+
+MIT
diff --git a/packages/electron-chrome-web-store/esbuild.config.js b/packages/electron-chrome-web-store/esbuild.config.js
new file mode 100644
index 0000000..c251b43
--- /dev/null
+++ b/packages/electron-chrome-web-store/esbuild.config.js
@@ -0,0 +1,36 @@
+const packageJson = require('./package.json')
+const { createConfig, build, EXTERNAL_BASE } = require('../../build/esbuild/esbuild.config.base')
+
+console.log(`building ${packageJson.name}`)
+
+const external = [...EXTERNAL_BASE, 'adm-zip', 'pbf', 'electron-chrome-web-store/preload']
+
+const esmOnlyModules = ['pbf']
+
+const browserConfig = createConfig({
+ entryPoints: ['src/browser/index.ts'],
+ outfile: 'dist/cjs/browser/index.js',
+ platform: 'node',
+ external: external.filter((module) => !esmOnlyModules.includes(module)),
+})
+
+const browserESMConfig = createConfig({
+ entryPoints: ['src/browser/index.ts'],
+ outfile: 'dist/esm/browser/index.mjs',
+ platform: 'neutral',
+ external,
+ format: 'esm',
+})
+
+build(browserConfig)
+build(browserESMConfig)
+
+const preloadConfig = createConfig({
+ entryPoints: ['src/renderer/chrome-web-store.preload.ts'],
+ outfile: 'dist/chrome-web-store.preload.js',
+ platform: 'browser',
+ external,
+ sourcemap: false,
+})
+
+build(preloadConfig)
diff --git a/packages/electron-chrome-web-store/package.json b/packages/electron-chrome-web-store/package.json
new file mode 100644
index 0000000..0c75867
--- /dev/null
+++ b/packages/electron-chrome-web-store/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "electron-chrome-web-store",
+ "version": "1.0.0",
+ "description": "Install and update Chrome extensions from the Chrome Web Store for Electron",
+ "main": "./dist/cjs/browser/index.js",
+ "module": "./dist/esm/browser/index.mjs",
+ "types": "./dist/types/browser/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/types/browser/index.d.ts",
+ "import": "./dist/esm/browser/index.mjs",
+ "require": "./dist/cjs/browser/index.js"
+ },
+ "./preload": "./dist/chrome-web-store.preload.js"
+ },
+ "scripts": {
+ "build": "yarn clean && tsc && node esbuild.config.js",
+ "clean": "node ../../scripts/clean.js",
+ "prepublish": "NODE_ENV=production yarn build"
+ },
+ "keywords": [
+ "electron",
+ "chrome",
+ "web",
+ "store",
+ "webstore",
+ "extensions"
+ ],
+ "repository": "",
+ "author": "Damon ",
+ "license": "MIT",
+ "devDependencies": {
+ "esbuild": "^0.24.0",
+ "rimraf": "^6.0.1",
+ "typescript": "^4.5.4"
+ },
+ "dependencies": {
+ "@types/chrome": "^0.0.287",
+ "adm-zip": "^0.5.16",
+ "debug": "^4.3.7",
+ "pbf": "^4.0.1"
+ }
+}
diff --git a/packages/electron-chrome-web-store/src/browser/api.ts b/packages/electron-chrome-web-store/src/browser/api.ts
new file mode 100644
index 0000000..aee1dfe
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/api.ts
@@ -0,0 +1,348 @@
+import * as fs from 'node:fs'
+import * as path from 'node:path'
+import debug from 'debug'
+import { app, BrowserWindow, ipcMain, nativeImage, NativeImage, Session } from 'electron'
+import { fetch } from './utils'
+
+import {
+ ExtensionInstallStatus,
+ MV2DeprecationStatus,
+ Result,
+ WebGlStatus,
+} from '../common/constants'
+import { installExtension, uninstallExtension } from './installer'
+import { ExtensionId, WebStoreState } from './types'
+
+const d = debug('electron-chrome-web-store:api')
+
+const WEBSTORE_URL = 'https://chromewebstore.google.com'
+
+function getExtensionInfo(ext: Electron.Extension) {
+ const manifest: chrome.runtime.Manifest = ext.manifest
+ return {
+ description: manifest.description || '',
+ enabled: !manifest.disabled,
+ homepageUrl: manifest.homepage_url || '',
+ hostPermissions: manifest.host_permissions || [],
+ icons: Object.entries(manifest?.icons || {}).map(([size, url]) => ({
+ size: parseInt(size),
+ url: `chrome://extension-icon/${ext.id}/${size}/0`,
+ })),
+ id: ext.id,
+ installType: 'normal',
+ isApp: !!manifest.app,
+ mayDisable: true,
+ name: manifest.name,
+ offlineEnabled: !!manifest.offline_enabled,
+ optionsUrl: manifest.options_page
+ ? `chrome-extension://${ext.id}/${manifest.options_page}`
+ : '',
+ permissions: manifest.permissions || [],
+ shortName: manifest.short_name || manifest.name,
+ type: manifest.app ? 'app' : 'extension',
+ updateUrl: manifest.update_url || '',
+ version: manifest.version,
+ }
+}
+
+function getExtensionInstallStatus(
+ state: WebStoreState,
+ extensionId: ExtensionId,
+ manifest?: chrome.runtime.Manifest,
+) {
+ if (manifest && manifest.manifest_version < state.minimumManifestVersion) {
+ return ExtensionInstallStatus.DEPRECATED_MANIFEST_VERSION
+ }
+
+ if (state.denylist?.has(extensionId)) {
+ return ExtensionInstallStatus.BLOCKED_BY_POLICY
+ }
+
+ if (state.allowlist && !state.allowlist.has(extensionId)) {
+ return ExtensionInstallStatus.BLOCKED_BY_POLICY
+ }
+
+ const sessionExtensions = state.session.extensions || state.session
+ const extensions = sessionExtensions.getAllExtensions()
+ const extension = extensions.find((ext) => ext.id === extensionId)
+
+ if (!extension) {
+ return ExtensionInstallStatus.INSTALLABLE
+ }
+
+ if (extension.manifest.disabled) {
+ return ExtensionInstallStatus.DISABLED
+ }
+
+ return ExtensionInstallStatus.ENABLED
+}
+
+interface InstallDetails {
+ id: string
+ manifest: string
+ localizedName: string
+ esbAllowlist: boolean
+ iconUrl: string
+}
+
+async function beginInstall(
+ { sender, senderFrame }: Electron.IpcMainInvokeEvent,
+ state: WebStoreState,
+ details: InstallDetails,
+) {
+ const extensionId = details.id
+
+ try {
+ if (state.installing.has(extensionId)) {
+ return { result: Result.INSTALL_IN_PROGRESS }
+ }
+
+ let manifest: chrome.runtime.Manifest
+ try {
+ manifest = JSON.parse(details.manifest)
+ } catch {
+ return { result: Result.MANIFEST_ERROR }
+ }
+
+ const installStatus = getExtensionInstallStatus(state, extensionId, manifest)
+ switch (installStatus) {
+ case ExtensionInstallStatus.INSTALLABLE:
+ break // good to go
+ case ExtensionInstallStatus.BLOCKED_BY_POLICY:
+ return { result: Result.BLOCKED_BY_POLICY }
+ default: {
+ d('unable to install extension %s with status "%s"', extensionId, installStatus)
+ return { result: Result.UNKNOWN_ERROR }
+ }
+ }
+
+ let iconUrl: URL
+ try {
+ iconUrl = new URL(details.iconUrl)
+ } catch {
+ return { result: Result.INVALID_ICON_URL }
+ }
+
+ let icon: NativeImage
+ try {
+ const response = await fetch(iconUrl.href)
+ const imageBuffer = Buffer.from(await response.arrayBuffer())
+ icon = nativeImage.createFromBuffer(imageBuffer)
+ } catch {
+ return { result: Result.ICON_ERROR }
+ }
+
+ const browserWindow = BrowserWindow.fromWebContents(sender)
+ if (!senderFrame || senderFrame.isDestroyed()) {
+ return { result: Result.UNKNOWN_ERROR }
+ }
+
+ if (state.beforeInstall) {
+ const result: unknown = await state.beforeInstall({
+ id: extensionId,
+ localizedName: details.localizedName,
+ manifest,
+ icon,
+ frame: senderFrame,
+ browserWindow: browserWindow || undefined,
+ })
+
+ if (typeof result !== 'object' || typeof (result as any).action !== 'string') {
+ return { result: Result.UNKNOWN_ERROR }
+ } else if ((result as any).action !== 'allow') {
+ return { result: Result.USER_CANCELLED }
+ }
+ }
+
+ state.installing.add(extensionId)
+ await installExtension(extensionId, state)
+ return { result: Result.SUCCESS }
+ } catch (error) {
+ console.error('Extension installation failed:', error)
+ return {
+ result: Result.INSTALL_ERROR,
+ message: error instanceof Error ? error.message : String(error),
+ }
+ } finally {
+ state.installing.delete(extensionId)
+ }
+}
+
+type IPCChannelHandler = (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any
+const handledIpcChannels = new Map>()
+
+export function registerWebStoreApi(webStoreState: WebStoreState) {
+ /** Handle IPCs from the Chrome Web Store. */
+ const handle = (
+ channel: string,
+ handle: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
+ ) => {
+ let handlersMap = handledIpcChannels.get(channel)
+
+ // Handle each channel only once
+ if (!handlersMap) {
+ handlersMap = new Map()
+ handledIpcChannels.set(channel, handlersMap)
+
+ ipcMain.handle(channel, async function handleWebStoreIpc(event, ...args) {
+ d('received %s', channel)
+
+ const senderOrigin = event.senderFrame?.origin
+ if (!senderOrigin || !senderOrigin.startsWith(WEBSTORE_URL)) {
+ d('ignoring webstore request from %s', senderOrigin)
+ return
+ }
+
+ const session = event.sender.session
+
+ const handler = handlersMap?.get(session)
+ if (!handler) {
+ d('no handler for session %s', session.storagePath)
+ return
+ }
+
+ const result = await handler(event, ...args)
+ d('%s result', channel, result)
+ return result
+ })
+ }
+
+ // Add handler
+ handlersMap.set(webStoreState.session, handle)
+ }
+
+ handle('chromeWebstore.beginInstall', async (event, details: InstallDetails) => {
+ const { senderFrame } = event
+
+ d('beginInstall', details)
+
+ const result = await beginInstall(event, webStoreState, details)
+
+ if (result.result === Result.SUCCESS) {
+ queueMicrotask(() => {
+ const sessionExtensions = webStoreState.session.extensions || webStoreState.session
+ const ext = sessionExtensions.getExtension(details.id)
+ if (ext && senderFrame && !senderFrame.isDestroyed()) {
+ try {
+ senderFrame.send('chrome.management.onInstalled', getExtensionInfo(ext))
+ } catch (error) {
+ console.error(error)
+ }
+ }
+ })
+ }
+
+ return result
+ })
+
+ handle('chromeWebstore.completeInstall', async (event, id) => {
+ // TODO: Implement completion of extension installation
+ return Result.SUCCESS
+ })
+
+ handle('chromeWebstore.enableAppLauncher', async (event, enable) => {
+ // TODO: Implement app launcher enable/disable
+ return true
+ })
+
+ handle('chromeWebstore.getBrowserLogin', async () => {
+ // TODO: Implement getting browser login
+ return ''
+ })
+ handle('chromeWebstore.getExtensionStatus', async (_event, id, manifestJson) => {
+ const manifest = JSON.parse(manifestJson)
+ return getExtensionInstallStatus(webStoreState, id, manifest)
+ })
+
+ handle('chromeWebstore.getFullChromeVersion', async () => {
+ return {
+ version_number: process.versions.chrome,
+ app_name: app.getName(),
+ }
+ })
+
+ handle('chromeWebstore.getIsLauncherEnabled', async () => {
+ // TODO: Implement checking if launcher is enabled
+ return true
+ })
+
+ handle('chromeWebstore.getMV2DeprecationStatus', async () => {
+ return webStoreState.minimumManifestVersion > 2
+ ? MV2DeprecationStatus.SOFT_DISABLE
+ : MV2DeprecationStatus.INACTIVE
+ })
+
+ handle('chromeWebstore.getReferrerChain', async () => {
+ // TODO: Implement getting referrer chain
+ return 'EgIIAA=='
+ })
+
+ handle('chromeWebstore.getStoreLogin', async () => {
+ // TODO: Implement getting store login
+ return ''
+ })
+
+ handle('chromeWebstore.getWebGLStatus', async () => {
+ await app.getGPUInfo('basic')
+ const features = app.getGPUFeatureStatus()
+ return features.webgl.startsWith('enabled')
+ ? WebGlStatus.WEBGL_ALLOWED
+ : WebGlStatus.WEBGL_BLOCKED
+ })
+
+ handle('chromeWebstore.install', async (event, id, silentInstall) => {
+ // TODO: Implement extension installation
+ return Result.SUCCESS
+ })
+
+ handle('chromeWebstore.isInIncognitoMode', async () => {
+ // TODO: Implement incognito mode check
+ return false
+ })
+
+ handle('chromeWebstore.isPendingCustodianApproval', async (event, id) => {
+ // TODO: Implement custodian approval check
+ return false
+ })
+
+ handle('chromeWebstore.setStoreLogin', async (event, login) => {
+ // TODO: Implement setting store login
+ return true
+ })
+
+ handle('chrome.runtime.getManifest', async () => {
+ // TODO: Implement getting extension manifest
+ return {}
+ })
+
+ handle('chrome.management.getAll', async (event) => {
+ const sessionExtensions = webStoreState.session.extensions || webStoreState.session
+ const extensions = sessionExtensions.getAllExtensions()
+ return extensions.map(getExtensionInfo)
+ })
+
+ handle('chrome.management.setEnabled', async (event, id, enabled) => {
+ // TODO: Implement enabling/disabling extension
+ return true
+ })
+
+ handle(
+ 'chrome.management.uninstall',
+ async (event, id, options: { showConfirmDialog: boolean }) => {
+ if (options?.showConfirmDialog) {
+ // TODO: confirmation dialog
+ }
+
+ try {
+ await uninstallExtension(id, webStoreState)
+ queueMicrotask(() => {
+ event.sender.send('chrome.management.onUninstalled', id)
+ })
+ return Result.SUCCESS
+ } catch (error) {
+ console.error(error)
+ return Result.UNKNOWN_ERROR
+ }
+ },
+ )
+}
diff --git a/packages/electron-chrome-web-store/src/browser/crx3.proto b/packages/electron-chrome-web-store/src/browser/crx3.proto
new file mode 100644
index 0000000..b38dd5a
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/crx3.proto
@@ -0,0 +1,62 @@
+// Copyright 2017 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+syntax = "proto2";
+
+option optimize_for = LITE_RUNTIME;
+
+package crx_file;
+
+// A CRX₃ file is a binary file of the following format:
+// [4 octets]: "Cr24", a magic number.
+// [4 octets]: The version of the *.crx file format used (currently 3).
+// [4 octets]: N, little-endian, the length of the header section.
+// [N octets]: The header (the binary encoding of a CrxFileHeader).
+// [M octets]: The ZIP archive.
+// Clients should reject CRX₃ files that contain an N that is too large for the
+// client to safely handle in memory.
+
+message CrxFileHeader {
+ // PSS signature with RSA public key. The public key is formatted as a
+ // X.509 SubjectPublicKeyInfo block, as in CRX₂. In the common case of a
+ // developer key proof, the first 128 bits of the SHA-256 hash of the
+ // public key must equal the crx_id.
+ repeated AsymmetricKeyProof sha256_with_rsa = 2;
+
+ // ECDSA signature, using the NIST P-256 curve. Public key appears in
+ // named-curve format.
+ // The pinned algorithm will be this, at least on 2017-01-01.
+ repeated AsymmetricKeyProof sha256_with_ecdsa = 3;
+
+ // A verified contents file containing signatures over the archive contents.
+ // The verified contents are encoded in UTF-8 and then GZIP-compressed.
+ // Consult
+ // https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/verified_contents.h
+ // for information about the verified contents format.
+ optional bytes verified_contents = 4;
+
+ // The binary form of a SignedData message. We do not use a nested
+ // SignedData message, as handlers of this message must verify the proofs
+ // on exactly these bytes, so it is convenient to parse in two steps.
+ //
+ // All proofs in this CrxFile message are on the value
+ // "CRX3 SignedData\x00" + signed_header_size + signed_header_data +
+ // archive, where "\x00" indicates an octet with value 0, "CRX3 SignedData"
+ // is encoded using UTF-8, signed_header_size is the size in octets of the
+ // contents of this field and is encoded using 4 octets in little-endian
+ // order, signed_header_data is exactly the content of this field, and
+ // archive is the remaining contents of the file following the header.
+ optional bytes signed_header_data = 10000;
+}
+
+message AsymmetricKeyProof {
+ optional bytes public_key = 1;
+ optional bytes signature = 2;
+}
+
+message SignedData {
+ // This is simple binary, not UTF-8 encoded mpdecimal; i.e. it is exactly
+ // 16 bytes long.
+ optional bytes crx_id = 1;
+}
diff --git a/packages/electron-chrome-web-store/src/browser/crx3.ts b/packages/electron-chrome-web-store/src/browser/crx3.ts
new file mode 100644
index 0000000..8c4c0ab
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/crx3.ts
@@ -0,0 +1,55 @@
+// code generated by pbf v4.0.1
+// modified for electron-chrome-web-store
+
+import Pbf from 'pbf'
+
+interface AsymmetricKeyProof {
+ public_key: Buffer
+ signature: Buffer
+}
+
+interface CrxFileHeader {
+ sha256_with_rsa: AsymmetricKeyProof[]
+ sha256_with_ecdsa: AsymmetricKeyProof[]
+ verified_contents?: Buffer
+ signed_header_data?: Buffer
+}
+
+export function readCrxFileHeader(pbf: Pbf, end?: any): CrxFileHeader {
+ return pbf.readFields(
+ readCrxFileHeaderField,
+ {
+ sha256_with_rsa: [],
+ sha256_with_ecdsa: [],
+ verified_contents: undefined,
+ signed_header_data: undefined,
+ },
+ end,
+ )
+}
+function readCrxFileHeaderField(tag: any, obj: any, pbf: Pbf) {
+ if (tag === 2) obj.sha256_with_rsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos))
+ else if (tag === 3)
+ obj.sha256_with_ecdsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos))
+ else if (tag === 4) obj.verified_contents = pbf.readBytes()
+ else if (tag === 10000) obj.signed_header_data = pbf.readBytes()
+}
+
+export function readAsymmetricKeyProof(pbf: Pbf, end: any) {
+ return pbf.readFields(
+ readAsymmetricKeyProofField,
+ { public_key: undefined, signature: undefined },
+ end,
+ )
+}
+function readAsymmetricKeyProofField(tag: any, obj: any, pbf: Pbf) {
+ if (tag === 1) obj.public_key = pbf.readBytes()
+ else if (tag === 2) obj.signature = pbf.readBytes()
+}
+
+export function readSignedData(pbf: Pbf, end?: any): { crx_id?: Buffer } {
+ return pbf.readFields(readSignedDataField, { crx_id: undefined }, end)
+}
+function readSignedDataField(tag: any, obj: any, pbf: Pbf) {
+ if (tag === 1) obj.crx_id = pbf.readBytes()
+}
diff --git a/packages/electron-chrome-web-store/src/browser/deps.d.ts b/packages/electron-chrome-web-store/src/browser/deps.d.ts
new file mode 100644
index 0000000..cef3f10
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/deps.d.ts
@@ -0,0 +1,2 @@
+declare module 'adm-zip'
+declare module 'debug'
diff --git a/packages/electron-chrome-web-store/src/browser/id.ts b/packages/electron-chrome-web-store/src/browser/id.ts
new file mode 100644
index 0000000..ff074eb
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/id.ts
@@ -0,0 +1,31 @@
+import { createHash } from 'node:crypto'
+
+/**
+ * Converts a normal hexadecimal string into the alphabet used by extensions.
+ * We use the characters 'a'-'p' instead of '0'-'f' to avoid ever having a
+ * completely numeric host, since some software interprets that as an IP address.
+ *
+ * @param id - The hexadecimal string to convert. This is modified in place.
+ */
+export function convertHexadecimalToIDAlphabet(id: string) {
+ let result = ''
+ for (const ch of id) {
+ const val = parseInt(ch, 16)
+ if (!isNaN(val)) {
+ result += String.fromCharCode('a'.charCodeAt(0) + val)
+ } else {
+ result += 'a'
+ }
+ }
+ return result
+}
+
+function generateIdFromHash(hash: Buffer): string {
+ const hashedId = hash.subarray(0, 16).toString('hex')
+ return convertHexadecimalToIDAlphabet(hashedId)
+}
+
+export function generateId(input: string): string {
+ const hash = createHash('sha256').update(input, 'base64').digest()
+ return generateIdFromHash(hash)
+}
diff --git a/packages/electron-chrome-web-store/src/browser/index.ts b/packages/electron-chrome-web-store/src/browser/index.ts
new file mode 100644
index 0000000..dcc3075
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/index.ts
@@ -0,0 +1,162 @@
+import { app, session as electronSession } from 'electron'
+import * as path from 'node:path'
+import { existsSync } from 'node:fs'
+import { createRequire } from 'node:module'
+
+import { registerWebStoreApi } from './api'
+import { loadAllExtensions } from './loader'
+export { loadAllExtensions } from './loader'
+export { installExtension, uninstallExtension, downloadExtension } from './installer'
+import { initUpdater } from './updater'
+export { updateExtensions } from './updater'
+import { getDefaultExtensionsPath } from './utils'
+import { BeforeInstall, ExtensionId, WebStoreState } from './types'
+
+function resolvePreloadPath(modulePath?: string) {
+ // Attempt to resolve preload path from module exports
+ try {
+ return createRequire(__dirname).resolve('electron-chrome-web-store/preload')
+ } catch (error) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(error)
+ }
+ }
+
+ const preloadFilename = 'chrome-web-store.preload.js'
+
+ // Deprecated: use modulePath if provided
+ if (modulePath) {
+ process.emitWarning(
+ 'electron-chrome-web-store: "modulePath" is deprecated and will be removed in future versions.',
+ { type: 'DeprecationWarning' },
+ )
+ return path.join(modulePath, 'dist', preloadFilename)
+ }
+
+ // Fallback to preload relative to entrypoint directory
+ return path.join(__dirname, preloadFilename)
+}
+
+interface ElectronChromeWebStoreOptions {
+ /**
+ * Session to enable the Chrome Web Store in.
+ * Defaults to session.defaultSession
+ */
+ session?: Electron.Session
+
+ /**
+ * Path to the 'electron-chrome-web-store' module.
+ *
+ * @deprecated See "Packaging the preload script" in the readme.
+ */
+ modulePath?: string
+
+ /**
+ * Path to extensions directory.
+ * Defaults to 'Extensions/' under app's userData path.
+ */
+ extensionsPath?: string
+
+ /**
+ * Load extensions installed by Chrome Web Store.
+ * Defaults to true.
+ */
+ loadExtensions?: boolean
+
+ /**
+ * Whether to allow loading unpacked extensions. Only loads if
+ * `loadExtensions` is also enabled.
+ * Defaults to false.
+ */
+ allowUnpackedExtensions?: boolean
+
+ /**
+ * List of allowed extension IDs to install.
+ */
+ allowlist?: ExtensionId[]
+
+ /**
+ * List of denied extension IDs to install.
+ */
+ denylist?: ExtensionId[]
+
+ /**
+ * Whether extensions should auto-update.
+ */
+ autoUpdate?: boolean
+
+ /**
+ * Minimum supported version of Chrome extensions.
+ * Defaults to 3.
+ */
+ minimumManifestVersion?: number
+
+ /**
+ * Called prior to installing an extension. If implemented, return a Promise
+ * which resolves with `{ action: 'allow' | 'deny' }` depending on the action
+ * to be taken.
+ */
+ beforeInstall?: BeforeInstall
+}
+
+/**
+ * Install Chrome Web Store support.
+ *
+ * @param options Chrome Web Store configuration options.
+ */
+export async function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {}) {
+ const session = opts.session || electronSession.defaultSession
+ const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
+ const loadExtensions = typeof opts.loadExtensions === 'boolean' ? opts.loadExtensions : true
+ const allowUnpackedExtensions =
+ typeof opts.allowUnpackedExtensions === 'boolean' ? opts.allowUnpackedExtensions : false
+ const autoUpdate = typeof opts.autoUpdate === 'boolean' ? opts.autoUpdate : true
+ const minimumManifestVersion =
+ typeof opts.minimumManifestVersion === 'number' ? opts.minimumManifestVersion : 3
+ const beforeInstall = typeof opts.beforeInstall === 'function' ? opts.beforeInstall : undefined
+
+ const webStoreState: WebStoreState = {
+ session,
+ extensionsPath,
+ installing: new Set(),
+ allowlist: opts.allowlist ? new Set(opts.allowlist) : undefined,
+ denylist: opts.denylist ? new Set(opts.denylist) : undefined,
+ minimumManifestVersion,
+ beforeInstall,
+ }
+
+ // Add preload script to session
+ const preloadPath = resolvePreloadPath(opts.modulePath)
+
+ if ('registerPreloadScript' in session) {
+ session.registerPreloadScript({
+ id: 'electron-chrome-web-store',
+ type: 'frame',
+ filePath: preloadPath,
+ })
+ } else {
+ // @ts-expect-error Deprecated electron@<35
+ session.setPreloads([...session.getPreloads(), preloadPath])
+ }
+
+ if (!existsSync(preloadPath)) {
+ console.error(
+ new Error(
+ `electron-chrome-web-store: Preload file not found at "${preloadPath}". ` +
+ 'See "Packaging the preload script" in the readme.',
+ ),
+ )
+ }
+
+ registerWebStoreApi(webStoreState)
+
+ await app.whenReady()
+
+ if (loadExtensions) {
+ await loadAllExtensions(session, extensionsPath, { allowUnpacked: allowUnpackedExtensions })
+ }
+
+ if (autoUpdate) {
+ void initUpdater(webStoreState)
+ }
+}
diff --git a/packages/electron-chrome-web-store/src/browser/installer.ts b/packages/electron-chrome-web-store/src/browser/installer.ts
new file mode 100644
index 0000000..192cb11
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/installer.ts
@@ -0,0 +1,276 @@
+import * as fs from 'node:fs'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import { Readable } from 'node:stream'
+import { pipeline } from 'node:stream/promises'
+import { session as electronSession } from 'electron'
+
+import AdmZip from 'adm-zip'
+import debug from 'debug'
+import Pbf from 'pbf'
+
+import { readCrxFileHeader, readSignedData } from './crx3'
+import { convertHexadecimalToIDAlphabet, generateId } from './id'
+import { fetch, getChromeVersion, getDefaultExtensionsPath } from './utils'
+import { findExtensionInstall } from './loader'
+import { ExtensionId } from './types'
+
+const d = debug('electron-chrome-web-store:installer')
+
+function getExtensionCrxURL(extensionId: ExtensionId) {
+ const url = new URL('https://clients2.google.com/service/update2/crx')
+ url.searchParams.append('response', 'redirect')
+ url.searchParams.append('acceptformat', ['crx2', 'crx3'].join(','))
+
+ const x = new URLSearchParams()
+ x.append('id', extensionId)
+ x.append('uc', '')
+
+ url.searchParams.append('x', x.toString())
+ url.searchParams.append('prodversion', getChromeVersion())
+
+ return url.toString()
+}
+
+interface CrxInfo {
+ extensionId: string
+ version: number
+ header: Buffer
+ contents: Buffer
+ publicKey: Buffer
+}
+
+// Parse CRX header and extract contents
+function parseCrx(buffer: Buffer): CrxInfo {
+ // CRX3 magic number: 'Cr24'
+ const magicNumber = buffer.toString('utf8', 0, 4)
+ if (magicNumber !== 'Cr24') {
+ throw new Error('Invalid CRX format')
+ }
+
+ // CRX3 format has version = 3 and header size at bytes 8-12
+ const version = buffer.readUInt32LE(4)
+ const headerSize = buffer.readUInt32LE(8)
+
+ // Extract header and contents
+ const header = buffer.subarray(12, 12 + headerSize)
+ const contents = buffer.subarray(12 + headerSize)
+
+ let extensionId: string
+ let publicKey: Buffer
+
+ // For CRX2 format
+ if (version === 2) {
+ const pubKeyLength = buffer.readUInt32LE(8)
+ const sigLength = buffer.readUInt32LE(12)
+ publicKey = buffer.subarray(16, 16 + pubKeyLength)
+ extensionId = generateId(publicKey.toString('base64'))
+ } else {
+ // For CRX3, extract public key from header
+ // CRX3 header contains a protocol buffer message
+ const crxFileHeader = readCrxFileHeader(new Pbf(header))
+ const crxSignedData = readSignedData(new Pbf(crxFileHeader.signed_header_data))
+ const declaredCrxId = crxSignedData.crx_id
+ ? convertHexadecimalToIDAlphabet(crxSignedData.crx_id.toString('hex'))
+ : null
+
+ if (!declaredCrxId) {
+ throw new Error('Invalid CRX signed data')
+ }
+
+ // Need to find store key proof which matches the declared ID
+ const keyProof = crxFileHeader.sha256_with_rsa.find((proof) => {
+ const crxId = proof.public_key ? generateId(proof.public_key.toString('base64')) : null
+ return crxId === declaredCrxId
+ })
+
+ if (!keyProof) {
+ throw new Error('Invalid CRX key')
+ }
+
+ extensionId = declaredCrxId
+ publicKey = keyProof.public_key
+ }
+
+ return {
+ extensionId,
+ version,
+ header,
+ contents,
+ publicKey,
+ }
+}
+
+// Extract CRX contents and update manifest
+async function unpackCrx(crx: CrxInfo, destPath: string): Promise {
+ // Create zip file from contents
+ const zip = new AdmZip(crx.contents)
+
+ // Extract zip to destination
+ zip.extractAllTo(destPath, true)
+
+ // Read manifest.json
+ const manifestPath = path.join(destPath, 'manifest.json')
+ const manifestContent = await fs.promises.readFile(manifestPath, 'utf8')
+ const manifest = JSON.parse(manifestContent) as chrome.runtime.Manifest
+
+ // Add public key to manifest
+ manifest.key = crx.publicKey.toString('base64')
+
+ // Write updated manifest back
+ await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2))
+
+ return manifest
+}
+
+async function readCrx(crxPath: string) {
+ const crxBuffer = await fs.promises.readFile(crxPath)
+ return parseCrx(crxBuffer)
+}
+
+async function downloadCrx(url: string, dest: string) {
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error('Failed to download extension')
+ }
+
+ const fileStream = fs.createWriteStream(dest)
+ const downloadStream = Readable.fromWeb(response.body as any)
+ await pipeline(downloadStream, fileStream)
+}
+
+export async function downloadExtensionFromURL(
+ url: string,
+ extensionsDir: string,
+ expectedExtensionId?: string,
+): Promise {
+ d('downloading %s', url)
+
+ const installUuid = crypto.randomUUID()
+ const crxPath = path.join(os.tmpdir(), `electron-cws-download_${installUuid}.crx`)
+ try {
+ await downloadCrx(url, crxPath)
+
+ const crx = await readCrx(crxPath)
+
+ if (expectedExtensionId && expectedExtensionId !== crx.extensionId) {
+ throw new Error(
+ `CRX mismatches expected extension ID: ${expectedExtensionId} !== ${crx.extensionId}`,
+ )
+ }
+
+ const unpackedPath = path.join(extensionsDir, crx.extensionId, installUuid)
+ await fs.promises.mkdir(unpackedPath, { recursive: true })
+ const manifest = await unpackCrx(crx, unpackedPath)
+
+ if (!manifest.version) {
+ throw new Error('Installed extension is missing manifest version')
+ }
+
+ const versionedPath = path.join(extensionsDir, crx.extensionId, `${manifest.version}_0`)
+ await fs.promises.rename(unpackedPath, versionedPath)
+
+ return versionedPath
+ } finally {
+ await fs.promises.rm(crxPath, { force: true })
+ }
+}
+
+/**
+ * Download and unpack extension to the given extensions directory.
+ */
+export async function downloadExtension(
+ extensionId: string,
+ extensionsDir: string,
+): Promise {
+ const url = getExtensionCrxURL(extensionId)
+ return await downloadExtensionFromURL(url, extensionsDir, extensionId)
+}
+
+interface CommonExtensionOptions {
+ /** Session to load extensions into. */
+ session?: Electron.Session
+
+ /**
+ * Directory where web store extensions will be installed.
+ * Defaults to `Extensions` under the app's `userData` directory.
+ */
+ extensionsPath?: string
+}
+
+interface InstallExtensionOptions extends CommonExtensionOptions {
+ /** Options for loading the extension. */
+ loadExtensionOptions?: Electron.LoadExtensionOptions
+}
+
+interface UninstallExtensionOptions extends CommonExtensionOptions {}
+
+/**
+ * Install extension from the web store.
+ */
+export async function installExtension(
+ extensionId: string,
+ opts: InstallExtensionOptions = {},
+): Promise {
+ d('installing %s', extensionId)
+
+ const session = opts.session || electronSession.defaultSession
+ const sessionExtensions = session.extensions || session
+ const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
+
+ // Check if already loaded
+ const existingExtension = sessionExtensions.getExtension(extensionId)
+ if (existingExtension) {
+ d('%s already loaded', extensionId)
+ return existingExtension
+ }
+
+ // Check if already installed
+ const existingExtensionInfo = await findExtensionInstall(extensionId, extensionsPath)
+ if (existingExtensionInfo && existingExtensionInfo.type === 'store') {
+ d('%s already installed', extensionId)
+ return await sessionExtensions.loadExtension(
+ existingExtensionInfo.path,
+ opts.loadExtensionOptions,
+ )
+ }
+
+ // Download and load new extension
+ const extensionPath = await downloadExtension(extensionId, extensionsPath)
+ const extension = await sessionExtensions.loadExtension(extensionPath, opts.loadExtensionOptions)
+ d('installed %s', extensionId)
+
+ return extension
+}
+
+/**
+ * Uninstall extension from the web store.
+ */
+export async function uninstallExtension(
+ extensionId: string,
+ opts: UninstallExtensionOptions = {},
+) {
+ d('uninstalling %s', extensionId)
+
+ const session = opts.session || electronSession.defaultSession
+ const sessionExtensions = session.extensions || session
+ const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath()
+
+ const extensions = sessionExtensions.getAllExtensions()
+ const existingExt = extensions.find((ext) => ext.id === extensionId)
+ if (existingExt) {
+ sessionExtensions.removeExtension(extensionId)
+ }
+
+ const extensionDir = path.join(extensionsPath, extensionId)
+ try {
+ const stat = await fs.promises.stat(extensionDir)
+ if (stat.isDirectory()) {
+ await fs.promises.rm(extensionDir, { recursive: true, force: true })
+ }
+ } catch (error: any) {
+ if (error?.code !== 'ENOENT') {
+ throw error
+ }
+ }
+}
diff --git a/packages/electron-chrome-web-store/src/browser/loader.ts b/packages/electron-chrome-web-store/src/browser/loader.ts
new file mode 100644
index 0000000..ac7fa3f
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/loader.ts
@@ -0,0 +1,172 @@
+import * as fs from 'node:fs'
+import * as path from 'node:path'
+import debug from 'debug'
+
+import { generateId } from './id'
+import { compareVersions } from './utils'
+import { ExtensionId } from './types'
+
+const d = debug('electron-chrome-web-store:loader')
+
+type ExtensionPathBaseInfo = { manifest: chrome.runtime.Manifest; path: string }
+type ExtensionPathInfo =
+ | ({ type: 'store'; id: string } & ExtensionPathBaseInfo)
+ | ({ type: 'unpacked' } & ExtensionPathBaseInfo)
+
+const manifestExists = async (dirPath: string) => {
+ if (!dirPath) return false
+ const manifestPath = path.join(dirPath, 'manifest.json')
+ try {
+ return (await fs.promises.stat(manifestPath)).isFile()
+ } catch {
+ return false
+ }
+}
+
+/**
+ * DFS directories for extension manifests.
+ */
+async function extensionSearch(dirPath: string, depth: number = 0): Promise {
+ if (depth >= 2) return []
+ const results = []
+ const dirEntries = await fs.promises.readdir(dirPath, { withFileTypes: true })
+ for (const entry of dirEntries) {
+ if (entry.isDirectory()) {
+ if (await manifestExists(path.join(dirPath, entry.name))) {
+ results.push(path.join(dirPath, entry.name))
+ } else {
+ results.push(...(await extensionSearch(path.join(dirPath, entry.name), depth + 1)))
+ }
+ }
+ }
+ return results
+}
+
+/**
+ * Discover list of extensions in the given path.
+ */
+async function discoverExtensions(extensionsPath: string): Promise {
+ try {
+ const stat = await fs.promises.stat(extensionsPath)
+ if (!stat.isDirectory()) {
+ d('%s is not a directory', extensionsPath)
+ return []
+ }
+ } catch {
+ d('%s does not exist', extensionsPath)
+ return []
+ }
+
+ const extensionDirectories = await extensionSearch(extensionsPath)
+ const results: ExtensionPathInfo[] = []
+
+ for (const extPath of extensionDirectories.filter(Boolean)) {
+ try {
+ const manifestPath = path.join(extPath!, 'manifest.json')
+ const manifestJson = (await fs.promises.readFile(manifestPath)).toString()
+ const manifest: chrome.runtime.Manifest = JSON.parse(manifestJson)
+ const result = manifest.key
+ ? {
+ type: 'store' as const,
+ path: extPath!,
+ manifest,
+ id: generateId(manifest.key),
+ }
+ : {
+ type: 'unpacked' as const,
+ path: extPath!,
+ manifest,
+ }
+ results.push(result)
+ } catch (e) {
+ console.error(e)
+ }
+ }
+
+ return results
+}
+
+/**
+ * Filter any outdated extensions in the case of duplicate installations.
+ */
+function filterOutdatedExtensions(extensions: ExtensionPathInfo[]): ExtensionPathInfo[] {
+ const uniqueExtensions: ExtensionPathInfo[] = []
+ const storeExtMap = new Map()
+
+ for (const ext of extensions) {
+ if (ext.type === 'unpacked') {
+ // Unpacked extensions are always unique to their path
+ uniqueExtensions.push(ext)
+ } else if (!storeExtMap.has(ext.id)) {
+ // New store extension
+ storeExtMap.set(ext.id, ext)
+ } else {
+ // Existing store extension, compare with existing version
+ const latestExt = storeExtMap.get(ext.id)!
+ if (compareVersions(latestExt.manifest.version, ext.manifest.version) < 0) {
+ storeExtMap.set(ext.id, ext)
+ }
+ }
+ }
+
+ // Append up to date store extensions
+ storeExtMap.forEach((ext) => uniqueExtensions.push(ext))
+
+ return uniqueExtensions
+}
+
+/**
+ * Load all extensions from the given directory.
+ */
+export async function loadAllExtensions(
+ session: Electron.Session,
+ extensionsPath: string,
+ options: {
+ allowUnpacked?: boolean
+ } = {},
+) {
+ const sessionExtensions = session.extensions || session
+
+ let extensions = await discoverExtensions(extensionsPath)
+ extensions = filterOutdatedExtensions(extensions)
+ d('discovered %d extension(s) in %s', extensions.length, extensionsPath)
+
+ for (const ext of extensions) {
+ try {
+ let extension: Electron.Extension | undefined
+ if (ext.type === 'store') {
+ const existingExt = sessionExtensions.getExtension(ext.id)
+ if (existingExt) {
+ d('skipping loading existing extension %s', ext.id)
+ continue
+ }
+ d('loading extension %s', `${ext.id}@${ext.manifest.version}`)
+ extension = await sessionExtensions.loadExtension(ext.path)
+ } else if (options.allowUnpacked) {
+ d('loading unpacked extension %s', ext.path)
+ extension = await sessionExtensions.loadExtension(ext.path)
+ }
+
+ if (
+ extension &&
+ extension.manifest.manifest_version === 3 &&
+ extension.manifest.background?.service_worker
+ ) {
+ const scope = `chrome-extension://${extension.id}`
+ await session.serviceWorkers.startWorkerForScope(scope).catch(() => {
+ console.error(`Failed to start worker for extension ${extension.id}`)
+ })
+ }
+ } catch (error) {
+ console.error(`Failed to load extension from ${ext.path}`)
+ console.error(error)
+ }
+ }
+}
+
+export async function findExtensionInstall(extensionId: string, extensionsPath: string) {
+ const extensionPath = path.join(extensionsPath, extensionId)
+ let extensions = await discoverExtensions(extensionPath)
+ extensions = filterOutdatedExtensions(extensions)
+ return extensions.length > 0 ? extensions[0] : null
+}
diff --git a/packages/electron-chrome-web-store/src/browser/types.ts b/packages/electron-chrome-web-store/src/browser/types.ts
new file mode 100644
index 0000000..2667fbb
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/types.ts
@@ -0,0 +1,24 @@
+export type ExtensionId = Electron.Extension['id']
+
+export interface ExtensionInstallDetails {
+ id: string
+ localizedName: string
+ manifest: chrome.runtime.Manifest
+ icon: Electron.NativeImage
+ browserWindow?: Electron.BrowserWindow
+ frame: Electron.WebFrameMain
+}
+
+export type BeforeInstall = (
+ details: ExtensionInstallDetails,
+) => Promise<{ action: 'allow' | 'deny' }>
+
+export interface WebStoreState {
+ session: Electron.Session
+ extensionsPath: string
+ installing: Set
+ allowlist?: Set
+ denylist?: Set
+ minimumManifestVersion: number
+ beforeInstall?: BeforeInstall
+}
diff --git a/packages/electron-chrome-web-store/src/browser/updater.ts b/packages/electron-chrome-web-store/src/browser/updater.ts
new file mode 100644
index 0000000..b53e4a4
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/updater.ts
@@ -0,0 +1,327 @@
+import * as fs from 'node:fs'
+import * as path from 'node:path'
+import debug from 'debug'
+import { app, powerMonitor, session as electronSession } from 'electron'
+
+import { compareVersions, fetch, getChromeVersion } from './utils'
+import { downloadExtensionFromURL } from './installer'
+import { WebStoreState } from './types'
+
+const d = debug('electron-chrome-web-store:updater')
+
+interface OmahaResponseBody {
+ response: {
+ server: string
+ protocol: string
+ daystart: {
+ elapsed_seconds: number
+ elapsed_days: number
+ }
+ app: Array<{
+ appid: string
+ cohort: string
+ status: string
+ cohortname: string
+ updatecheck: {
+ _esbAllowlist: string
+ status:
+ | 'ok'
+ | 'noupdate'
+ | 'error-internal'
+ | 'error-hash'
+ | 'error-osnotsupported'
+ | 'error-hwnotsupported'
+ | 'error-unsupportedprotocol'
+ urls?: {
+ url: Array<{
+ codebase: string
+ }>
+ }
+ manifest?: {
+ version: string
+ packages: {
+ package: Array<{
+ hash_sha256: string
+ size: number
+ name: string
+ fp: string
+ required: boolean
+ }>
+ }
+ }
+ }
+ }>
+ }
+}
+
+type ExtensionUpdate = {
+ extension: Electron.Extension
+ id: string
+ name: string
+ version: string
+ url: string
+}
+
+const SYSTEM_IDLE_DURATION = 1 * 60 * 60 * 1000 // 1 hour
+const UPDATE_CHECK_INTERVAL = 5 * 60 * 60 * 1000 // 5 hours
+const MIN_UPDATE_INTERVAL = 3 * 60 * 60 * 1000 // 3 hours
+
+/** Time of last update check */
+let lastUpdateCheck: number | undefined
+
+/**
+ * Updates are limited to certain URLs for the initial implementation.
+ */
+const ALLOWED_UPDATE_URLS = new Set(['https://clients2.google.com/service/update2/crx'])
+
+const getSessionId = (() => {
+ let sessionId: string
+ return () => sessionId || (sessionId = crypto.randomUUID())
+})()
+
+const getOmahaPlatform = (): string => {
+ switch (process.platform) {
+ case 'win32':
+ return 'win'
+ case 'darwin':
+ return 'mac'
+ default:
+ return process.platform
+ }
+}
+
+const getOmahaArch = (): string => {
+ switch (process.arch) {
+ case 'ia32':
+ return 'x86'
+ case 'x64':
+ return 'x64'
+ default:
+ return process.arch
+ }
+}
+
+function filterWebStoreExtension(extension: Electron.Extension) {
+ const manifest = extension.manifest as chrome.runtime.Manifest
+ if (!manifest) return false
+ // TODO: implement extension.isFromStore() to check creation flags
+ return manifest.key && manifest.update_url && ALLOWED_UPDATE_URLS.has(manifest.update_url)
+}
+
+async function fetchAvailableUpdates(extensions: Electron.Extension[]): Promise {
+ if (extensions.length === 0) return []
+
+ const extensionIds = extensions.map((extension) => extension.id)
+ const extensionMap: Record = extensions.reduce(
+ (map, ext) => ({
+ ...map,
+ [ext.id]: ext,
+ }),
+ {},
+ )
+
+ const chromeVersion = getChromeVersion()
+ const url = 'https://update.googleapis.com/service/update2/json'
+
+ // Chrome's extension updater uses its Omaha Protocol.
+ // https://chromium.googlesource.com/chromium/src/+/main/docs/updater/protocol_3_1.md
+ const body = {
+ request: {
+ '@updater': 'electron-chrome-web-store',
+ acceptformat: 'crx3',
+ app: [
+ ...extensions.map((extension) => ({
+ appid: extension.id,
+ updatecheck: {},
+ // API always reports 'noupdate' when version is set :thinking:
+ // version: extension.version,
+ })),
+ ],
+ os: {
+ platform: getOmahaPlatform(),
+ arch: getOmahaArch(),
+ },
+ prodversion: chromeVersion,
+ protocol: '3.1',
+ requestid: crypto.randomUUID(),
+ sessionid: getSessionId(),
+ testsource: process.env.NODE_ENV === 'production' ? '' : 'electron_dev',
+ },
+ }
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'X-Goog-Update-Interactivity': 'bg',
+ 'X-Goog-Update-AppId': extensionIds.join(','),
+ 'X-Goog-Update-Updater': `chromiumcrx-${chromeVersion}`,
+ },
+ body: JSON.stringify(body),
+ })
+
+ if (!response.ok) {
+ d('update response not ok')
+ return []
+ }
+
+ // Skip safe JSON prefix
+ const text = await response.text()
+ const prefix = `)]}'\n`
+ if (!text.startsWith(prefix)) {
+ d('unexpected update response: %s', text)
+ return []
+ }
+
+ const json = text.substring(prefix.length)
+ const result: OmahaResponseBody = JSON.parse(json)
+
+ let updates: ExtensionUpdate[]
+ try {
+ const apps = result?.response?.app || []
+ updates = apps
+ // Find extensions with update
+ .filter((app) => app.updatecheck.status === 'ok')
+ // Collect info
+ .map((app) => {
+ const extensionId = app.appid
+ const extension = extensionMap[extensionId]
+ const manifest = app.updatecheck.manifest!
+ const pkg = manifest!.packages.package[0]
+ return {
+ extension,
+ id: extensionId,
+ version: manifest.version,
+ name: pkg.name,
+ url: app.updatecheck.urls!.url[0].codebase,
+ }
+ })
+ // Remove extensions without newer version
+ .filter((update) => {
+ const extension = extensionMap[update.id]
+ return compareVersions(extension.version, update.version) < 0
+ })
+ } catch (error) {
+ console.error('Unable to read extension updates response', error)
+ return []
+ }
+
+ return updates
+}
+
+async function updateExtension(session: Electron.Session, update: ExtensionUpdate) {
+ const sessionExtensions = session.extensions || session
+ const extensionId = update.id
+ const oldExtension = update.extension
+ d('updating %s %s -> %s', extensionId, oldExtension.version, update.version)
+
+ // Updates must be installed in adjacent directories. Ensure the old install
+ // was contained in a versioned directory structure.
+ const oldVersionDirectoryName = path.basename(oldExtension.path)
+ if (!oldVersionDirectoryName.startsWith(oldExtension.version)) {
+ console.error(
+ `updateExtension: extension ${extensionId} must conform to versioned directory names`,
+ {
+ oldPath: oldExtension.path,
+ },
+ )
+ d('skipping %s update due to invalid install path %s', extensionId, oldExtension.path)
+ return
+ }
+
+ // Download update
+ const extensionsPath = path.join(oldExtension.path, '..', '..')
+ const updatePath = await downloadExtensionFromURL(update.url, extensionsPath, extensionId)
+ d('downloaded update %s@%s', extensionId, update.version)
+
+ // Reload extension if already loaded
+ if (sessionExtensions.getExtension(extensionId)) {
+ sessionExtensions.removeExtension(extensionId)
+ await sessionExtensions.loadExtension(updatePath)
+ d('loaded update %s@%s', extensionId, update.version)
+ }
+
+ // Remove old version
+ await fs.promises.rm(oldExtension.path, { recursive: true, force: true })
+}
+
+async function checkForUpdates(session: Electron.Session) {
+ // Only check for extensions from the store
+ const sessionExtensions = session.extensions || session
+ const extensions = sessionExtensions.getAllExtensions().filter(filterWebStoreExtension)
+ d('checking for updates: %s', extensions.map((ext) => `${ext.id}@${ext.version}`).join(','))
+
+ const updates = await fetchAvailableUpdates(extensions)
+ if (!updates || updates.length === 0) {
+ d('no updates found')
+ return []
+ }
+
+ return updates
+}
+
+async function installUpdates(session: Electron.Session, updates: ExtensionUpdate[]) {
+ d('updating %d extension(s)', updates.length)
+ for (const update of updates) {
+ try {
+ await updateExtension(session, update)
+ } catch (error) {
+ console.error(`checkForUpdates: Error updating extension ${update.id}`)
+ console.error(error)
+ }
+ }
+}
+
+/**
+ * Check session's loaded extensions for updates and install any if available.
+ */
+export async function updateExtensions(
+ session: Electron.Session = electronSession.defaultSession,
+): Promise {
+ const updates = await checkForUpdates(session)
+ if (updates.length > 0) {
+ await installUpdates(session, updates)
+ }
+}
+
+async function maybeCheckForUpdates(session: Electron.Session) {
+ const idleState = powerMonitor.getSystemIdleState(SYSTEM_IDLE_DURATION)
+ if (idleState !== 'active') {
+ d('skipping update check while system is in "%s" idle state', idleState)
+ return
+ }
+
+ // Determine if enough time has passed to check updates
+ if (lastUpdateCheck && Date.now() - lastUpdateCheck < MIN_UPDATE_INTERVAL) {
+ return
+ }
+ lastUpdateCheck = Date.now()
+
+ void updateExtensions(session)
+}
+
+export async function initUpdater(state: WebStoreState) {
+ const check = () => maybeCheckForUpdates(state.session)
+
+ switch (process.platform) {
+ case 'darwin':
+ app.on('did-become-active', check)
+ break
+ case 'win32':
+ case 'linux':
+ app.on('browser-window-focus', check)
+ break
+ }
+
+ const updateIntervalId = setInterval(check, UPDATE_CHECK_INTERVAL)
+ check()
+
+ app.on('before-quit', (event) => {
+ queueMicrotask(() => {
+ if (!event.defaultPrevented) {
+ d('stopping update checks')
+ clearInterval(updateIntervalId)
+ }
+ })
+ })
+}
diff --git a/packages/electron-chrome-web-store/src/browser/utils.ts b/packages/electron-chrome-web-store/src/browser/utils.ts
new file mode 100644
index 0000000..9705c62
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/browser/utils.ts
@@ -0,0 +1,28 @@
+import * as path from 'node:path'
+import { app, net } from 'electron'
+
+// Include fallbacks for node environments that aren't Electron
+export const fetch =
+ // Prefer Node's fetch until net.fetch crash is fixed
+ // https://github.com/electron/electron/pull/45050
+ globalThis.fetch ||
+ net?.fetch ||
+ (() => {
+ throw new Error(
+ 'electron-chrome-web-store: Missing fetch API. Please upgrade Electron or Node.',
+ )
+ })
+export const getChromeVersion = () => process.versions.chrome || '131.0.6778.109'
+
+export function compareVersions(version1: string, version2: string) {
+ const v1 = version1.split('.').map(Number)
+ const v2 = version2.split('.').map(Number)
+
+ for (let i = 0; i < 3; i++) {
+ if (v1[i] > v2[i]) return 1
+ if (v1[i] < v2[i]) return -1
+ }
+ return 0
+}
+
+export const getDefaultExtensionsPath = () => path.join(app.getPath('userData'), 'Extensions')
diff --git a/packages/electron-chrome-web-store/src/common/constants.ts b/packages/electron-chrome-web-store/src/common/constants.ts
new file mode 100644
index 0000000..bda7ab0
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/common/constants.ts
@@ -0,0 +1,47 @@
+export const ExtensionInstallStatus = {
+ BLACKLISTED: 'blacklisted',
+ BLOCKED_BY_POLICY: 'blocked_by_policy',
+ CAN_REQUEST: 'can_request',
+ CORRUPTED: 'corrupted',
+ CUSTODIAN_APPROVAL_REQUIRED: 'custodian_approval_required',
+ CUSTODIAN_APPROVAL_REQUIRED_FOR_INSTALLATION: 'custodian_approval_required_for_installation',
+ DEPRECATED_MANIFEST_VERSION: 'deprecated_manifest_version',
+ DISABLED: 'disabled',
+ ENABLED: 'enabled',
+ FORCE_INSTALLED: 'force_installed',
+ INSTALLABLE: 'installable',
+ REQUEST_PENDING: 'request_pending',
+ TERMINATED: 'terminated',
+}
+
+export const MV2DeprecationStatus = {
+ INACTIVE: 'inactive',
+ SOFT_DISABLE: 'soft_disable',
+ WARNING: 'warning',
+}
+
+export const Result = {
+ ALREADY_INSTALLED: 'already_installed',
+ BLACKLISTED: 'blacklisted',
+ BLOCKED_BY_POLICY: 'blocked_by_policy',
+ BLOCKED_FOR_CHILD_ACCOUNT: 'blocked_for_child_account',
+ FEATURE_DISABLED: 'feature_disabled',
+ ICON_ERROR: 'icon_error',
+ INSTALL_ERROR: 'install_error',
+ INSTALL_IN_PROGRESS: 'install_in_progress',
+ INVALID_ICON_URL: 'invalid_icon_url',
+ INVALID_ID: 'invalid_id',
+ LAUNCH_IN_PROGRESS: 'launch_in_progress',
+ MANIFEST_ERROR: 'manifest_error',
+ MISSING_DEPENDENCIES: 'missing_dependencies',
+ SUCCESS: 'success',
+ UNKNOWN_ERROR: 'unknown_error',
+ UNSUPPORTED_EXTENSION_TYPE: 'unsupported_extension_type',
+ USER_CANCELLED: 'user_cancelled',
+ USER_GESTURE_REQUIRED: 'user_gesture_required',
+}
+
+export const WebGlStatus = {
+ WEBGL_ALLOWED: 'webgl_allowed',
+ WEBGL_BLOCKED: 'webgl_blocked',
+}
diff --git a/packages/electron-chrome-web-store/src/renderer/chrome-web-store.preload.ts b/packages/electron-chrome-web-store/src/renderer/chrome-web-store.preload.ts
new file mode 100644
index 0000000..8081a5b
--- /dev/null
+++ b/packages/electron-chrome-web-store/src/renderer/chrome-web-store.preload.ts
@@ -0,0 +1,326 @@
+import { contextBridge, ipcRenderer, webFrame } from 'electron'
+import {
+ ExtensionInstallStatus,
+ MV2DeprecationStatus,
+ Result,
+ WebGlStatus,
+} from '../common/constants'
+
+interface WebstorePrivate {
+ ExtensionInstallStatus: typeof ExtensionInstallStatus
+ MV2DeprecationStatus: typeof MV2DeprecationStatus
+ Result: typeof Result
+ WebGlStatus: typeof WebGlStatus
+
+ beginInstallWithManifest3: (
+ details: unknown,
+ callback?: (result: string) => void,
+ ) => Promise
+ completeInstall: (id: string, callback?: (result: string) => void) => Promise
+ enableAppLauncher: (enable: boolean, callback?: (result: boolean) => void) => Promise
+ getBrowserLogin: (callback?: (result: string) => void) => Promise
+ getExtensionStatus: (
+ id: string,
+ manifestJson: string,
+ callback?: (status: string) => void,
+ ) => Promise
+ getFullChromeVersion: (callback?: (result: string) => void) => Promise<{
+ version_number: string
+ app_name: string
+ }>
+ getIsLauncherEnabled: (callback?: (result: boolean) => void) => Promise
+ getMV2DeprecationStatus: (callback?: (result: string) => void) => Promise
+ getReferrerChain: (callback?: (result: unknown[]) => void) => Promise
+ getStoreLogin: (callback?: (result: string) => void) => Promise
+ getWebGLStatus: (callback?: (result: string) => void) => Promise
+ install: (
+ id: string,
+ silentInstall: boolean,
+ callback?: (result: string) => void,
+ ) => Promise
+ isInIncognitoMode: (callback?: (result: boolean) => void) => Promise
+ isPendingCustodianApproval: (id: string, callback?: (result: boolean) => void) => Promise
+ setStoreLogin: (login: string, callback?: (result: boolean) => void) => Promise
+}
+
+function updateBranding(appName: string) {
+ const update = () => {
+ requestAnimationFrame(() => {
+ const chromeButtons = Array.from(document.querySelectorAll('span')).filter((node) =>
+ node.innerText.includes('Chrome'),
+ )
+
+ for (const button of chromeButtons) {
+ button.innerText = button.innerText.replace('Chrome', appName)
+ }
+ })
+ }
+
+ // Try twice to ensure branding changes
+ update()
+ setTimeout(update, 1000 / 60)
+}
+
+function getUAProductVersion(userAgent: string, product: string) {
+ const regex = new RegExp(`${product}/([\\d.]+)`)
+ return userAgent.match(regex)?.[1]
+}
+
+function overrideUserAgent() {
+ const chromeVersion = getUAProductVersion(navigator.userAgent, 'Chrome') || '133.0.6920.0'
+ const userAgent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`
+ webFrame.executeJavaScript(
+ `(${function (userAgent: string) {
+ Object.defineProperty(navigator, 'userAgent', { value: userAgent })
+ }})(${JSON.stringify(userAgent)});`,
+ )
+}
+
+const DEBUG = process.env.NODE_ENV === 'development'
+
+function log(...args: any[]) {
+ if (!DEBUG) return
+ console.debug(...args)
+}
+
+function setupChromeWebStoreApi() {
+ let appName: string | undefined
+
+ const setAppName = (name: string) => {
+ appName = name
+ updateBranding(appName)
+ }
+
+ const maybeUpdateBranding = () => {
+ if (appName) updateBranding(appName)
+ }
+
+ const setExtensionError = (message?: string) => {
+ webFrame.executeJavaScript(`
+ if (typeof chrome !== 'undefined') {
+ if (!chrome.extension) chrome.extension = {};
+ chrome.extension.lastError = ${JSON.stringify(message ? { message } : null)};
+ }
+ `)
+ }
+
+ /**
+ * Implementation of Chrome's webstorePrivate for Electron.
+ */
+ const electronWebstore: WebstorePrivate = {
+ ExtensionInstallStatus,
+ MV2DeprecationStatus,
+ Result,
+ WebGlStatus,
+
+ beginInstallWithManifest3: async (details, callback) => {
+ log('webstorePrivate.beginInstallWithManifest3', details)
+ const { result, message } = await ipcRenderer.invoke('chromeWebstore.beginInstall', details)
+ log('webstorePrivate.beginInstallWithManifest3 result:', result)
+ setExtensionError(result === Result.SUCCESS ? null : message)
+ if (callback) callback(result)
+ return result
+ },
+
+ completeInstall: async (id, callback) => {
+ log('webstorePrivate.completeInstall', id)
+ const result = await ipcRenderer.invoke('chromeWebstore.completeInstall', id)
+ log('webstorePrivate.completeInstall result:', result)
+ if (callback) callback(result)
+ maybeUpdateBranding()
+ return result
+ },
+
+ enableAppLauncher: async (enable, callback) => {
+ log('webstorePrivate.enableAppLauncher', enable)
+ const result = await ipcRenderer.invoke('chromeWebstore.enableAppLauncher', enable)
+ log('webstorePrivate.enableAppLauncher result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ getBrowserLogin: async (callback) => {
+ log('webstorePrivate.getBrowserLogin called')
+ const result = await ipcRenderer.invoke('chromeWebstore.getBrowserLogin')
+ log('webstorePrivate.getBrowserLogin result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ getExtensionStatus: async (id, manifestJson, callback) => {
+ log('webstorePrivate.getExtensionStatus', id, { id, manifestJson, callback })
+ const result = await ipcRenderer.invoke('chromeWebstore.getExtensionStatus', id, manifestJson)
+ log('webstorePrivate.getExtensionStatus result:', id, result)
+ if (callback) callback(result)
+ maybeUpdateBranding()
+ return result
+ },
+
+ getFullChromeVersion: async (callback) => {
+ log('webstorePrivate.getFullChromeVersion called')
+ const result = await ipcRenderer.invoke('chromeWebstore.getFullChromeVersion')
+ log('webstorePrivate.getFullChromeVersion result:', result)
+
+ if (result.app_name) {
+ setAppName(result.app_name)
+ delete result.app_name
+ }
+
+ if (callback) callback(result)
+ return result
+ },
+
+ getIsLauncherEnabled: async (callback) => {
+ log('webstorePrivate.getIsLauncherEnabled called')
+ const result = await ipcRenderer.invoke('chromeWebstore.getIsLauncherEnabled')
+ log('webstorePrivate.getIsLauncherEnabled result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ getMV2DeprecationStatus: async (callback) => {
+ log('webstorePrivate.getMV2DeprecationStatus called')
+ const result = await ipcRenderer.invoke('chromeWebstore.getMV2DeprecationStatus')
+ log('webstorePrivate.getMV2DeprecationStatus result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ getReferrerChain: async (callback) => {
+ log('webstorePrivate.getReferrerChain called')
+ const result = await ipcRenderer.invoke('chromeWebstore.getReferrerChain')
+ log('webstorePrivate.getReferrerChain result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ getStoreLogin: async (callback) => {
+ log('webstorePrivate.getStoreLogin called')
+ const result = await ipcRenderer.invoke('chromeWebstore.getStoreLogin')
+ log('webstorePrivate.getStoreLogin result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ getWebGLStatus: async (callback) => {
+ log('webstorePrivate.getWebGLStatus called')
+ const result = await ipcRenderer.invoke('chromeWebstore.getWebGLStatus')
+ log('webstorePrivate.getWebGLStatus result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ install: async (id, silentInstall, callback) => {
+ log('webstorePrivate.install', { id, silentInstall })
+ const result = await ipcRenderer.invoke('chromeWebstore.install', id, silentInstall)
+ log('webstorePrivate.install result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ isInIncognitoMode: async (callback) => {
+ log('webstorePrivate.isInIncognitoMode called')
+ const result = await ipcRenderer.invoke('chromeWebstore.isInIncognitoMode')
+ log('webstorePrivate.isInIncognitoMode result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ isPendingCustodianApproval: async (id, callback) => {
+ log('webstorePrivate.isPendingCustodianApproval', id)
+ const result = await ipcRenderer.invoke('chromeWebstore.isPendingCustodianApproval', id)
+ log('webstorePrivate.isPendingCustodianApproval result:', result)
+ if (callback) callback(result)
+ return result
+ },
+
+ setStoreLogin: async (login, callback) => {
+ log('webstorePrivate.setStoreLogin', login)
+ const result = await ipcRenderer.invoke('chromeWebstore.setStoreLogin', login)
+ log('webstorePrivate.setStoreLogin result:', result)
+ if (callback) callback(result)
+ return result
+ },
+ }
+
+ // Expose webstorePrivate API
+ contextBridge.exposeInMainWorld('electronWebstore', electronWebstore)
+
+ // Expose chrome.runtime and chrome.management APIs
+ const runtime = {
+ lastError: null,
+ getManifest: async () => {
+ log('chrome.runtime.getManifest called')
+ return {}
+ },
+ }
+ contextBridge.exposeInMainWorld('electronRuntime', runtime)
+
+ const management = {
+ onInstalled: {
+ addListener: (callback: () => void) => {
+ log('chrome.management.onInstalled.addListener called')
+ ipcRenderer.on('chrome.management.onInstalled', callback)
+ },
+ removeListener: (callback: () => void) => {
+ log('chrome.management.onInstalled.removeListener called')
+ ipcRenderer.removeListener('chrome.management.onInstalled', callback)
+ },
+ },
+ onUninstalled: {
+ addListener: (callback: () => void) => {
+ log('chrome.management.onUninstalled.addListener called')
+ ipcRenderer.on('chrome.management.onUninstalled', callback)
+ },
+ removeListener: (callback: () => void) => {
+ log('chrome.management.onUninstalled.removeListener called')
+ ipcRenderer.removeListener('chrome.management.onUninstalled', callback)
+ },
+ },
+ getAll: (callback: (extensions: any[]) => void) => {
+ log('chrome.management.getAll called')
+ ipcRenderer.invoke('chrome.management.getAll').then((result) => {
+ log('chrome.management.getAll result:', result)
+ callback(result)
+ })
+ },
+ setEnabled: async (id: string, enabled: boolean) => {
+ log('chrome.management.setEnabled', { id, enabled })
+ const result = await ipcRenderer.invoke('chrome.management.setEnabled', id, enabled)
+ log('chrome.management.setEnabled result:', result)
+ return result
+ },
+ uninstall: (id: string, options: { showConfirmDialog: boolean }, callback?: () => void) => {
+ log('chrome.management.uninstall', { id, options })
+ ipcRenderer.invoke('chrome.management.uninstall', id, options).then((result) => {
+ log('chrome.management.uninstall result:', result)
+ if (callback) callback()
+ })
+ },
+ }
+ contextBridge.exposeInMainWorld('electronManagement', management)
+
+ webFrame.executeJavaScript(`
+ (function () {
+ chrome.webstorePrivate = globalThis.electronWebstore;
+ Object.assign(chrome.runtime, electronRuntime);
+ Object.assign(chrome.management, electronManagement);
+ void 0;
+ }());
+ `)
+
+ // Fetch app name
+ electronWebstore.getFullChromeVersion()
+
+ // Replace branding
+ overrideUserAgent()
+ process.once('document-start', maybeUpdateBranding)
+ if ('navigation' in window) {
+ ;(window.navigation as any).addEventListener('navigate', maybeUpdateBranding)
+ }
+}
+
+if (location.href.startsWith('https://chromewebstore.google.com')) {
+ log('Injecting Chrome Web Store API')
+ setupChromeWebStoreApi()
+}
diff --git a/packages/electron-chrome-web-store/tsconfig.json b/packages/electron-chrome-web-store/tsconfig.json
new file mode 100644
index 0000000..01eddcb
--- /dev/null
+++ b/packages/electron-chrome-web-store/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+
+ "compilerOptions": {
+ "moduleResolution": "node",
+ "outDir": "dist/types",
+ "declaration": true,
+ "emitDeclarationOnly": true
+ },
+
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/scripts/clean.js b/scripts/clean.js
new file mode 100644
index 0000000..b3b5ae0
--- /dev/null
+++ b/scripts/clean.js
@@ -0,0 +1,21 @@
+const fs = require('fs')
+const path = require('path')
+
+function cleanDirectory(dirPath) {
+ const resolvedPath = path.resolve(dirPath)
+
+ const parentDir = path.basename(path.dirname(resolvedPath))
+ if (parentDir !== 'packages') {
+ console.error(`Error: Directory "${resolvedPath}" is not inside a "packages" folder`)
+ return
+ }
+
+ const distPath = path.join(resolvedPath, 'dist')
+
+ if (fs.existsSync(distPath)) {
+ fs.rmSync(distPath, { recursive: true, force: true })
+ console.log(`deleted: ${distPath}`)
+ }
+}
+
+cleanDirectory(process.cwd())
diff --git a/tsconfig.json b/tsconfig.base.json
similarity index 92%
rename from tsconfig.json
rename to tsconfig.base.json
index 1048075..5b93800 100644
--- a/tsconfig.json
+++ b/tsconfig.base.json
@@ -31,5 +31,5 @@
"./forge.config.ts",
"*.ts",
"vite.renderer.config.ts"
- ]
+, "packages/electron-chrome-context-menu", "packages/chrome-ui" ]
}