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() + 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() + + 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 + >() + + 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" ] }