├── .gitattributes ├── .github ├── FUNDING.yml ├── pull_request_template.md └── workflows │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── LICENSE ├── LICENSE-PATRON.md ├── README.md ├── build ├── esbuild │ └── esbuild.config.base.js └── webpack │ └── webpack.config.base.js ├── extensions ├── .gitkeep └── README.md ├── package.json ├── packages ├── electron-chrome-context-menu │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── electron-chrome-extensions │ ├── .gitignore │ ├── .npmignore │ ├── CHANGELOG.md │ ├── LICENSE-GPL │ ├── LICENSE-PATRON.md │ ├── LICENSE.md │ ├── README.md │ ├── esbuild.config.js │ ├── package.json │ ├── screenshot-browser-action.png │ ├── screenshot-dark-reader.png │ ├── screenshot-ublock-origin.png │ ├── script │ │ ├── native-messaging-host │ │ │ ├── .gitignore │ │ │ ├── build.js │ │ │ ├── main.js │ │ │ └── sea-config.json │ │ └── spec-runner.js │ ├── spec │ │ ├── chrome-browserAction-spec.ts │ │ ├── chrome-contextMenus-spec.ts │ │ ├── chrome-nativeMessaging-spec.ts │ │ ├── chrome-notifications-spec.ts │ │ ├── chrome-tabs-spec.ts │ │ ├── chrome-webNavigation-spec.ts │ │ ├── chrome-windows-spec.ts │ │ ├── crx-helpers.ts │ │ ├── events-helpers.ts │ │ ├── extensions-spec.ts │ │ ├── fixtures │ │ │ ├── .gitignore │ │ │ ├── browser-action-list │ │ │ │ └── default.html │ │ │ ├── chrome-browserAction-click │ │ │ │ ├── background.js │ │ │ │ ├── content-script.js │ │ │ │ └── manifest.json │ │ │ ├── chrome-browserAction-popup │ │ │ │ ├── icon_16.png │ │ │ │ ├── icon_32.png │ │ │ │ ├── manifest.json │ │ │ │ └── popup.html │ │ │ ├── chrome-webNavigation │ │ │ │ ├── background.js │ │ │ │ ├── content-script.js │ │ │ │ └── manifest.json │ │ │ ├── crx-test-preload.ts │ │ │ └── rpc │ │ │ │ ├── background.js │ │ │ │ ├── content-script.js │ │ │ │ ├── icon_16.png │ │ │ │ ├── manifest.json │ │ │ │ └── popup.html │ │ ├── hooks.ts │ │ ├── index.js │ │ ├── spec-helpers.ts │ │ └── window-helpers.ts │ ├── src │ │ ├── browser-action.ts │ │ ├── browser │ │ │ ├── api │ │ │ │ ├── browser-action.ts │ │ │ │ ├── commands.ts │ │ │ │ ├── common.ts │ │ │ │ ├── context-menus.ts │ │ │ │ ├── cookies.ts │ │ │ │ ├── lib │ │ │ │ │ ├── native-messaging-host.ts │ │ │ │ │ └── winreg.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── permissions.ts │ │ │ │ ├── runtime.ts │ │ │ │ ├── tabs.ts │ │ │ │ ├── web-navigation.ts │ │ │ │ └── windows.ts │ │ │ ├── context.ts │ │ │ ├── deps.d.ts │ │ │ ├── impl.ts │ │ │ ├── index.ts │ │ │ ├── license.ts │ │ │ ├── manifest.ts │ │ │ ├── partition.ts │ │ │ ├── popup.ts │ │ │ ├── router.ts │ │ │ └── store.ts │ │ ├── index.ts │ │ ├── preload.ts │ │ └── renderer │ │ │ ├── event.ts │ │ │ └── index.ts │ └── tsconfig.json ├── electron-chrome-web-store │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── esbuild.config.js │ ├── package.json │ ├── src │ │ ├── browser │ │ │ ├── api.ts │ │ │ ├── crx3.proto │ │ │ ├── crx3.ts │ │ │ ├── deps.d.ts │ │ │ ├── id.ts │ │ │ ├── index.ts │ │ │ ├── installer.ts │ │ │ ├── loader.ts │ │ │ ├── types.ts │ │ │ ├── updater.ts │ │ │ └── utils.ts │ │ ├── common │ │ │ └── constants.ts │ │ └── renderer │ │ │ └── chrome-web-store.preload.ts │ └── tsconfig.json └── shell │ ├── .gitignore │ ├── README.md │ ├── browser │ ├── main.js │ ├── menu.js │ ├── tabs.js │ └── ui │ │ ├── manifest.json │ │ ├── new-tab.html │ │ ├── webui.html │ │ └── webui.js │ ├── forge.config.js │ ├── index.js │ ├── package.json │ ├── preload.ts │ ├── webpack.main.config.js │ └── webpack.renderer.config.js ├── screenshot.png ├── scripts ├── clean.js ├── find_chrome_apis.sh └── generate-hash.js ├── tsconfig.base.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.ts text 7 | *.js text -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [samuelmaddock] 4 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | 5 | 6 | 7 | ✅ By sending this pull request, I agree to the [Contributor License Agreement](https://github.com/samuelmaddock/electron-browser-shell#contributor-license-agreement) of this project. 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test electron-chrome-extensions 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'packages/**' 7 | pull_request: 8 | paths: 9 | - 'packages/**' 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest] 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - name: Checkout Repo 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: '20.x' 27 | 28 | - name: Install dependencies 29 | run: yarn install 30 | 31 | - name: Build 32 | run: yarn build 33 | 34 | - name: Run tests (Linux) 35 | if: runner.os == 'Linux' 36 | uses: GabrielBB/xvfb-action@v1 37 | with: 38 | run: yarn test 39 | 40 | - name: Run tests (Windows) 41 | if: runner.os == 'Windows' 42 | run: yarn test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | extensions/* 2 | !extensions/.gitkeep 3 | !extensions/README.md 4 | 5 | disabled/ 6 | node_modules 7 | 8 | noncompliant.txt 9 | yarn-error.log 10 | .DS_Store -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | esbuild.*.js 3 | *.log -------------------------------------------------------------------------------- /LICENSE-PATRON.md: -------------------------------------------------------------------------------- 1 | # Patron License 2 | 3 | Payment Platforms: 4 | 5 | - 6 | 7 | Participating Contributors: 8 | 9 | - Samuel Maddock 10 | 11 | ## Purpose 12 | 13 | This license gives everyone patronizing contributors to this software permission to ignore any noncommercial or copyleft rules of its free public license, while continuing to protect contributors from liability. 14 | 15 | ## Acceptance 16 | 17 | In order to agree to these terms and receive a license, you must qualify under [Patrons](#patrons). The rules of these terms are both obligations under your agreement and conditions to your license. That agreement and your license continue only while you qualify as a patron. You must not do anything with this software that triggers a rule that you cannot or will not follow. 18 | 19 | ## Patrons 20 | 21 | To accept these terms, you must be enrolled to make regular payments through any of the payment platforms pages listed above, in amounts qualifying you for a tier that includes a "patron license" or otherwise identifies a license under these terms as a reward. 22 | 23 | ## Scope 24 | 25 | Except under [Seat](#seat) and [Applications](#applications), you may not sublicense or transfer any agreement or license under these terms to anyone else. 26 | 27 | ## Seat 28 | 29 | If a legal entity, rather than an individual, accepts these terms, the entity may sublicense one individual employee or independent contractor at any given time. If the employee or contractor breaks any rule of these terms, the entity will stand directly responsible. 30 | 31 | ## Applications 32 | 33 | If you combine this software with other software in a larger application, you may sublicense this software as part of your larger application, and allow further sublicensing in turn, under these rules: 34 | 35 | 1. Your larger application must have significant additional content or functionality beyond that of this software, and end users must license your larger application primarily for that added content or functionality. 36 | 37 | 2. You may not sublicense anyone to break any rule of the public license for this software for any changes of their own or any software besides your larger application. 38 | 39 | 3. You may build, and sublicense for, as many larger applications as you like. 40 | 41 | ## Copyright 42 | 43 | Each contributor licenses you to do everything with this software that would otherwise infringe that contributor's copyright in it. 44 | 45 | ## Notices 46 | 47 | You must ensure that everyone who gets a copy of any part of this software from you, with or without changes, also gets the texts of both this license and the free public license for this software. 48 | 49 | ## Excuse 50 | 51 | If anyone notifies you in writing that you have not complied with [Notices](#notices), you can keep your agreement and your license by taking all practical steps to comply within 30 days after the notice. If you do not do so, your agreement under these terms ends immediately, and your license ends with it. 52 | 53 | ## Patent 54 | 55 | Each contributor licenses you to do everything with this software that would otherwise infringe any patent claims they can license or become able to license. 56 | 57 | ## Reliability 58 | 59 | No contributor can revoke this license, but your license may end if you break any rule of these terms. 60 | 61 | ## No Liability 62 | 63 | ***As far as the law allows, this software comes as is, without any warranty or condition, and no contributor will be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # electron-browser-shell 2 | 3 | A minimal, tabbed web browser with support for Chrome extensions—built on Electron. 4 | 5 |  6 | 7 | ## Packages 8 | 9 | | Name | Description | 10 | | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | 11 | | [shell](./packages/shell) | A minimal, tabbed web browser used as a testbed for development of Chrome extension support. | 12 | | [electron-chrome-extensions](./packages/electron-chrome-extensions) | Adds additional API support for Chrome extensions to Electron. | 13 | | [electron-chrome-context-menu](./packages/electron-chrome-context-menu) | Chrome context menu for Electron browsers. | 14 | | [electron-chrome-web-store](./packages/electron-chrome-web-store) | Download extensions from the Chrome Web Store in Electron. | 15 | 16 | ## Usage 17 | 18 | ```bash 19 | # Get the code 20 | git clone git@github.com:samuelmaddock/electron-browser-shell.git 21 | cd electron-browser-shell 22 | 23 | # Install and launch the browser 24 | yarn 25 | yarn start 26 | ``` 27 | 28 | ### Install extensions 29 | 30 | Navigate to the [Chrome Web Store](https://chromewebstore.google.com/) and install an extension. 31 | 32 | To test local unpacked extensions, include them in `./extensions` then launch the browser. 33 | 34 | ## Roadmap 35 | 36 | ### 🚀 Current 37 | 38 | - [x] Browser tabs 39 | - [x] Unpacked extension loader 40 | - [x] Initial [`chrome.tabs` extensions API](https://developer.chrome.com/extensions/tabs) 41 | - [x] Initial [extension popup](https://developer.chrome.com/extensions/browserAction) support 42 | - [x] .CRX extension loader 43 | - [x] [Chrome Web Store](https://chromewebstore.google.com) extension installer 44 | - [x] Automatic extension updates 45 | - [x] [Manifest V3](https://developer.chrome.com/docs/extensions/mv3/intro/) support—pending [electron/electron#44411](https://github.com/electron/electron/pull/44411) 46 | - [ ] Support for common [`chrome.*` extension APIs](https://developer.chrome.com/docs/extensions/reference/api) 47 | - [ ] Robust extension popup support 48 | - [ ] Respect extension manifest permissions 49 | 50 | ### 🤞 Eventually 51 | 52 | - [ ] Extension management (enable/disable/uninstall) 53 | - [ ] Installation prompt UX 54 | - [ ] [Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/Microsoft-Edge-Extensions-Home) extension installer 55 | - [ ] Full support of [`chrome.*` extension APIs](https://developer.chrome.com/docs/extensions/reference/api) 56 | 57 | ### 🤔 Considering 58 | 59 | - [ ] Opt-in support for custom `webRequest` blocking implementation 60 | - [ ] Browser tab discarding 61 | 62 | ### ❌ Not planned 63 | 64 | - [Chrome Platform App APIs](https://developer.chrome.com/docs/extensions/reference/#platform_apps_apis) 65 | 66 | ## License 67 | 68 | Most packages in this project use MIT with the exception of electron-chrome-extensions. 69 | 70 | For proprietary use, please [contact me](mailto:sam@samuelmaddock.com?subject=electron-browser-shell%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. 71 | 72 | ### Contributor license agreement 73 | 74 | By sending a pull request, you hereby grant to owners and users of the 75 | electron-browser-shell project a perpetual, worldwide, non-exclusive, 76 | no-charge, royalty-free, irrevocable copyright license to reproduce, prepare 77 | derivative works of, publicly display, publicly perform, sublicense, and 78 | distribute your contributions and such derivative works. 79 | 80 | The owners of the electron-browser-shell project will also be granted the right to relicense the 81 | contributed source code and its derivative works. 82 | -------------------------------------------------------------------------------- /build/esbuild/esbuild.config.base.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild') 2 | 3 | function createConfig(opts = {}) { 4 | const prod = process.env.NODE_ENV === 'production' 5 | const define = 6 | opts.format === 'esm' 7 | ? { 8 | ...opts.define, 9 | __dirname: 'import.meta.dirname', 10 | } 11 | : { 12 | ...opts.define, 13 | } 14 | return { 15 | bundle: true, 16 | platform: opts.platform || 'node', 17 | target: 'es2020', 18 | sourcemap: !prod, 19 | minify: false, 20 | external: [], 21 | loader: { 22 | '.ts': 'ts', 23 | '.tsx': 'tsx', 24 | '.css': 'css', 25 | }, 26 | ...opts, 27 | define, 28 | } 29 | } 30 | 31 | function build(config) { 32 | esbuild.build(config).catch(() => process.exit(1)) 33 | } 34 | 35 | const EXTERNAL_BASE = [ 36 | 'node:crypto', 37 | 'node:events', 38 | 'node:fs', 39 | 'node:module', 40 | 'node:os', 41 | 'node:path', 42 | 'node:stream', 43 | 'node:stream/promises', 44 | 'electron', 45 | 'debug', 46 | ] 47 | 48 | module.exports = { createConfig, build, EXTERNAL_BASE } 49 | -------------------------------------------------------------------------------- /build/webpack/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | const base = { 4 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', 5 | devtool: 'source-map', 6 | 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.tsx?$/, 11 | exclude: /node_modules/, 12 | use: { 13 | loader: 'babel-loader', 14 | options: { 15 | cacheDirectory: true, 16 | }, 17 | }, 18 | }, 19 | ], 20 | }, 21 | 22 | optimization: { 23 | moduleIds: 'named', 24 | }, 25 | 26 | resolve: { 27 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], 28 | modules: ['node_modules'], 29 | }, 30 | 31 | plugins: [ 32 | // new webpack.EnvironmentPlugin({ 33 | // NODE_ENV: 'production', 34 | // }), 35 | ], 36 | } 37 | 38 | module.exports = base 39 | -------------------------------------------------------------------------------- /extensions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/extensions/.gitkeep -------------------------------------------------------------------------------- /extensions/README.md: -------------------------------------------------------------------------------- 1 | Place unpacked extensions (not .crx archives) here to have them automatically loaded by the browser. 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-browser-shell", 3 | "version": "1.0.0", 4 | "description": "A minimal browser shell built on Electron.", 5 | "private": true, 6 | "workspaces": [ 7 | "packages/shell", 8 | "packages/electron-chrome-extensions", 9 | "packages/electron-chrome-context-menu", 10 | "packages/electron-chrome-web-store" 11 | ], 12 | "scripts": { 13 | "build": "yarn run build:context-menu && yarn run build:chrome-web-store && yarn run build:extensions && yarn run build:shell", 14 | "build:chrome-web-store": "yarn --cwd ./packages/electron-chrome-web-store build", 15 | "build:context-menu": "yarn --cwd ./packages/electron-chrome-context-menu build", 16 | "build:extensions": "yarn --cwd ./packages/electron-chrome-extensions build", 17 | "build:shell": "yarn --cwd ./packages/shell build", 18 | "start": "yarn build:context-menu && yarn build:extensions && yarn build:chrome-web-store && yarn --cwd ./packages/shell start", 19 | "start:debug": "cross-env DEBUG='electron*' yarn start", 20 | "start:electron-dev": "cross-env ELECTRON_OVERRIDE_DIST_PATH=$(e show out --path) ELECTRON_ENABLE_LOGGING=1 yarn start", 21 | "start:electron-dev:debug": "cross-env DEBUG='electron*' yarn start:electron-dev", 22 | "start:electron-dev:trace": "cross-env ELECTRON_OVERRIDE_DIST_PATH=$(e show out --path) ELECTRON_ENABLE_LOGGING=1 yarn --cwd ./packages/shell start:trace", 23 | "start:skip-build": "cross-env SHELL_DEBUG=true DEBUG='electron-chrome-extensions*' yarn --cwd ./packages/shell start", 24 | "test": "yarn test:extensions", 25 | "test:extensions": "yarn --cwd ./packages/electron-chrome-extensions test", 26 | "prepare": "husky", 27 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css}\"", 28 | "generate-noncompliant": "cat noncompliant.txt | awk '{print tolower($0)}' | xargs -I {} node ./scripts/generate-hash.js {}" 29 | }, 30 | "license": "GPL-3.0", 31 | "author": "Samuel Maddock ", 32 | "dependencies": {}, 33 | "devDependencies": { 34 | "husky": "^9.1.7", 35 | "lint-staged": "^15.2.10", 36 | "prettier": "^3.4.1" 37 | }, 38 | "repository": "git@github.com:samuelmaddock/electron-browser-shell.git", 39 | "engines": { 40 | "node": ">= 16.0.0", 41 | "yarn": ">= 1.10.0 < 2.0.0" 42 | }, 43 | "lint-staged": { 44 | "*.{js,jsx,ts,tsx,json,css,md}": "prettier --write" 45 | }, 46 | "prettier": { 47 | "printWidth": 100, 48 | "singleQuote": true, 49 | "semi": false, 50 | "endOfLine": "lf" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/electron-chrome-context-menu/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.map -------------------------------------------------------------------------------- /packages/electron-chrome-context-menu/.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | -------------------------------------------------------------------------------- /packages/electron-chrome-context-menu/README.md: -------------------------------------------------------------------------------- 1 | # electron-chrome-context-menu 2 | 3 | > Chrome context menu for Electron browsers 4 | 5 | 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. 6 | 7 | This module aims to provide a context menu with close to feature parity to that of Google Chrome. 8 | 9 | ## Install 10 | 11 | > npm install electron-chrome-context-menu 12 | 13 | ## Usage 14 | 15 | ```ts 16 | // ES imports 17 | import buildChromeContextMenu from 'electron-chrome-context-menu' 18 | // CommonJS 19 | const buildChromeContextMenu = require('electron-chrome-context-menu').default 20 | 21 | const { app } = require('electron') 22 | 23 | app.on('web-contents-created', (event, webContents) => { 24 | webContents.on('context-menu', (e, params) => { 25 | const menu = buildChromeContextMenu({ 26 | params, 27 | webContents, 28 | openLink: (url, disposition) => { 29 | webContents.loadURL(url) 30 | } 31 | }) 32 | 33 | menu.popup() 34 | }) 35 | }) 36 | ``` 37 | 38 | > For a complete example, see the [`electron-browser-shell`](https://github.com/samuelmaddock/electron-browser-shell) project. 39 | 40 | ## API 41 | 42 | ### `buildChromeContextMenu(options)` 43 | 44 | * `options` Object 45 | * `params` Electron.ContextMenuParams - Context menu parameters emitted from the WebContents 'context-menu' event. 46 | * `webContents` Electron.WebContents - WebContents which emitted the 'context-menu' event. 47 | * `openLink(url, disposition, params)` - Handler for opening links. 48 | * `url` String 49 | * `disposition` String - Can be `default`, `foreground-tab`, `background-tab`, and `new-window`. 50 | * `params` Electron.ContextMenuParams 51 | * `extensionMenuItems` Electron.MenuItem[] (optional) - Collection of menu items for active web extensions. 52 | * `labels` Object (optional) - Labels used to create menu items. Replace this if localization is needed. 53 | 54 | ## License 55 | 56 | MIT 57 | -------------------------------------------------------------------------------- /packages/electron-chrome-context-menu/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, clipboard, Menu, MenuItem } from 'electron' 2 | 3 | const LABELS = { 4 | openInNewTab: (type: 'link' | Electron.ContextMenuParams['mediaType']) => 5 | `Open ${type} in new tab`, 6 | openInNewWindow: (type: 'link' | Electron.ContextMenuParams['mediaType']) => 7 | `Open ${type} in new window`, 8 | copyAddress: (type: 'link' | Electron.ContextMenuParams['mediaType']) => `Copy ${type} address`, 9 | undo: 'Undo', 10 | redo: 'Redo', 11 | cut: 'Cut', 12 | copy: 'Copy', 13 | delete: 'Delete', 14 | paste: 'Paste', 15 | selectAll: 'Select All', 16 | back: 'Back', 17 | forward: 'Forward', 18 | reload: 'Reload', 19 | inspect: 'Inspect', 20 | addToDictionary: 'Add to dictionary', 21 | exitFullScreen: 'Exit full screen', 22 | emoji: 'Emoji', 23 | } 24 | 25 | const getBrowserWindowFromWebContents = (webContents: Electron.WebContents) => { 26 | return BrowserWindow.getAllWindows().find((win) => { 27 | if (win.webContents === webContents) return true 28 | 29 | let browserViews: Electron.BrowserView[] 30 | 31 | if ('getBrowserViews' in win) { 32 | browserViews = win.getBrowserViews() 33 | } else if ('getBrowserView' in win) { 34 | // @ts-ignore 35 | browserViews = [win.getBrowserView()] 36 | } else { 37 | browserViews = [] 38 | } 39 | 40 | return browserViews.some((view) => view.webContents === webContents) 41 | }) 42 | } 43 | 44 | type ChromeContextMenuLabels = typeof LABELS 45 | 46 | interface ChromeContextMenuOptions { 47 | /** Context menu parameters emitted from the WebContents 'context-menu' event. */ 48 | params: Electron.ContextMenuParams 49 | 50 | /** WebContents which emitted the 'context-menu' event. */ 51 | webContents: Electron.WebContents 52 | 53 | /** Handler for opening links. */ 54 | openLink: ( 55 | url: string, 56 | disposition: 'default' | 'foreground-tab' | 'background-tab' | 'new-window', 57 | params: Electron.ContextMenuParams, 58 | ) => void 59 | 60 | /** Chrome extension menu items. */ 61 | extensionMenuItems?: MenuItem[] 62 | 63 | /** Labels used to create menu items. Replace this if localization is needed. */ 64 | labels?: ChromeContextMenuLabels 65 | 66 | /** 67 | * @deprecated Use 'labels' instead. 68 | */ 69 | strings?: ChromeContextMenuLabels 70 | } 71 | 72 | export const buildChromeContextMenu = (opts: ChromeContextMenuOptions): Menu => { 73 | const { params, webContents, openLink, extensionMenuItems } = opts 74 | 75 | const labels = opts.labels || opts.strings || LABELS 76 | 77 | const menu = new Menu() 78 | const append = (opts: Electron.MenuItemConstructorOptions) => menu.append(new MenuItem(opts)) 79 | const appendSeparator = () => menu.append(new MenuItem({ type: 'separator' })) 80 | 81 | if (params.linkURL) { 82 | append({ 83 | label: labels.openInNewTab('link'), 84 | click: () => { 85 | openLink(params.linkURL, 'default', params) 86 | }, 87 | }) 88 | append({ 89 | label: labels.openInNewWindow('link'), 90 | click: () => { 91 | openLink(params.linkURL, 'new-window', params) 92 | }, 93 | }) 94 | appendSeparator() 95 | append({ 96 | label: labels.copyAddress('link'), 97 | click: () => { 98 | clipboard.writeText(params.linkURL) 99 | }, 100 | }) 101 | appendSeparator() 102 | } else if (params.mediaType !== 'none') { 103 | // TODO: Loop, Show controls 104 | append({ 105 | label: labels.openInNewTab(params.mediaType), 106 | click: () => { 107 | openLink(params.srcURL, 'default', params) 108 | }, 109 | }) 110 | append({ 111 | label: labels.copyAddress(params.mediaType), 112 | click: () => { 113 | clipboard.writeText(params.srcURL) 114 | }, 115 | }) 116 | appendSeparator() 117 | } 118 | 119 | if (params.isEditable) { 120 | if (params.misspelledWord) { 121 | for (const suggestion of params.dictionarySuggestions) { 122 | append({ 123 | label: suggestion, 124 | click: () => webContents.replaceMisspelling(suggestion), 125 | }) 126 | } 127 | 128 | if (params.dictionarySuggestions.length > 0) appendSeparator() 129 | 130 | append({ 131 | label: labels.addToDictionary, 132 | click: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord), 133 | }) 134 | } else { 135 | if ( 136 | app.isEmojiPanelSupported() && 137 | !['input-number', 'input-telephone'].includes(params.formControlType) 138 | ) { 139 | append({ 140 | label: labels.emoji, 141 | click: () => app.showEmojiPanel(), 142 | }) 143 | appendSeparator() 144 | } 145 | 146 | append({ 147 | label: labels.redo, 148 | enabled: params.editFlags.canRedo, 149 | click: () => webContents.redo(), 150 | }) 151 | append({ 152 | label: labels.undo, 153 | enabled: params.editFlags.canUndo, 154 | click: () => webContents.undo(), 155 | }) 156 | } 157 | 158 | appendSeparator() 159 | 160 | append({ 161 | label: labels.cut, 162 | enabled: params.editFlags.canCut, 163 | click: () => webContents.cut(), 164 | }) 165 | append({ 166 | label: labels.copy, 167 | enabled: params.editFlags.canCopy, 168 | click: () => webContents.copy(), 169 | }) 170 | append({ 171 | label: labels.paste, 172 | enabled: params.editFlags.canPaste, 173 | click: () => webContents.paste(), 174 | }) 175 | append({ 176 | label: labels.delete, 177 | enabled: params.editFlags.canDelete, 178 | click: () => webContents.delete(), 179 | }) 180 | appendSeparator() 181 | if (params.editFlags.canSelectAll) { 182 | append({ 183 | label: labels.selectAll, 184 | click: () => webContents.selectAll(), 185 | }) 186 | appendSeparator() 187 | } 188 | } else if (params.selectionText) { 189 | append({ 190 | label: labels.copy, 191 | click: () => { 192 | clipboard.writeText(params.selectionText) 193 | }, 194 | }) 195 | appendSeparator() 196 | } 197 | 198 | if (menu.items.length === 0) { 199 | const browserWindow = getBrowserWindowFromWebContents(webContents) 200 | 201 | // TODO: Electron needs a way to detect whether we're in HTML5 full screen. 202 | // Also need to properly exit full screen in Blink rather than just exiting 203 | // the Electron BrowserWindow. 204 | if (browserWindow?.fullScreen) { 205 | append({ 206 | label: labels.exitFullScreen, 207 | click: () => browserWindow.setFullScreen(false), 208 | }) 209 | 210 | appendSeparator() 211 | } 212 | 213 | append({ 214 | label: labels.back, 215 | enabled: webContents.navigationHistory.canGoBack(), 216 | click: () => webContents.navigationHistory.goBack(), 217 | }) 218 | append({ 219 | label: labels.forward, 220 | enabled: webContents.navigationHistory.canGoForward(), 221 | click: () => webContents.navigationHistory.goForward(), 222 | }) 223 | append({ 224 | label: labels.reload, 225 | click: () => webContents.reload(), 226 | }) 227 | appendSeparator() 228 | } 229 | 230 | if (extensionMenuItems) { 231 | extensionMenuItems.forEach((item) => menu.append(item)) 232 | if (extensionMenuItems.length > 0) appendSeparator() 233 | } 234 | 235 | append({ 236 | label: labels.inspect, 237 | click: () => { 238 | webContents.inspectElement(params.x, params.y) 239 | 240 | if (!webContents.isDevToolsFocused()) { 241 | webContents.devToolsWebContents?.focus() 242 | } 243 | }, 244 | }) 245 | 246 | return menu 247 | } 248 | 249 | export default buildChromeContextMenu 250 | -------------------------------------------------------------------------------- /packages/electron-chrome-context-menu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-chrome-context-menu", 3 | "version": "1.1.0", 4 | "description": "Chrome context menu for Electron browsers", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "prepublishOnly": "npm run build" 9 | }, 10 | "keywords": [ 11 | "electron", 12 | "chrome", 13 | "context", 14 | "menu" 15 | ], 16 | "repository": "https://github.com/samuelmaddock/electron-browser-shell", 17 | "author": "Samuel Maddock ", 18 | "license": "MIT", 19 | "peerDependencies": { 20 | "electron": ">=9.0.0" 21 | }, 22 | "devDependencies": { 23 | "typescript": "^4.1.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/electron-chrome-context-menu/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | 4 | "compilerOptions": { 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "declaration": true 8 | }, 9 | 10 | "include": ["*"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.map 3 | *.preload.js 4 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | spec 3 | script 4 | webpack.config.js 5 | tsconfig.json 6 | CHANGELOG.md 7 | *.png 8 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [4.0.0] - 2024-10-22 9 | 10 | ### Added 11 | 12 | - BREAKING: Added required `license` property to ElectronChromeExtensions constructor. 13 | 14 | There are several applications distributing this package without sponsoring. I'm attempting to make this requirement more obvious ahead of Manifest V3 support. 15 | 16 | ## [3.10.1] - 2023-06-02 17 | 18 | ### Fixed 19 | 20 | - `chrome.webNavigation` internals erroring due to electron@25.x.y deprecations. 21 | - Unloaded extensions are now removed from ``. 22 | 23 | ## [3.10.0] - 2023-01-16 24 | 25 | ### Added 26 | 27 | - Added URL pattern matching to `chrome.tabs.query`. 28 | - Added title pattern matching to `chrome.tabs.query`. 29 | - `chrome.tabs.create` and `chrome.tabs.update` now resolve relative URLs. 30 | 31 | ### Changed 32 | 33 | - `ElectronChromeExtensions.getContextMenuItems()` now groups multiple top-level items under an extension submenu. 34 | - Removed the ability for `tabs.create` and `tabs.update` to set `chrome://` or `javascript:` URLs. 35 | - Updated vulnerable dependencies. 36 | 37 | ## [3.9.0] - 2021-09-04 38 | 39 | ### Added 40 | 41 | - `chrome.webNavigation.onDOMContentLoaded` for iframes—`electron@15.0.0-beta.2` required. 42 | 43 | ### Changed 44 | 45 | - Reduced IPC traffic for updated extension icons. 46 | 47 | ### Fixed 48 | 49 | - `chrome.contextMenus.onClicked` callback was never invoked. 50 | - `partition` of `` would error when a remote session partition was set. 51 | 52 | ## [3.8.0] - 2021-06-14 53 | 54 | ### Added 55 | 56 | - `chrome.cookies.onChanged` 57 | - `chrome.extension.isAllowedIncognitoAccess` 58 | 59 | ### Changed 60 | 61 | - Extension background scripts now subscribe to API events to cut down on IPC traffic. 62 | - `selectTab()` from the `ElectronChromeExtensions` constructor options is now called when the Chrome extensions API sets the active tab. 63 | 64 | ## [3.7.0] - 2021-06-05 65 | 66 | ### Added 67 | 68 | - Exposed `action` and `badge` CSS shadow parts for customizing `` element. 69 | 70 | ### Fixed 71 | 72 | - Calling `ElectronChromeExtensions.getContextMenuItems()` threw an error when no `browser_action` was defined in an extension's manifest file. 73 | 74 | ## [3.6.1] - 2021-06-05 75 | 76 | ### Added 77 | 78 | - Included license files in NPM package. 79 | 80 | ## [3.6.0] - 2021-05-20 81 | 82 | ### Added 83 | 84 | - Initial `chrome.contextMenu` support when right-clicking browser actions. 85 | - Support `chrome.contextMenu` entries with `parentId` set. 86 | - Added `ElectronChromeExtensions.fromSession()` to get an existing instance. 87 | 88 | ### Changed 89 | 90 | - Renamed `Extensions` class to `ElectronChromeExtensions`. 91 | 92 | ### Fixed 93 | 94 | - Disabled `chrome.contextMenu` items now appear disabled instead of being hidden. 95 | 96 | ## [3.5.0] - 2021-05-09 97 | 98 | ### Added 99 | 100 | - Stubbed `chrome.commands` methods. 101 | 102 | ### Fixed 103 | 104 | - Browser action popup not using `browserAction.setPopup()` value. 105 | 106 | ## [3.4.0] - 2021-04-07 107 | 108 | ### Added 109 | 110 | - Added `Extensions.removeExtension(extension)`. 111 | 112 | ### Changed 113 | 114 | - Improvements to the browser action styles. 115 | 116 | ### Fixed 117 | 118 | - Errors being thrown in Electron 12 when `'extension-unloaded'` is emitted. 119 | 120 | ## [3.3.0] - 2021-02-10 121 | 122 | ### Added 123 | 124 | - `` now supports custom sessions which can be set using the `partition` attribute. 125 | 126 | ### Changed 127 | 128 | - ``'s `tab` attribute is now optional and will use the active tab by default. 129 | 130 | ### Fixed 131 | 132 | - Fixed `browser-action-list` badge text not updating after being removed. 133 | 134 | ## [3.2.0] - 2021-02-07 135 | 136 | ### Added 137 | 138 | - Added `modulePath` option to `Extensions` constructor. 139 | 140 | ## [3.1.1] - 2021-01-27 141 | 142 | ### Fixed 143 | 144 | - Fix `browser-action-list` API not working when `contextIsolation` is disabled. 145 | 146 | ## [3.1.0] - 2021-01-24 147 | 148 | ### Added 149 | 150 | - Basic `chrome.notifications` support 151 | - `chrome.browserAction.onClicked` 152 | - `chrome.tabs.executeScript` for the active tab 153 | - `chrome.webNavigation.onBeforeNavigate`, `chrome.webNavigation.onDOMContentLoaded`, `chrome.webNavigation.onCompleted` 154 | - `chrome.webNavigation.getFrame` and `chrome.webNavigation.getAllFrames` (Electron 12+) 155 | 156 | ## [3.0.0] - 2021-01-15 157 | 158 | ### Added 159 | 160 | - Most of `chrome.cookies` 161 | - Most of `chrome.windows` 162 | - `chrome.tabs.getAllInWindow` 163 | - `chrome.webNavigation.getAllFrames` 164 | - `chrome.storage` now uses `local` as a fallback for `sync` and `managed` 165 | which aren't currently supported by Electron. 166 | - Basic hover style for `` items. 167 | 168 | ### Changed 169 | 170 | - BREAKING: Replace `event` object passed into `Extensions` constructor option. 171 | functions with an instance of the tab's `BrowserWindow` owner. 172 | 173 | ### Fixed 174 | 175 | - Extension action popups now resize appropriately in electron@12.x.y. 176 | 177 | [4.0.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.10.1...electron-chrome-extensions@4.0.0 178 | [3.10.1]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.10.0...electron-chrome-extensions@3.10.1 179 | [3.10.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.9.0...electron-chrome-extensions@3.10.0 180 | [3.9.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.8.0...electron-chrome-extensions@3.9.0 181 | [3.8.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.7.0...electron-chrome-extensions@3.8.0 182 | [3.7.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.6.1...electron-chrome-extensions@3.7.0 183 | [3.6.1]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.6.0...electron-chrome-extensions@3.6.1 184 | [3.6.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.5.0...electron-chrome-extensions@3.6.0 185 | [3.5.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.4.0...electron-chrome-extensions@3.5.0 186 | [3.4.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.3.0...electron-chrome-extensions@3.4.0 187 | [3.3.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.2.0...electron-chrome-extensions@3.3.0 188 | [3.2.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.1.1...electron-chrome-extensions@3.2.0 189 | [3.1.1]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.0.1...electron-chrome-extensions@3.1.1 190 | [3.1.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@3.0.0...electron-chrome-extensions@3.1.0 191 | [3.0.0]: https://github.com/samuelmaddock/electron-browser-shell/compare/electron-chrome-extensions@2.1.0...electron-chrome-extensions@3.0.0 192 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/LICENSE-PATRON.md: -------------------------------------------------------------------------------- 1 | # Patron License 2 | 3 | Payment Platforms: 4 | 5 | - 6 | 7 | Participating Contributors: 8 | 9 | - Samuel Maddock 10 | 11 | ## Purpose 12 | 13 | This license gives everyone patronizing contributors to this software permission to ignore any noncommercial or copyleft rules of its free public license, while continuing to protect contributors from liability. 14 | 15 | ## Acceptance 16 | 17 | In order to agree to these terms and receive a license, you must qualify under [Patrons](#patrons). The rules of these terms are both obligations under your agreement and conditions to your license. That agreement and your license continue only while you qualify as a patron. You must not do anything with this software that triggers a rule that you cannot or will not follow. 18 | 19 | ## Patrons 20 | 21 | To accept these terms, you must be enrolled to make regular payments through any of the payment platforms pages listed above, in amounts qualifying you for a tier that includes a "patron license" or otherwise identifies a license under these terms as a reward. 22 | 23 | ## Scope 24 | 25 | Except under [Seat](#seat) and [Applications](#applications), you may not sublicense or transfer any agreement or license under these terms to anyone else. 26 | 27 | ## Seat 28 | 29 | If a legal entity, rather than an individual, accepts these terms, the entity may sublicense one individual employee or independent contractor at any given time. If the employee or contractor breaks any rule of these terms, the entity will stand directly responsible. 30 | 31 | ## Applications 32 | 33 | If you combine this software with other software in a larger application, you may sublicense this software as part of your larger application, and allow further sublicensing in turn, under these rules: 34 | 35 | 1. Your larger application must have significant additional content or functionality beyond that of this software, and end users must license your larger application primarily for that added content or functionality. 36 | 37 | 2. You may not sublicense anyone to break any rule of the public license for this software for any changes of their own or any software besides your larger application. 38 | 39 | 3. You may build, and sublicense for, as many larger applications as you like. 40 | 41 | ## Copyright 42 | 43 | Each contributor licenses you to do everything with this software that would otherwise infringe that contributor's copyright in it. 44 | 45 | ## Notices 46 | 47 | You must ensure that everyone who gets a copy of any part of this software from you, with or without changes, also gets the texts of both this license and the free public license for this software. 48 | 49 | ## Excuse 50 | 51 | If anyone notifies you in writing that you have not complied with [Notices](#notices), you can keep your agreement and your license by taking all practical steps to comply within 30 days after the notice. If you do not do so, your agreement under these terms ends immediately, and your license ends with it. 52 | 53 | ## Patent 54 | 55 | Each contributor licenses you to do everything with this software that would otherwise infringe any patent claims they can license or become able to license. 56 | 57 | ## Reliability 58 | 59 | No contributor can revoke this license, but your license may end if you break any rule of these terms. 60 | 61 | ## No Liability 62 | 63 | ***As far as the law allows, this software comes as is, without any warranty or condition, and no contributor will be liable to anyone for any damages related to this software or this license, under any kind of legal claim.*** 64 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/LICENSE.md: -------------------------------------------------------------------------------- 1 | electron-chrome-extensions provides dual licensing. 2 | 3 | See [LICENSE-GPL](./LICENSE-GPL) and [LICENSE-PATRON.md](./LICENSE-PATRON.md) for more info. 4 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/esbuild.config.js: -------------------------------------------------------------------------------- 1 | const packageJson = require('./package.json') 2 | const { createConfig, build, EXTERNAL_BASE } = require('../../build/esbuild/esbuild.config.base') 3 | 4 | console.log(`building ${packageJson.name}`) 5 | 6 | const external = [...EXTERNAL_BASE, 'electron-chrome-extensions/preload'] 7 | 8 | const browserConfig = createConfig({ 9 | entryPoints: ['src/index.ts'], 10 | outfile: 'dist/cjs/index.js', 11 | platform: 'node', 12 | external, 13 | }) 14 | 15 | const browserESMConfig = createConfig({ 16 | entryPoints: ['src/index.ts'], 17 | outfile: 'dist/esm/index.mjs', 18 | platform: 'node', 19 | external, 20 | format: 'esm', 21 | }) 22 | 23 | build(browserConfig) 24 | build(browserESMConfig) 25 | 26 | const preloadConfig = createConfig({ 27 | entryPoints: ['src/preload.ts'], 28 | outfile: 'dist/chrome-extension-api.preload.js', 29 | platform: 'browser', 30 | external, 31 | sourcemap: false, 32 | }) 33 | 34 | build(preloadConfig) 35 | 36 | const browserActionPreloadConfig = createConfig({ 37 | entryPoints: ['src/browser-action.ts'], 38 | outfile: 'dist/cjs/browser-action.js', 39 | platform: 'browser', 40 | format: 'cjs', 41 | external, 42 | sourcemap: false, 43 | }) 44 | 45 | const browserActionESMPreloadConfig = createConfig({ 46 | entryPoints: ['src/browser-action.ts'], 47 | outfile: 'dist/esm/browser-action.mjs', 48 | platform: 'browser', 49 | external, 50 | sourcemap: false, 51 | format: 'esm', 52 | }) 53 | 54 | build(browserActionPreloadConfig) 55 | build(browserActionESMPreloadConfig) 56 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-chrome-extensions", 3 | "version": "4.8.0", 4 | "description": "Chrome extension support for Electron", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.mjs", 7 | "types": "./dist/types/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types/index.d.ts", 11 | "import": "./dist/esm/index.mjs", 12 | "require": "./dist/cjs/index.js" 13 | }, 14 | "./browser-action": { 15 | "types": "./dist/types/browser-action.d.ts", 16 | "import": "./dist/esm/browser-action.mjs", 17 | "require": "./dist/cjs/browser-action.js" 18 | }, 19 | "./dist/browser-action": { 20 | "types": "./dist/types/browser-action.d.ts", 21 | "import": "./dist/esm/browser-action.mjs", 22 | "require": "./dist/cjs/browser-action.js" 23 | }, 24 | "./preload": "./dist/chrome-extension-api.preload.js" 25 | }, 26 | "scripts": { 27 | "build": "yarn clean && tsc && node esbuild.config.js", 28 | "clean": "node ../../scripts/clean.js", 29 | "prepublishOnly": "NODE_ENV=production yarn build", 30 | "pretest": "esbuild spec/fixtures/crx-test-preload.ts --bundle --external:electron --outfile=spec/fixtures/crx-test-preload.js --platform=node", 31 | "test": "node ./script/spec-runner.js" 32 | }, 33 | "keywords": [ 34 | "electron", 35 | "chrome", 36 | "extensions" 37 | ], 38 | "repository": "https://github.com/samuelmaddock/electron-browser-shell", 39 | "author": "Samuel Maddock ", 40 | "license": "SEE LICENSE IN LICENSE.md", 41 | "dependencies": { 42 | "debug": "^4.3.1" 43 | }, 44 | "devDependencies": { 45 | "@types/chai": "^4.2.14", 46 | "@types/chai-as-promised": "^7.1.3", 47 | "@types/chrome": "^0.0.300", 48 | "@types/mocha": "^8.0.4", 49 | "chai": "^4.2.0", 50 | "chai-as-promised": "^7.1.1", 51 | "colors": "^1.4.0", 52 | "electron": "^35.0.0-beta.3", 53 | "esbuild": "^0.24.2", 54 | "minimist": "^1.2.7", 55 | "mocha": "^8.2.1", 56 | "ts-node": "^10.9.1", 57 | "typescript": "^4.9.4", 58 | "walkdir": "^0.4.1" 59 | }, 60 | "peerDependencies": {} 61 | } 62 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/screenshot-browser-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/packages/electron-chrome-extensions/screenshot-browser-action.png -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/screenshot-dark-reader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/packages/electron-chrome-extensions/screenshot-dark-reader.png -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/screenshot-ublock-origin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/packages/electron-chrome-extensions/screenshot-ublock-origin.png -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/script/native-messaging-host/.gitignore: -------------------------------------------------------------------------------- 1 | crxtesthost 2 | crxtesthost.blob 3 | crxtesthost.exe 4 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/script/native-messaging-host/build.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { promises: fs } = require('node:fs') 4 | const path = require('node:path') 5 | const os = require('node:os') 6 | const util = require('node:util') 7 | const cp = require('node:child_process') 8 | const exec = util.promisify(cp.exec) 9 | 10 | const basePath = 'script/native-messaging-host/' 11 | const outDir = path.join(__dirname, '.') 12 | const exeName = `crxtesthost${process.platform === 'win32' ? '.exe' : ''}` 13 | const seaBlobName = 'crxtesthost.blob' 14 | 15 | async function createSEA() { 16 | await fs.rm(path.join(outDir, seaBlobName), { force: true }) 17 | await fs.rm(path.join(outDir, exeName), { force: true }) 18 | 19 | await exec('node --experimental-sea-config sea-config.json', { cwd: outDir }) 20 | await fs.cp(process.execPath, path.join(outDir, exeName)) 21 | 22 | if (process.platform === 'darwin') { 23 | await exec(`codesign --remove-signature ${exeName}`, { cwd: outDir }) 24 | } 25 | 26 | console.info(`Building ${exeName}…`) 27 | const buildCmd = [ 28 | 'npx postject', 29 | `${basePath}${exeName}`, 30 | 'NODE_SEA_BLOB', 31 | `${basePath}${seaBlobName}`, 32 | '--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2', 33 | ...(process.platform === 'darwin' ? ['--macho-segment-name NODE_SEA'] : []), 34 | ] 35 | await exec(buildCmd.join(' '), { cwd: outDir }) 36 | 37 | if (process.platform === 'darwin') { 38 | await exec(`codesign --sign - ${exeName}`, { cwd: outDir }) 39 | } 40 | } 41 | 42 | async function installConfig(extensionIds) { 43 | console.info(`Installing config…`) 44 | 45 | const hostName = 'com.crx.test' 46 | const manifest = { 47 | name: hostName, 48 | description: 'electron-chrome-extensions test', 49 | path: path.join(outDir, exeName), 50 | type: 'stdio', 51 | allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`), 52 | } 53 | 54 | const writeManifest = async (manifestPath) => { 55 | await fs.mkdir(manifestPath, { recursive: true }) 56 | const filePath = path.join(manifestPath, `${hostName}.json`) 57 | const data = Buffer.from(JSON.stringify(manifest, null, 2)) 58 | await fs.writeFile(filePath, data) 59 | return filePath 60 | } 61 | 62 | switch (process.platform) { 63 | case 'darwin': { 64 | const manifestDir = path.join( 65 | os.homedir(), 66 | 'Library', 67 | 'Application Support', 68 | 'Electron', 69 | 'NativeMessagingHosts', 70 | ) 71 | await writeManifest(manifestDir) 72 | break 73 | } 74 | case 'win32': { 75 | const manifestDir = path.join( 76 | os.homedir(), 77 | 'AppData', 78 | 'Roaming', 79 | 'Electron', 80 | 'NativeMessagingHosts', 81 | ) 82 | const manifestPath = await writeManifest(manifestDir) 83 | const registryKey = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${hostName}` 84 | await exec(`reg add "${registryKey}" /ve /t REG_SZ /d "${manifestPath}" /f`, { 85 | stdio: 'inherit', 86 | }) 87 | break 88 | } 89 | default: 90 | return 91 | } 92 | } 93 | 94 | async function main() { 95 | const extensionIdsArg = process.argv[2] 96 | if (!extensionIdsArg) { 97 | console.error('Must pass in csv of allowed extension IDs') 98 | process.exit(1) 99 | } 100 | 101 | const extensionIds = extensionIdsArg.split(',') 102 | await createSEA() 103 | await installConfig(extensionIds) 104 | } 105 | 106 | main() 107 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/script/native-messaging-host/main.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs') 2 | 3 | function readMessage() { 4 | let buffer = Buffer.alloc(4) 5 | if (fs.readSync(0, buffer, 0, 4, null) !== 4) { 6 | process.exit(1) 7 | } 8 | 9 | let messageLength = buffer.readUInt32LE(0) 10 | let messageBuffer = Buffer.alloc(messageLength) 11 | fs.readSync(0, messageBuffer, 0, messageLength, null) 12 | 13 | return JSON.parse(messageBuffer.toString()) 14 | } 15 | 16 | function sendMessage(message) { 17 | let json = JSON.stringify(message) 18 | let buffer = Buffer.alloc(4 + json.length) 19 | buffer.writeUInt32LE(json.length, 0) 20 | buffer.write(json, 4) 21 | 22 | fs.writeSync(1, buffer) 23 | } 24 | 25 | const message = readMessage() 26 | sendMessage(message) 27 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/script/native-messaging-host/sea-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "main.js", 3 | "output": "crxtesthost.blob" 4 | } 5 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/script/spec-runner.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const childProcess = require('child_process') 4 | const path = require('path') 5 | const unknownFlags = [] 6 | 7 | require('colors') 8 | const pass = '✓'.green 9 | const fail = '✗'.red 10 | 11 | const args = require('minimist')(process.argv, { 12 | string: ['target'], 13 | unknown: (arg) => unknownFlags.push(arg), 14 | }) 15 | 16 | const unknownArgs = [] 17 | for (const flag of unknownFlags) { 18 | unknownArgs.push(flag) 19 | const onlyFlag = flag.replace(/^-+/, '') 20 | if (args[onlyFlag]) { 21 | unknownArgs.push(args[onlyFlag]) 22 | } 23 | } 24 | 25 | async function main() { 26 | await runElectronTests() 27 | } 28 | 29 | async function runElectronTests() { 30 | const errors = [] 31 | 32 | const testResultsDir = process.env.ELECTRON_TEST_RESULTS_DIR 33 | 34 | try { 35 | console.info('\nRunning:') 36 | if (testResultsDir) { 37 | process.env.MOCHA_FILE = path.join(testResultsDir, `test-results.xml`) 38 | } 39 | await runMainProcessElectronTests() 40 | } catch (err) { 41 | errors.push([err]) 42 | } 43 | 44 | if (errors.length !== 0) { 45 | for (const err of errors) { 46 | console.error('\n\nRunner Failed:', err[0]) 47 | console.error(err[1]) 48 | } 49 | console.log(`${fail} Electron test runners have failed`) 50 | process.exit(1) 51 | } 52 | } 53 | 54 | async function runMainProcessElectronTests() { 55 | let exe = require('electron') 56 | const runnerArgs = ['spec', ...unknownArgs.slice(2)] 57 | 58 | // Fix issue in CI 59 | // "The SUID sandbox helper binary was found, but is not configured correctly." 60 | if (process.platform === 'linux') { 61 | runnerArgs.push('--no-sandbox') 62 | } 63 | 64 | const { status, signal } = childProcess.spawnSync(exe, runnerArgs, { 65 | cwd: path.resolve(__dirname, '..'), 66 | env: process.env, 67 | stdio: 'inherit', 68 | }) 69 | if (status !== 0) { 70 | if (status) { 71 | const textStatus = 72 | process.platform === 'win32' ? `0x${status.toString(16)}` : status.toString() 73 | console.log(`${fail} Electron tests failed with code ${textStatus}.`) 74 | } else { 75 | console.log(`${fail} Electron tests failed with kill signal ${signal}.`) 76 | } 77 | process.exit(1) 78 | } 79 | console.log(`${pass} Electron main process tests passed.`) 80 | } 81 | 82 | main().catch((error) => { 83 | console.error('An error occurred inside the spec runner:', error) 84 | process.exit(1) 85 | }) 86 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/chrome-contextMenus-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { ipcMain } from 'electron' 3 | import { once } from 'node:events' 4 | 5 | import { useExtensionBrowser, useServer } from './hooks' 6 | import { uuid } from './spec-helpers' 7 | 8 | describe('chrome.contextMenus', () => { 9 | const server = useServer() 10 | const browser = useExtensionBrowser({ 11 | url: server.getUrl, 12 | extensionName: 'rpc', 13 | }) 14 | 15 | const getContextMenuItems = async () => { 16 | // TODO: why is this needed since upgrading to Electron 22? 17 | await new Promise((resolve) => setTimeout(resolve, 1000)) 18 | 19 | const contextMenuPromise = once(browser.webContents, 'context-menu') 20 | 21 | // Simulate right-click to create context-menu event. 22 | const opts = { x: 0, y: 0, button: 'right' as any } 23 | browser.webContents.sendInputEvent({ ...opts, type: 'mouseDown' }) 24 | browser.webContents.sendInputEvent({ ...opts, type: 'mouseUp' }) 25 | 26 | const [, params] = await contextMenuPromise 27 | return browser.extensions.getContextMenuItems(browser.webContents, params) 28 | } 29 | 30 | describe('create()', () => { 31 | it('creates item with label', async () => { 32 | const id = uuid() 33 | const title = 'ヤッホー' 34 | await browser.crx.exec('contextMenus.create', { id, title }) 35 | const items = await getContextMenuItems() 36 | expect(items).to.have.lengthOf(1) 37 | expect(items[0].id).to.equal(id) 38 | expect(items[0].label).to.equal(title) 39 | }) 40 | 41 | it('creates a child item', async () => { 42 | const parentId = uuid() 43 | const id = uuid() 44 | await browser.crx.exec('contextMenus.create', { id: parentId, title: 'parent' }) 45 | await browser.crx.exec('contextMenus.create', { id, parentId, title: 'child' }) 46 | const items = await getContextMenuItems() 47 | expect(items).to.have.lengthOf(1) 48 | expect(items[0].label).to.equal('parent') 49 | expect(items[0].submenu).to.be.an('object') 50 | expect(items[0].submenu!.items).to.have.lengthOf(1) 51 | expect(items[0].submenu!.items[0].label).to.equal('child') 52 | }) 53 | 54 | it('groups multiple top-level items', async () => { 55 | await browser.crx.exec('contextMenus.create', { id: uuid(), title: 'one' }) 56 | await browser.crx.exec('contextMenus.create', { id: uuid(), title: 'two' }) 57 | const items = await getContextMenuItems() 58 | expect(items).to.have.lengthOf(1) 59 | expect(items[0].label).to.equal(browser.extension.name) 60 | expect(items[0].submenu).to.be.an('object') 61 | expect(items[0].submenu!.items).to.have.lengthOf(2) 62 | expect(items[0].submenu!.items[0].label).to.equal('one') 63 | expect(items[0].submenu!.items[1].label).to.equal('two') 64 | }) 65 | 66 | it('invokes the create callback', async () => { 67 | const ipcName = 'create-callback' 68 | await browser.crx.exec('contextMenus.create', { 69 | title: 'callback', 70 | onclick: { __IPC_FN__: ipcName }, 71 | }) 72 | const items = await getContextMenuItems() 73 | const p = once(ipcMain, ipcName) 74 | items[0].click() 75 | await p 76 | }) 77 | }) 78 | 79 | describe('remove()', () => { 80 | it('removes item', async () => { 81 | const id = uuid() 82 | await browser.crx.exec('contextMenus.create', { id }) 83 | await browser.crx.exec('contextMenus.remove', id) 84 | const items = await getContextMenuItems() 85 | expect(items).to.be.empty 86 | }) 87 | }) 88 | 89 | describe('removeAll()', () => { 90 | it('removes all items', async () => { 91 | await browser.crx.exec('contextMenus.create', {}) 92 | await browser.crx.exec('contextMenus.create', {}) 93 | await browser.crx.exec('contextMenus.removeAll') 94 | const items = await getContextMenuItems() 95 | expect(items).to.be.empty 96 | }) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { randomUUID } from 'node:crypto' 3 | import { promisify } from 'node:util' 4 | import * as cp from 'node:child_process' 5 | import * as path from 'node:path' 6 | const exec = promisify(cp.exec) 7 | 8 | import { useExtensionBrowser, useServer } from './hooks' 9 | import { getExtensionId } from './crx-helpers' 10 | 11 | // TODO: build crxtesthost on Linux (see script/native-messaging-host/build.js) 12 | if (process.platform !== 'linux') { 13 | describe('nativeMessaging', () => { 14 | const server = useServer() 15 | const browser = useExtensionBrowser({ 16 | url: server.getUrl, 17 | extensionName: 'rpc', 18 | }) 19 | const hostApplication = 'com.crx.test' 20 | 21 | before(async function () { 22 | this.timeout(60e3) 23 | const extensionId = await getExtensionId('rpc') 24 | const nativeMessagingPath = path.join(__dirname, '..', 'script', 'native-messaging-host') 25 | const buildScript = path.join(nativeMessagingPath, 'build.js') 26 | await exec(`node ${buildScript} ${extensionId}`) 27 | }) 28 | 29 | describe('sendNativeMessage()', () => { 30 | it('sends and receives primitive value', async () => { 31 | const value = randomUUID() 32 | const result = await browser.crx.exec('runtime.sendNativeMessage', hostApplication, value) 33 | expect(result).to.equal(value) 34 | }) 35 | 36 | it('sends and receives object', async () => { 37 | const value = { json: randomUUID(), wow: 'nice' } 38 | const result = await browser.crx.exec('runtime.sendNativeMessage', hostApplication, value) 39 | expect(result).to.deep.equal(value) 40 | }) 41 | }) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/chrome-notifications-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | import { useExtensionBrowser, useServer } from './hooks' 4 | import { uuid } from './spec-helpers' 5 | 6 | const basicOpts: chrome.notifications.NotificationOptions = { 7 | type: 'basic', 8 | title: 'title', 9 | message: 'message', 10 | iconUrl: 'icon_16.png', 11 | silent: true, 12 | } 13 | 14 | describe('chrome.notifications', () => { 15 | const server = useServer() 16 | const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'rpc' }) 17 | 18 | describe('create()', () => { 19 | it('creates and shows a basic notification', async () => { 20 | const notificationId = uuid() 21 | const result = await browser.crx.exec('notifications.create', notificationId, basicOpts) 22 | expect(result).to.equal(notificationId) 23 | await browser.crx.exec('notifications.clear', notificationId) 24 | }) 25 | 26 | it('ignores invalid options', async () => { 27 | const notificationId = uuid() 28 | const result = await browser.crx.exec('notifications.create', notificationId, {}) 29 | expect(result).is.null 30 | }) 31 | 32 | it('ignores icons outside of extensions directory', async () => { 33 | const notificationId = uuid() 34 | const result = await browser.crx.exec('notifications.create', notificationId, { 35 | ...basicOpts, 36 | iconUrl: '../chrome-browserAction/icon_16.png', 37 | }) 38 | expect(result).is.null 39 | }) 40 | 41 | it('creates a notification with no ID given', async () => { 42 | const notificationId = await browser.crx.exec('notifications.create', basicOpts) 43 | expect(notificationId).to.be.string 44 | await browser.crx.exec('notifications.clear', notificationId) 45 | }) 46 | }) 47 | 48 | describe('getAll()', () => { 49 | it('lists created notification', async () => { 50 | const notificationId = uuid() 51 | await browser.crx.exec('notifications.create', notificationId, basicOpts) 52 | const list = await browser.crx.exec('notifications.getAll') 53 | expect(list).to.deep.equal([notificationId]) 54 | await browser.crx.exec('notifications.clear', notificationId) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/chrome-webNavigation-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { ipcMain } from 'electron' 3 | 4 | import { useExtensionBrowser, useServer } from './hooks' 5 | 6 | describe('chrome.webNavigation', () => { 7 | const server = useServer() 8 | const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'chrome-webNavigation' }) 9 | 10 | // TODO: for some reason 'onCommitted' will sometimes not arrive 11 | it.skip('emits events in the correct order', async () => { 12 | const expectedEventLog = [ 13 | 'onBeforeNavigate', 14 | 'onCommitted', 15 | 'onDOMContentLoaded', 16 | 'onCompleted', 17 | ] 18 | 19 | const eventsPromise = new Promise((resolve) => { 20 | const eventLog: string[] = [] 21 | ipcMain.on('logEvent', (e, eventName) => { 22 | if (eventLog.length === 0 && eventName !== 'onBeforeNavigate') { 23 | // ignore events that come in late from initial load 24 | return 25 | } 26 | 27 | eventLog.push(eventName) 28 | 29 | if (eventLog.length === expectedEventLog.length) { 30 | resolve(eventLog) 31 | } 32 | }) 33 | }) 34 | 35 | await browser.window.webContents.loadURL(`${server.getUrl()}`) 36 | 37 | const eventLog = await eventsPromise 38 | expect(eventLog).to.deep.equal(expectedEventLog) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/chrome-windows-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { app, webContents } from 'electron' 3 | import { emittedOnce } from './events-helpers' 4 | 5 | import { useExtensionBrowser, useServer } from './hooks' 6 | 7 | describe('chrome.windows', () => { 8 | const server = useServer() 9 | const browser = useExtensionBrowser({ url: server.getUrl, extensionName: 'rpc' }) 10 | 11 | describe('get()', () => { 12 | it('gets details on the window', async () => { 13 | const windowId = browser.window.id 14 | const result = await browser.crx.exec('windows.get', windowId) 15 | expect(result).to.be.an('object') 16 | expect(result.id).to.equal(windowId) 17 | }) 18 | }) 19 | 20 | describe('getLastFocused()', () => { 21 | it('gets the last focused window', async () => { 22 | // HACK: focus() doesn't actually emit this in tests 23 | browser.window.emit('focus') 24 | const windowId = browser.window.id 25 | const result = await browser.crx.exec('windows.getLastFocused') 26 | expect(result).to.be.an('object') 27 | expect(result.id).to.equal(windowId) 28 | }) 29 | }) 30 | 31 | describe('remove()', () => { 32 | it('removes the window', async () => { 33 | const windowId = browser.window.id 34 | const closedPromise = emittedOnce(browser.window, 'closed') 35 | browser.crx.exec('windows.remove', windowId) 36 | await closedPromise 37 | }) 38 | 39 | it('removes the current window', async () => { 40 | const closedPromise = emittedOnce(browser.window, 'closed') 41 | browser.crx.exec('windows.remove') 42 | await closedPromise 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/crx-helpers.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { app, BrowserWindow, session, webContents } from 'electron' 3 | import { uuid } from './spec-helpers' 4 | 5 | export const createCrxSession = () => { 6 | const partitionName = `crx-${uuid()}` 7 | const partition = `persist:${partitionName}` 8 | return { 9 | partitionName, 10 | partition, 11 | session: session.fromPartition(partition), 12 | } 13 | } 14 | 15 | export const addCrxPreload = (session: Electron.Session) => { 16 | const preloadPath = path.join(__dirname, 'fixtures', 'crx-test-preload.js') 17 | if ('registerPreloadScript' in session) { 18 | session.registerPreloadScript({ 19 | id: 'crx-test-preload', 20 | type: 'frame', 21 | filePath: preloadPath, 22 | }) 23 | } else { 24 | // @ts-expect-error Deprecated electron@<35 25 | session.setPreloads([...session.getPreloads(), preloadPath]) 26 | } 27 | } 28 | 29 | export const createCrxRemoteWindow = () => { 30 | const sessionDetails = createCrxSession() 31 | addCrxPreload(sessionDetails.session) 32 | 33 | const win = new BrowserWindow({ 34 | show: false, 35 | webPreferences: { 36 | session: sessionDetails.session, 37 | nodeIntegration: false, 38 | contextIsolation: true, 39 | }, 40 | }) 41 | 42 | return win 43 | } 44 | 45 | const isBackgroundHostSupported = (extension: Electron.Extension) => 46 | extension.manifest.manifest_version === 2 && extension.manifest.background?.scripts?.length > 0 47 | 48 | export const waitForBackgroundPage = async ( 49 | extension: Electron.Extension, 50 | session: Electron.Session, 51 | ) => { 52 | if (!isBackgroundHostSupported(extension)) return 53 | 54 | return await new Promise((resolve) => { 55 | const resolveHost = (wc: Electron.WebContents) => { 56 | app.removeListener('web-contents-created', onWebContentsCreated) 57 | resolve(wc) 58 | } 59 | 60 | const hostPredicate = (wc: Electron.WebContents) => 61 | !wc.isDestroyed() && wc.getURL().includes(extension.id) && wc.session === session 62 | 63 | const observeWebContents = (wc: Electron.WebContents) => { 64 | if (wc.getType() !== 'backgroundPage') return 65 | 66 | if (hostPredicate(wc)) { 67 | resolveHost(wc) 68 | return 69 | } 70 | 71 | wc.once('did-frame-navigate', () => { 72 | if (hostPredicate(wc)) { 73 | resolveHost(wc) 74 | } 75 | }) 76 | } 77 | 78 | const onWebContentsCreated = (_event: any, wc: Electron.WebContents) => observeWebContents(wc) 79 | 80 | webContents.getAllWebContents().forEach(observeWebContents) 81 | app.on('web-contents-created', onWebContentsCreated) 82 | }) 83 | } 84 | 85 | export async function waitForBackgroundScriptEvaluated( 86 | extension: Electron.Extension, 87 | session: Electron.Session, 88 | ) { 89 | if (!isBackgroundHostSupported(extension)) return 90 | 91 | const backgroundHost = await waitForBackgroundPage(extension, session) 92 | if (!backgroundHost) return 93 | 94 | await new Promise((resolve) => { 95 | const onConsoleMessage = (_event: any, _level: any, message: string) => { 96 | if (message === 'background-script-evaluated') { 97 | backgroundHost.removeListener('console-message', onConsoleMessage) 98 | resolve() 99 | } 100 | } 101 | backgroundHost.on('console-message', onConsoleMessage) 102 | }) 103 | } 104 | 105 | export async function getExtensionId(name: string) { 106 | const extensionPath = path.join(__dirname, 'fixtures', name) 107 | const ses = createCrxSession().session 108 | const extension = await ses.loadExtension(extensionPath) 109 | return extension.id 110 | } 111 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/events-helpers.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013-2020 GitHub Inc. 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining 4 | // a copy of this software and associated documentation files (the 5 | // "Software"), to deal in the Software without restriction, including 6 | // without limitation the rights to use, copy, modify, merge, publish, 7 | // distribute, sublicense, and/or sell copies of the Software, and to 8 | // permit persons to whom the Software is furnished to do so, subject to 9 | // the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be 12 | // included in all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | /** 23 | * @fileoverview A set of helper functions to make it easier to work 24 | * with events in async/await manner. 25 | */ 26 | 27 | /** 28 | * @param {!EventTarget} target 29 | * @param {string} eventName 30 | * @return {!Promise} 31 | */ 32 | export const waitForEvent = (target: EventTarget, eventName: string) => { 33 | return new Promise((resolve) => { 34 | target.addEventListener(eventName, resolve, { once: true }) 35 | }) 36 | } 37 | 38 | /** 39 | * @param {!EventEmitter} emitter 40 | * @param {string} eventName 41 | * @return {!Promise} With Event as the first item. 42 | */ 43 | export const emittedOnce = ( 44 | emitter: NodeJS.EventEmitter, 45 | eventName: string, 46 | trigger?: () => void, 47 | ) => { 48 | return emittedNTimes(emitter, eventName, 1, trigger).then(([result]) => result) 49 | } 50 | 51 | export const emittedNTimes = async ( 52 | emitter: NodeJS.EventEmitter, 53 | eventName: string, 54 | times: number, 55 | trigger?: () => void, 56 | ) => { 57 | const events: any[][] = [] 58 | const p = new Promise((resolve) => { 59 | const handler = (...args: any[]) => { 60 | events.push(args) 61 | if (events.length === times) { 62 | emitter.removeListener(eventName, handler) 63 | resolve(events) 64 | } 65 | } 66 | emitter.on(eventName, handler) 67 | }) 68 | if (trigger) { 69 | await Promise.resolve(trigger()) 70 | } 71 | return p 72 | } 73 | 74 | export const emittedUntil = async ( 75 | emitter: NodeJS.EventEmitter, 76 | eventName: string, 77 | untilFn: Function, 78 | ) => { 79 | const p = new Promise((resolve) => { 80 | const handler = (...args: any[]) => { 81 | if (untilFn(...args)) { 82 | emitter.removeListener(eventName, handler) 83 | resolve(args) 84 | } 85 | } 86 | emitter.on(eventName, handler) 87 | }) 88 | return p 89 | } 90 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/extensions-spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { session } from 'electron' 3 | import { ElectronChromeExtensions } from '../' 4 | 5 | describe('Extensions', () => { 6 | const testSession = session.fromPartition('test-extensions') 7 | const extensions = new ElectronChromeExtensions({ 8 | license: 'internal-license-do-not-use' as any, 9 | session: testSession, 10 | }) 11 | 12 | it('retrieves the instance with fromSession()', () => { 13 | expect(ElectronChromeExtensions.fromSession(testSession)).to.equal(extensions) 14 | }) 15 | 16 | it('throws when two instances are created for session', () => { 17 | expect(() => { 18 | new ElectronChromeExtensions({ 19 | license: 'internal-license-do-not-use' as any, 20 | session: testSession, 21 | }) 22 | }).to.throw() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | crx-test-preload.js 2 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/browser-action-list/default.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/background.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | 3 | chrome.browserAction.onClicked.addListener((tab) => { 4 | chrome.tabs.sendMessage(tab.id, tab) 5 | }) 6 | 7 | console.log('background-script-evaluated') 8 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/content-script.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function evalInMainWorld(fn) { 4 | const script = document.createElement('script') 5 | script.textContent = `((${fn})())` 6 | document.documentElement.appendChild(script) 7 | } 8 | 9 | chrome.runtime.onMessage.addListener((message) => { 10 | const funcStr = `() => { electronTest.sendIpc('success', ${JSON.stringify(message)}) }` 11 | evalInMainWorld(funcStr) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-click/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-browserAction-click", 3 | "version": "1.0", 4 | "manifest_version": 2, 5 | "browser_action": { 6 | "default_title": "browserAction click" 7 | }, 8 | "content_scripts": [ 9 | { 10 | "matches": [""], 11 | "js": ["content-script.js"], 12 | "run_at": "document_start" 13 | } 14 | ], 15 | "background": { 16 | "scripts": ["background.js"], 17 | "persistent": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_16.png -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/icon_32.png -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-browserAction-popup", 3 | "version": "1.0", 4 | "manifest_version": 2, 5 | "browser_action": { 6 | "default_icon": { 7 | "16": "icon_16.png", 8 | "32": "icon_32.png" 9 | }, 10 | "default_popup": "popup.html", 11 | "default_title": "browserAction Popup" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-browserAction-popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | browserAction -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/background.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | 3 | const eventNames = [ 4 | 'onBeforeNavigate', 5 | 'onCommitted', 6 | 'onCompleted', 7 | 'onCreatedNavigationTarget', 8 | 'onDOMContentLoaded', 9 | 'onErrorOccurred', 10 | 'onHistoryStateUpdated', 11 | 'onReferenceFragmentUpdated', 12 | 'onTabReplaced', 13 | ] 14 | 15 | let activeTabId 16 | 17 | let eventLog = [] 18 | const logEvent = (eventName) => { 19 | if (eventName) eventLog.push(eventName) 20 | if (typeof activeTabId === 'undefined') return 21 | 22 | eventLog.forEach((eventName) => { 23 | chrome.tabs.sendMessage(activeTabId, { name: 'logEvent', args: eventName }) 24 | }) 25 | 26 | eventLog = [] 27 | } 28 | 29 | eventNames.forEach((eventName) => { 30 | chrome.webNavigation[eventName].addListener(() => { 31 | logEvent(eventName) 32 | }) 33 | }) 34 | 35 | chrome.tabs.query({ active: true, windowId: chrome.windows.WINDOW_ID_CURRENT }, ([tab]) => { 36 | activeTabId = tab.id 37 | logEvent() 38 | }) 39 | 40 | console.log('background-script-evaluated') 41 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/content-script.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function evalInMainWorld(fn) { 4 | const script = document.createElement('script') 5 | script.textContent = `((${fn})())` 6 | document.documentElement.appendChild(script) 7 | } 8 | 9 | chrome.runtime.onMessage.addListener(({ name, args }) => { 10 | const funcStr = `() => { electronTest.sendIpc(${JSON.stringify(name)}, ${JSON.stringify(args)}) }` 11 | evalInMainWorld(funcStr) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/chrome-webNavigation/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-webNavigation", 3 | "version": "1.0", 4 | "manifest_version": 2, 5 | "content_scripts": [ 6 | { 7 | "matches": [""], 8 | "js": ["content-script.js"], 9 | "run_at": "document_start" 10 | } 11 | ], 12 | "background": { 13 | "scripts": ["background.js"], 14 | "persistent": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/crx-test-preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron' 2 | import { injectBrowserAction } from '../../src/browser-action' 3 | 4 | // This should go without saying, but you should never do this in a production 5 | // app. These bindings are purely for testing convenience. 6 | const apiName = 'electronTest' 7 | const api = { 8 | sendIpc(channel: string, ...args: any[]) { 9 | return ipcRenderer.send(channel, ...args) 10 | }, 11 | invokeIpc(channel: string, ...args: any[]) { 12 | return ipcRenderer.invoke(channel, ...args) 13 | }, 14 | } 15 | 16 | try { 17 | contextBridge.exposeInMainWorld(apiName, api) 18 | } catch { 19 | window[apiName] = api 20 | } 21 | 22 | // Inject in all test pages. 23 | injectBrowserAction() 24 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/rpc/background.js: -------------------------------------------------------------------------------- 1 | /* global chrome */ 2 | 3 | const sendIpc = ({ tabId, name }) => { 4 | chrome.tabs.sendMessage(tabId, { type: 'send-ipc', args: [name] }) 5 | } 6 | 7 | const transformArgs = (args, sender) => { 8 | const tabId = sender.tab.id 9 | 10 | const transformArg = (arg) => { 11 | if (arg && typeof arg === 'object') { 12 | // Convert object to function that sends IPC 13 | if ('__IPC_FN__' in arg) { 14 | return () => { 15 | sendIpc({ tabId, name: arg.__IPC_FN__ }) 16 | } 17 | } else { 18 | // Deep transform objects 19 | for (const key of Object.keys(arg)) { 20 | if (arg.hasOwnProperty(key)) { 21 | arg[key] = transformArg(arg[key]) 22 | } 23 | } 24 | } 25 | } 26 | 27 | return arg 28 | } 29 | 30 | return args.map(transformArg) 31 | } 32 | 33 | chrome.runtime.onMessage.addListener((message, sender, reply) => { 34 | switch (message.type) { 35 | case 'api': { 36 | const { method, args } = message 37 | 38 | const [apiName, subMethod] = method.split('.') 39 | 40 | if (typeof chrome[apiName][subMethod] === 'function') { 41 | const transformedArgs = transformArgs(args, sender) 42 | chrome[apiName][subMethod](...transformedArgs, reply) 43 | } 44 | 45 | break 46 | } 47 | 48 | case 'event-once': { 49 | const { name } = message 50 | 51 | const [apiName, eventName] = name.split('.') 52 | 53 | if (typeof chrome[apiName][eventName] === 'object') { 54 | const event = chrome[apiName][eventName] 55 | event.addListener(function callback(...args) { 56 | if (chrome.runtime.lastError) { 57 | reply(chrome.runtime.lastError) 58 | } else { 59 | reply(args) 60 | } 61 | 62 | event.removeListener(callback) 63 | }) 64 | } 65 | } 66 | } 67 | 68 | // Respond asynchronously 69 | return true 70 | }) 71 | 72 | console.log('background-script-evaluated') 73 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/rpc/content-script.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function evalInMainWorld(fn) { 4 | const script = document.createElement('script') 5 | script.textContent = `((${fn})())` 6 | document.documentElement.appendChild(script) 7 | } 8 | 9 | function sendIpc(name, ...args) { 10 | const jsonArgs = [name, ...args].map((arg) => JSON.stringify(arg)) 11 | const funcStr = `() => { electronTest.sendIpc(${jsonArgs.join(', ')}) }` 12 | evalInMainWorld(funcStr) 13 | } 14 | 15 | async function exec(action) { 16 | const send = async () => { 17 | return new Promise((resolve, reject) => { 18 | chrome.runtime.sendMessage(action, (result) => { 19 | if (chrome.runtime.lastError) { 20 | reject(chrome.runtime.lastError.message) 21 | } else { 22 | resolve(result) 23 | } 24 | }) 25 | }) 26 | } 27 | 28 | // Retry logic - the connection doesn't seem to always be available when 29 | // attempting to send. This started when upgrading to Electron 22 from 15. 30 | let result 31 | for (let i = 0; i < 3; i++) { 32 | try { 33 | result = await send() 34 | break 35 | } catch (e) { 36 | console.error(e) 37 | await new Promise((resolve) => setTimeout(resolve, 100)) // sleep 38 | } 39 | } 40 | 41 | sendIpc('success', result) 42 | } 43 | 44 | window.addEventListener('message', (event) => { 45 | exec(event.data) 46 | }) 47 | 48 | evalInMainWorld(() => { 49 | window.exec = (json) => window.postMessage(JSON.parse(json)) 50 | }) 51 | 52 | chrome.runtime.onMessage.addListener((message) => { 53 | switch (message.type) { 54 | case 'send-ipc': { 55 | const [name] = message.args 56 | sendIpc(name) 57 | break 58 | } 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/rpc/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/packages/electron-chrome-extensions/spec/fixtures/rpc/icon_16.png -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/rpc/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-rpc", 3 | "version": "1.0", 4 | "browser_action": { 5 | "default_title": "RPC" 6 | }, 7 | "content_scripts": [ 8 | { 9 | "matches": [""], 10 | "js": ["content-script.js"], 11 | "run_at": "document_end" 12 | } 13 | ], 14 | "background": { 15 | "scripts": ["background.js"], 16 | "persistent": true 17 | }, 18 | "manifest_version": 2, 19 | "permissions": [ 20 | "contextMenus", 21 | "nativeMessaging", 22 | "webRequest", 23 | "webRequestBlocking", 24 | "" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/fixtures/rpc/popup.html: -------------------------------------------------------------------------------- 1 | 2 | browserAction -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/hooks.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, BrowserWindow, app, Extension, webContents } from 'electron' 2 | import * as http from 'http' 3 | import * as path from 'node:path' 4 | import { AddressInfo } from 'net' 5 | import { ElectronChromeExtensions } from '../' 6 | import { emittedOnce } from './events-helpers' 7 | import { addCrxPreload, createCrxSession, waitForBackgroundScriptEvaluated } from './crx-helpers' 8 | import { ChromeExtensionImpl } from '../dist/types/browser/impl' 9 | 10 | export const useServer = () => { 11 | const emptyPage = ` 12 | 13 | 14 | title 15 | 16 | 17 | 18 | 19 | ` 20 | 21 | // NB. extensions are only allowed on http://, https:// and ftp:// (!) urls by default. 22 | let server: http.Server 23 | let url: string 24 | 25 | before(async () => { 26 | server = http.createServer((req, res) => { 27 | res.writeHead(200, { 'Content-Type': 'text/html' }) 28 | res.end(emptyPage) 29 | }) 30 | await new Promise((resolve) => 31 | server.listen(0, '127.0.0.1', () => { 32 | url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/` 33 | resolve() 34 | }), 35 | ) 36 | }) 37 | after(() => { 38 | server.close() 39 | }) 40 | 41 | return { 42 | getUrl: () => url, 43 | } 44 | } 45 | 46 | const fixtures = path.join(__dirname, 'fixtures') 47 | 48 | export const useExtensionBrowser = (opts: { 49 | url?: () => string 50 | file?: string 51 | extensionName: string 52 | openDevTools?: boolean 53 | assignTabDetails?: ChromeExtensionImpl['assignTabDetails'] 54 | }) => { 55 | let w: Electron.BrowserWindow 56 | let extensions: ElectronChromeExtensions 57 | let extension: Extension 58 | let partition: string 59 | let customSession: Electron.Session 60 | 61 | beforeEach(async () => { 62 | const sessionDetails = createCrxSession() 63 | 64 | partition = sessionDetails.partition 65 | customSession = sessionDetails.session 66 | 67 | addCrxPreload(customSession) 68 | 69 | extensions = new ElectronChromeExtensions({ 70 | license: 'internal-license-do-not-use' as any, 71 | session: customSession, 72 | async createTab(details) { 73 | const tab = (webContents as any).create({ sandbox: true }) 74 | if (details.url) await tab.loadURL(details.url) 75 | return [tab, w!] 76 | }, 77 | assignTabDetails(details, tab) { 78 | opts.assignTabDetails?.(details, tab) 79 | }, 80 | }) 81 | 82 | extension = await customSession.loadExtension(path.join(fixtures, opts.extensionName)) 83 | await waitForBackgroundScriptEvaluated(extension, customSession) 84 | 85 | w = new BrowserWindow({ 86 | show: false, 87 | webPreferences: { session: customSession, nodeIntegration: false, contextIsolation: true }, 88 | }) 89 | 90 | if (opts.openDevTools) { 91 | w.webContents.openDevTools({ mode: 'detach' }) 92 | } 93 | 94 | extensions.addTab(w.webContents, w) 95 | 96 | if (opts.file) { 97 | await w.loadFile(opts.file) 98 | } else if (opts.url) { 99 | await w.loadURL(opts.url()) 100 | } 101 | }) 102 | 103 | afterEach(() => { 104 | if (!w.isDestroyed()) { 105 | if (w.webContents.isDevToolsOpened()) { 106 | w.webContents.closeDevTools() 107 | } 108 | 109 | w.destroy() 110 | } 111 | }) 112 | 113 | return { 114 | get window() { 115 | return w 116 | }, 117 | get webContents() { 118 | return w.webContents 119 | }, 120 | get extensions() { 121 | return extensions 122 | }, 123 | get extension() { 124 | return extension 125 | }, 126 | get session() { 127 | return customSession 128 | }, 129 | get partition() { 130 | return partition 131 | }, 132 | 133 | crx: { 134 | async exec(method: string, ...args: any[]) { 135 | const p = emittedOnce(ipcMain, 'success') 136 | const rpcStr = JSON.stringify({ type: 'api', method, args }) 137 | const safeRpcStr = rpcStr.replace(/'/g, "\\'") 138 | const js = `exec('${safeRpcStr}')` 139 | await w.webContents.executeJavaScript(js) 140 | const [, result] = await p 141 | return result 142 | }, 143 | 144 | async eventOnce(eventName: string) { 145 | const p = emittedOnce(ipcMain, 'success') 146 | await w.webContents.executeJavaScript( 147 | `exec('${JSON.stringify({ type: 'event-once', name: eventName })}')`, 148 | ) 149 | const [, results] = await p 150 | 151 | if (typeof results === 'string') { 152 | throw new Error(results) 153 | } 154 | 155 | return results 156 | }, 157 | }, 158 | } 159 | } 160 | 161 | export const useBackgroundPageLogging = () => { 162 | app.on('web-contents-created', (event, wc) => { 163 | if (wc.getType() === 'backgroundPage') { 164 | wc.on('console-message', (ev, level, message, line, sourceId) => { 165 | console.log(`(${sourceId}) ${message}`) 166 | }) 167 | } 168 | }) 169 | } 170 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/index.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013-2020 GitHub Inc. 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining 4 | // a copy of this software and associated documentation files (the 5 | // "Software"), to deal in the Software without restriction, including 6 | // without limitation the rights to use, copy, modify, merge, publish, 7 | // distribute, sublicense, and/or sell copies of the Software, and to 8 | // permit persons to whom the Software is furnished to do so, subject to 9 | // the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be 12 | // included in all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | const Module = require('module') 23 | const path = require('path') 24 | const { promises: fs } = require('fs') 25 | const v8 = require('v8') 26 | 27 | Module.globalPaths.push(path.resolve(__dirname, '../spec/node_modules')) 28 | 29 | // We want to terminate on errors, not throw up a dialog 30 | process.on('uncaughtException', (err) => { 31 | console.error('Unhandled exception in main spec runner:', err) 32 | process.exit(1) 33 | }) 34 | 35 | // Tell ts-node which tsconfig to use 36 | process.env.TS_NODE_PROJECT = path.resolve(__dirname, '../tsconfig.json') 37 | process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' 38 | 39 | const { app, protocol } = require('electron') 40 | 41 | v8.setFlagsFromString('--expose_gc') 42 | app.commandLine.appendSwitch('js-flags', '--expose_gc') 43 | app.commandLine.appendSwitch('enable-features', 'ElectronSerialChooser') 44 | // Prevent the spec runner quiting when the first window closes 45 | app.on('window-all-closed', () => null) 46 | 47 | // Use fake device for Media Stream to replace actual camera and microphone. 48 | app.commandLine.appendSwitch('use-fake-device-for-media-stream') 49 | 50 | // @ts-ignore 51 | global.standardScheme = 'app' 52 | // @ts-ignore 53 | global.zoomScheme = 'zoom' 54 | protocol.registerSchemesAsPrivileged([ 55 | // @ts-ignore 56 | { scheme: global.standardScheme, privileges: { standard: true, secure: true, stream: false } }, 57 | // @ts-ignore 58 | { scheme: global.zoomScheme, privileges: { standard: true, secure: true } }, 59 | { scheme: 'cors-blob', privileges: { corsEnabled: true, supportFetchAPI: true } }, 60 | { scheme: 'cors', privileges: { corsEnabled: true, supportFetchAPI: true } }, 61 | { scheme: 'no-cors', privileges: { supportFetchAPI: true } }, 62 | { scheme: 'no-fetch', privileges: { corsEnabled: true } }, 63 | { scheme: 'stream', privileges: { standard: true, stream: true } }, 64 | { scheme: 'foo', privileges: { standard: true } }, 65 | { scheme: 'bar', privileges: { standard: true } }, 66 | { scheme: 'crx', privileges: { bypassCSP: true } }, 67 | ]) 68 | 69 | const cleanupTestSessions = async () => { 70 | const sessionsPath = path.join(app.getPath('userData'), 'Partitions') 71 | 72 | let sessions 73 | 74 | try { 75 | sessions = await fs.readdir(sessionsPath) 76 | } catch (e) { 77 | return // dir doesn't exist 78 | } 79 | 80 | sessions = sessions.filter((session) => session.startsWith('crx-')) 81 | if (sessions.length === 0) return 82 | 83 | console.log(`Cleaning up ${sessions.length} sessions from previous test runners`) 84 | 85 | for (const session of sessions) { 86 | const sessionPath = path.join(sessionsPath, session) 87 | await fs.rm(sessionPath, { recursive: true, force: true }) 88 | } 89 | } 90 | 91 | app 92 | .whenReady() 93 | .then(async () => { 94 | require('ts-node/register') 95 | 96 | await cleanupTestSessions() 97 | 98 | const argv = require('yargs') 99 | .boolean('ci') 100 | .array('files') 101 | .string('g') 102 | .alias('g', 'grep') 103 | .boolean('i') 104 | .alias('i', 'invert').argv 105 | 106 | const Mocha = require('mocha') 107 | const mochaOptions = {} 108 | if (process.env.MOCHA_REPORTER) { 109 | mochaOptions.reporter = process.env.MOCHA_REPORTER 110 | } 111 | if (process.env.MOCHA_MULTI_REPORTERS) { 112 | mochaOptions.reporterOptions = { 113 | reporterEnabled: process.env.MOCHA_MULTI_REPORTERS, 114 | } 115 | } 116 | const mocha = new Mocha(mochaOptions) 117 | 118 | // The cleanup method is registered this way rather than through an 119 | // `afterEach` at the top level so that it can run before other `afterEach` 120 | // methods. 121 | // 122 | // The order of events is: 123 | // 1. test completes, 124 | // 2. `defer()`-ed methods run, in reverse order, 125 | // 3. regular `afterEach` hooks run. 126 | const { runCleanupFunctions, getFiles } = require('./spec-helpers') 127 | mocha.suite.on('suite', function attach(suite) { 128 | suite.afterEach('cleanup', runCleanupFunctions) 129 | suite.on('suite', attach) 130 | }) 131 | 132 | if (!process.env.MOCHA_REPORTER) { 133 | mocha.ui('bdd').reporter('tap') 134 | } 135 | const mochaTimeout = process.env.MOCHA_TIMEOUT || 10000 136 | mocha.timeout(mochaTimeout) 137 | 138 | if (argv.grep) mocha.grep(argv.grep) 139 | if (argv.invert) mocha.invert() 140 | 141 | const filter = (file) => { 142 | if (!/-spec\.[tj]s$/.test(file)) { 143 | return false 144 | } 145 | 146 | // This allows you to run specific modules only: 147 | // npm run test -match=menu 148 | const moduleMatch = process.env.npm_config_match 149 | ? new RegExp(process.env.npm_config_match, 'g') 150 | : null 151 | if (moduleMatch && !moduleMatch.test(file)) { 152 | return false 153 | } 154 | 155 | const baseElectronDir = path.resolve(__dirname, '..') 156 | if (argv.files && !argv.files.includes(path.relative(baseElectronDir, file))) { 157 | return false 158 | } 159 | 160 | return true 161 | } 162 | 163 | const testFiles = await getFiles(__dirname, { filter }) 164 | testFiles.sort().forEach((file) => { 165 | mocha.addFile(file) 166 | }) 167 | 168 | const cb = () => { 169 | // Ensure the callback is called after runner is defined 170 | process.nextTick(() => { 171 | process.exit(runner.failures) 172 | }) 173 | } 174 | 175 | // Set up chai in the correct order 176 | const chai = require('chai') 177 | chai.use(require('chai-as-promised')) 178 | // chai.use(require('dirty-chai')); 179 | 180 | const runner = mocha.run(cb) 181 | }) 182 | .catch((err) => { 183 | console.error('An error occurred while running the spec-main spec runner') 184 | console.error(err) 185 | process.exit(1) 186 | }) 187 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/spec-helpers.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013-2020 GitHub Inc. 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining 4 | // a copy of this software and associated documentation files (the 5 | // "Software"), to deal in the Software without restriction, including 6 | // without limitation the rights to use, copy, modify, merge, publish, 7 | // distribute, sublicense, and/or sell copies of the Software, and to 8 | // permit persons to whom the Software is furnished to do so, subject to 9 | // the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be 12 | // included in all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | import * as childProcess from 'node:child_process' 23 | import * as nodeCrypto from 'node:crypto' 24 | import * as path from 'node:path' 25 | import * as http from 'node:http' 26 | import * as v8 from 'v8' 27 | import { SuiteFunction, TestFunction } from 'mocha' 28 | 29 | const addOnly = (fn: Function): T => { 30 | const wrapped = (...args: any[]) => { 31 | return fn(...args) 32 | } 33 | ;(wrapped as any).only = wrapped 34 | ;(wrapped as any).skip = wrapped 35 | return wrapped as any 36 | } 37 | 38 | export const ifit = (condition: boolean) => (condition ? it : addOnly(it.skip)) 39 | export const ifdescribe = (condition: boolean) => 40 | condition ? describe : addOnly(describe.skip) 41 | 42 | export const delay = (time: number = 0) => new Promise((resolve) => setTimeout(resolve, time)) 43 | 44 | type CleanupFunction = (() => void) | (() => Promise) 45 | const cleanupFunctions: CleanupFunction[] = [] 46 | export async function runCleanupFunctions() { 47 | for (const cleanup of cleanupFunctions) { 48 | const r = cleanup() 49 | if (r instanceof Promise) { 50 | await r 51 | } 52 | } 53 | cleanupFunctions.length = 0 54 | } 55 | 56 | export function defer(f: CleanupFunction) { 57 | cleanupFunctions.unshift(f) 58 | } 59 | 60 | class RemoteControlApp { 61 | process: childProcess.ChildProcess 62 | port: number 63 | 64 | constructor(proc: childProcess.ChildProcess, port: number) { 65 | this.process = proc 66 | this.port = port 67 | } 68 | 69 | remoteEval = (js: string): Promise => { 70 | return new Promise((resolve, reject) => { 71 | const req = http.request( 72 | { 73 | host: '127.0.0.1', 74 | port: this.port, 75 | method: 'POST', 76 | }, 77 | (res) => { 78 | const chunks = [] as Buffer[] 79 | res.on('data', (chunk) => { 80 | chunks.push(chunk) 81 | }) 82 | res.on('end', () => { 83 | const ret = v8.deserialize(Buffer.concat(chunks)) 84 | if (Object.prototype.hasOwnProperty.call(ret, 'error')) { 85 | reject(new Error(`remote error: ${ret.error}\n\nTriggered at:`)) 86 | } else { 87 | resolve(ret.result) 88 | } 89 | }) 90 | }, 91 | ) 92 | req.write(js) 93 | req.end() 94 | }) 95 | } 96 | 97 | remotely = (script: Function, ...args: any[]): Promise => { 98 | return this.remoteEval(`(${script})(...${JSON.stringify(args)})`) 99 | } 100 | } 101 | 102 | export async function startRemoteControlApp() { 103 | const appPath = path.join(__dirname, 'fixtures', 'apps', 'remote-control') 104 | const appProcess = childProcess.spawn(process.execPath, [appPath]) 105 | appProcess.stderr.on('data', (d) => { 106 | process.stderr.write(d) 107 | }) 108 | const port = await new Promise((resolve) => { 109 | appProcess.stdout.on('data', (d) => { 110 | const m = /Listening: (\d+)/.exec(d.toString()) 111 | if (m && m[1] != null) { 112 | resolve(Number(m[1])) 113 | } 114 | }) 115 | }) 116 | defer(() => { 117 | appProcess.kill('SIGINT') 118 | }) 119 | return new RemoteControlApp(appProcess, port) 120 | } 121 | 122 | export async function getFiles(directoryPath: string, { filter = null }: any = {}) { 123 | const files: string[] = [] 124 | const walker = require('walkdir').walk(directoryPath, { 125 | no_recurse: true, 126 | }) 127 | walker.on('file', (file: string) => { 128 | if (!filter || filter(file)) { 129 | files.push(file) 130 | } 131 | }) 132 | await new Promise((resolve) => walker.on('end', resolve)) 133 | return files 134 | } 135 | 136 | export const uuid = () => nodeCrypto.randomUUID() 137 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/spec/window-helpers.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013-2020 GitHub Inc. 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining 4 | // a copy of this software and associated documentation files (the 5 | // "Software"), to deal in the Software without restriction, including 6 | // without limitation the rights to use, copy, modify, merge, publish, 7 | // distribute, sublicense, and/or sell copies of the Software, and to 8 | // permit persons to whom the Software is furnished to do so, subject to 9 | // the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be 12 | // included in all copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | import { expect } from 'chai' 23 | import { BrowserWindow } from 'electron/main' 24 | import { emittedOnce } from './events-helpers' 25 | 26 | async function ensureWindowIsClosed(window: BrowserWindow | null) { 27 | if (window && !window.isDestroyed()) { 28 | if (window.webContents && !window.webContents.isDestroyed()) { 29 | // If a window isn't destroyed already, and it has non-destroyed WebContents, 30 | // then calling destroy() won't immediately destroy it, as it may have 31 | // children which need to be destroyed first. In that case, we 32 | // await the 'closed' event which signals the complete shutdown of the 33 | // window. 34 | const isClosed = emittedOnce(window, 'closed') 35 | window.destroy() 36 | await isClosed 37 | } else { 38 | // If there's no WebContents or if the WebContents is already destroyed, 39 | // then the 'closed' event has already been emitted so there's nothing to 40 | // wait for. 41 | window.destroy() 42 | } 43 | } 44 | } 45 | 46 | export const closeWindow = async ( 47 | window: BrowserWindow | null = null, 48 | { assertNotWindows } = { assertNotWindows: true }, 49 | ) => { 50 | await ensureWindowIsClosed(window) 51 | 52 | if (assertNotWindows) { 53 | const windows = BrowserWindow.getAllWindows() 54 | try { 55 | expect(windows).to.have.lengthOf(0) 56 | } finally { 57 | for (const win of windows) { 58 | await ensureWindowIsClosed(win) 59 | } 60 | } 61 | } 62 | } 63 | 64 | export async function closeAllWindows() { 65 | for (const w of BrowserWindow.getAllWindows()) { 66 | await closeWindow(w, { assertNotWindows: false }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/commands.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from '../context' 2 | import { ExtensionEvent } from '../router' 3 | 4 | export class CommandsAPI { 5 | private commandMap = new Map* extensionId */ string, chrome.commands.Command[]>() 6 | 7 | constructor(private ctx: ExtensionContext) { 8 | const handle = this.ctx.router.apiHandler() 9 | handle('commands.getAll', this.getAll) 10 | 11 | ctx.session.on('extension-loaded', (_event, extension) => { 12 | this.processExtension(extension) 13 | }) 14 | 15 | ctx.session.on('extension-unloaded', (_event, extension) => { 16 | this.removeCommands(extension) 17 | }) 18 | } 19 | 20 | private processExtension(extension: Electron.Extension) { 21 | const manifest: chrome.runtime.Manifest = extension.manifest 22 | if (!manifest.commands) return 23 | 24 | if (!this.commandMap.has(extension.id)) { 25 | this.commandMap.set(extension.id, []) 26 | } 27 | const commands = this.commandMap.get(extension.id)! 28 | 29 | for (const [name, details] of Object.entries(manifest.commands!)) { 30 | // TODO: attempt to register commands 31 | commands.push({ 32 | name, 33 | description: details.description, 34 | shortcut: '', 35 | }) 36 | } 37 | } 38 | 39 | private removeCommands(extension: Electron.Extension) { 40 | this.commandMap.delete(extension.id) 41 | } 42 | 43 | private getAll = ({ extension }: ExtensionEvent): chrome.commands.Command[] => { 44 | return this.commandMap.get(extension.id) || [] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/common.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs' 2 | import * as path from 'node:path' 3 | import { BaseWindow, BrowserWindow, nativeImage } from 'electron' 4 | 5 | export interface TabContents extends Electron.WebContents { 6 | favicon?: string 7 | } 8 | 9 | export type ContextMenuType = 10 | | 'all' 11 | | 'page' 12 | | 'frame' 13 | | 'selection' 14 | | 'link' 15 | | 'editable' 16 | | 'image' 17 | | 'video' 18 | | 'audio' 19 | | 'launcher' 20 | | 'browser_action' 21 | | 'page_action' 22 | | 'action' 23 | 24 | /** 25 | * Get the extension's properly typed Manifest. 26 | * 27 | * I can't seem to get TS's merged type declarations working so I'm using this 28 | * instead for now. 29 | */ 30 | export const getExtensionManifest = (extension: Electron.Extension): chrome.runtime.Manifest => 31 | extension.manifest 32 | 33 | export const getExtensionUrl = (extension: Electron.Extension, uri: string) => { 34 | try { 35 | return new URL(uri, extension.url).href 36 | } catch {} 37 | } 38 | 39 | export const resolveExtensionPath = ( 40 | extension: Electron.Extension, 41 | uri: string, 42 | requestPath?: string, 43 | ) => { 44 | // Resolve any relative paths. 45 | const relativePath = path.join(requestPath || '/', uri) 46 | const resPath = path.join(extension.path, relativePath) 47 | 48 | // prevent any parent traversals 49 | if (!resPath.startsWith(extension.path)) return 50 | 51 | return resPath 52 | } 53 | 54 | export const validateExtensionResource = async (extension: Electron.Extension, uri: string) => { 55 | const resPath = resolveExtensionPath(extension, uri) 56 | if (!resPath) return 57 | 58 | try { 59 | await fs.stat(resPath) 60 | } catch { 61 | return // doesn't exist 62 | } 63 | 64 | return resPath 65 | } 66 | 67 | export enum ResizeType { 68 | Exact, 69 | Up, 70 | Down, 71 | } 72 | 73 | export const matchSize = ( 74 | imageSet: { [key: number]: string }, 75 | size: number, 76 | match: ResizeType, 77 | ): string | undefined => { 78 | // TODO: match based on size 79 | const first = parseInt(Object.keys(imageSet).pop()!, 10) 80 | return imageSet[first] 81 | } 82 | 83 | /** Gets the relative path to the extension's default icon. */ 84 | export const getIconPath = ( 85 | extension: Electron.Extension, 86 | iconSize: number = 32, 87 | resizeType = ResizeType.Up, 88 | ) => { 89 | const manifest = getExtensionManifest(extension) 90 | const { icons } = manifest 91 | 92 | const default_icon: chrome.runtime.ManifestIcons | undefined = ( 93 | manifest.manifest_version === 3 ? manifest.action : manifest.browser_action 94 | )?.default_icon 95 | 96 | if (typeof default_icon === 'string') { 97 | const iconPath = default_icon 98 | return iconPath 99 | } else if (typeof default_icon === 'object') { 100 | const iconPath = matchSize(default_icon, iconSize, resizeType) 101 | return iconPath 102 | } else if (typeof icons === 'object') { 103 | const iconPath = matchSize(icons, iconSize, resizeType) 104 | return iconPath 105 | } 106 | } 107 | 108 | export const getIconImage = (extension: Electron.Extension) => { 109 | const iconPath = getIconPath(extension) 110 | const iconAbsolutePath = iconPath && resolveExtensionPath(extension, iconPath) 111 | return iconAbsolutePath ? nativeImage.createFromPath(iconAbsolutePath) : undefined 112 | } 113 | 114 | const escapePattern = (pattern: string) => pattern.replace(/[\\^$+?.()|[\]{}]/g, '\\$&') 115 | 116 | /** 117 | * @see https://developer.chrome.com/extensions/match_patterns 118 | */ 119 | export const matchesPattern = (pattern: string, url: string) => { 120 | if (pattern === '') return true 121 | const regexp = new RegExp(`^${pattern.split('*').map(escapePattern).join('.*')}$`) 122 | return url.match(regexp) 123 | } 124 | 125 | export const matchesTitlePattern = (pattern: string, title: string) => { 126 | const regexp = new RegExp(`^${pattern.split('*').map(escapePattern).join('.*')}$`) 127 | return title.match(regexp) 128 | } 129 | 130 | export const getAllWindows = () => [...BaseWindow.getAllWindows(), ...BrowserWindow.getAllWindows()] 131 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/cookies.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from '../context' 2 | import { ExtensionEvent } from '../router' 3 | 4 | enum CookieStoreID { 5 | Default = '0', 6 | Incognito = '1', 7 | } 8 | 9 | const onChangedCauseTranslation: { [key: string]: string } = { 10 | 'expired-overwrite': 'expired_overwrite', 11 | } 12 | 13 | const createCookieDetails = (cookie: Electron.Cookie): chrome.cookies.Cookie => ({ 14 | ...cookie, 15 | domain: cookie.domain || '', 16 | hostOnly: Boolean(cookie.hostOnly), 17 | session: Boolean(cookie.session), 18 | path: cookie.path || '', 19 | httpOnly: Boolean(cookie.httpOnly), 20 | secure: Boolean(cookie.secure), 21 | storeId: CookieStoreID.Default, 22 | }) 23 | 24 | export class CookiesAPI { 25 | private get cookies() { 26 | return this.ctx.session.cookies 27 | } 28 | 29 | constructor(private ctx: ExtensionContext) { 30 | const handle = this.ctx.router.apiHandler() 31 | handle('cookies.get', this.get.bind(this)) 32 | handle('cookies.getAll', this.getAll.bind(this)) 33 | handle('cookies.set', this.set.bind(this)) 34 | handle('cookies.remove', this.remove.bind(this)) 35 | handle('cookies.getAllCookieStores', this.getAllCookieStores.bind(this)) 36 | 37 | this.cookies.addListener('changed', this.onChanged) 38 | } 39 | 40 | private async get( 41 | event: ExtensionEvent, 42 | details: chrome.cookies.CookieDetails, 43 | ): Promise { 44 | // TODO: storeId 45 | const cookies = await this.cookies.get({ 46 | url: details.url, 47 | name: details.name, 48 | }) 49 | 50 | // TODO: If more than one cookie of the same name exists for the given URL, 51 | // the one with the longest path will be returned. For cookies with the 52 | // same path length, the cookie with the earliest creation time will be returned. 53 | return cookies.length > 0 ? createCookieDetails(cookies[0]) : null 54 | } 55 | 56 | private async getAll( 57 | event: ExtensionEvent, 58 | details: chrome.cookies.GetAllDetails, 59 | ): Promise { 60 | // TODO: storeId 61 | const cookies = await this.cookies.get({ 62 | url: details.url, 63 | name: details.name, 64 | domain: details.domain, 65 | path: details.path, 66 | secure: details.secure, 67 | session: details.session, 68 | }) 69 | 70 | return cookies.map(createCookieDetails) 71 | } 72 | 73 | private async set( 74 | event: ExtensionEvent, 75 | details: chrome.cookies.SetDetails, 76 | ): Promise { 77 | await this.cookies.set(details) 78 | const cookies = await this.cookies.get(details) 79 | return cookies.length > 0 ? createCookieDetails(cookies[0]) : null 80 | } 81 | 82 | private async remove( 83 | event: ExtensionEvent, 84 | details: chrome.cookies.CookieDetails, 85 | ): Promise { 86 | try { 87 | await this.cookies.remove(details.url, details.name) 88 | } catch { 89 | return null 90 | } 91 | return details 92 | } 93 | 94 | private async getAllCookieStores(event: ExtensionEvent): Promise { 95 | const tabIds = Array.from(this.ctx.store.tabs) 96 | .map((tab) => (tab.isDestroyed() ? undefined : tab.id)) 97 | .filter(Boolean) as number[] 98 | return [{ id: CookieStoreID.Default, tabIds }] 99 | } 100 | 101 | private onChanged = ( 102 | event: Electron.Event, 103 | cookie: Electron.Cookie, 104 | cause: string, 105 | removed: boolean, 106 | ) => { 107 | const changeInfo: chrome.cookies.CookieChangeInfo = { 108 | cause: onChangedCauseTranslation[cause] || cause, 109 | cookie: createCookieDetails(cookie), 110 | removed, 111 | } 112 | 113 | this.ctx.router.broadcastEvent('cookies.onChanged', changeInfo) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process' 2 | import { promises as fs } from 'node:fs' 3 | import * as os from 'node:os' 4 | import * as path from 'node:path' 5 | import { app } from 'electron' 6 | import debug from 'debug' 7 | import { ExtensionSender, IpcEvent } from '../../router' 8 | import { readRegistryKey } from './winreg' 9 | 10 | const d = debug('electron-chrome-extensions:nativeMessaging') 11 | 12 | interface NativeConfig { 13 | name: string 14 | description: string 15 | path: string 16 | type: 'stdio' 17 | allowed_origins: string[] 18 | } 19 | 20 | function isValidConfig(config: any): config is NativeConfig { 21 | return ( 22 | typeof config === 'object' && 23 | config !== null && 24 | typeof config.name === 'string' && 25 | typeof config.description === 'string' && 26 | typeof config.path === 'string' && 27 | config.type === 'stdio' && 28 | Array.isArray(config.allowed_origins) 29 | ) 30 | } 31 | 32 | async function getConfigSearchPaths(application: string) { 33 | const appJson = `${application}.json` 34 | let searchPaths: string[] 35 | switch (process.platform) { 36 | case 'darwin': 37 | searchPaths = [ 38 | path.join('/Library/Google/Chrome/NativeMessagingHosts', appJson), 39 | // Also look under Chrome's directory since some apps only install their 40 | // config there 41 | path.join( 42 | os.homedir(), 43 | 'Library', 44 | 'Application Support', 45 | 'Google/Chrome/NativeMessagingHosts', 46 | appJson, 47 | ), 48 | path.join(app.getPath('userData'), 'NativeMessagingHosts', appJson), 49 | ] 50 | break 51 | case 'linux': 52 | searchPaths = [ 53 | path.join('/etc/opt/chrome/native-messaging-hosts/', appJson), 54 | path.join(os.homedir(), '.config/google-chrome/NativeMessagingHosts/', appJson), 55 | path.join(app.getPath('userData'), 'NativeMessagingHosts', appJson), 56 | ] 57 | break 58 | case 'win32': { 59 | searchPaths = ( 60 | await Promise.allSettled([ 61 | readRegistryKey('HKLM', `Software\\Google\\Chrome\\NativeMessagingHosts\\${application}`), 62 | readRegistryKey('HKCU', `Software\\Google\\Chrome\\NativeMessagingHosts\\${application}`), 63 | ]) 64 | ) 65 | .map((result) => (result.status === 'fulfilled' ? result.value : undefined)) 66 | .filter(Boolean) as string[] 67 | break 68 | } 69 | default: 70 | throw new Error('Unsupported platform') 71 | } 72 | return searchPaths 73 | } 74 | 75 | async function readNativeMessagingHostConfig( 76 | application: string, 77 | ): Promise { 78 | const searchPaths = await getConfigSearchPaths(application) 79 | d('searching', searchPaths) 80 | for (const filePath of searchPaths) { 81 | try { 82 | const data = await fs.readFile(filePath) 83 | const config = JSON.parse(data.toString()) 84 | if (isValidConfig(config)) { 85 | d('read config in %s', filePath, config) 86 | return config 87 | } else { 88 | d('%s contained invalid config', filePath, config) 89 | } 90 | } catch (error) { 91 | if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { 92 | d('unable to read %s', filePath) 93 | } else { 94 | d('unknown error', error) 95 | } 96 | continue 97 | } 98 | } 99 | } 100 | export class NativeMessagingHost { 101 | private process?: ReturnType 102 | private sender: ExtensionSender 103 | private connectionId: string 104 | private connected: boolean = false 105 | private pending?: any[] 106 | private keepAlive: boolean 107 | private resolveResponse?: (message: any) => void 108 | 109 | ready?: Promise 110 | 111 | constructor( 112 | extensionId: string, 113 | sender: ExtensionSender, 114 | connectionId: string, 115 | application: string, 116 | keepAlive: boolean = true, 117 | ) { 118 | this.keepAlive = keepAlive 119 | this.sender = sender 120 | if (keepAlive) { 121 | this.sender.ipc.on(`crx-native-msg-${connectionId}`, this.receiveExtensionMessage) 122 | } 123 | this.connectionId = connectionId 124 | this.ready = this.launch(application, extensionId) 125 | } 126 | 127 | destroy() { 128 | if (!this.connected) return 129 | this.connected = false 130 | if (this.process) { 131 | this.process.kill() 132 | this.process = undefined 133 | } 134 | if (this.keepAlive) { 135 | this.sender.ipc.off(`crx-native-msg-${this.connectionId}`, this.receiveExtensionMessage) 136 | this.sender.send(`crx-native-msg-${this.connectionId}-disconnect`) 137 | } 138 | } 139 | 140 | private async launch(application: string, extensionId: string) { 141 | const config = await readNativeMessagingHostConfig(application) 142 | if (!config) { 143 | d('launch: unable to find %s for %s', application, extensionId) 144 | this.destroy() 145 | return 146 | } 147 | 148 | const extensionUrl = `chrome-extension://${extensionId}/` 149 | if (!config.allowed_origins?.includes(extensionUrl)) { 150 | d('launch: %s not in allowed origins', extensionId) 151 | this.destroy() 152 | return 153 | } 154 | 155 | let isFile = false 156 | try { 157 | const stat = await fs.stat(config.path) 158 | isFile = stat.isFile() 159 | } catch (error) { 160 | d('launch: unable to find %s', config.path, error) 161 | } 162 | 163 | if (!isFile) { 164 | this.destroy() 165 | return 166 | } 167 | 168 | d('launch: spawning %s for %s', config.path, extensionId) 169 | this.process = spawn(config.path, [extensionUrl], { 170 | shell: false, 171 | }) 172 | 173 | this.process.stdout!.on('data', this.receive) 174 | this.process.stderr!.on('data', (data) => { 175 | d('stderr: %s', data.toString()) 176 | }) 177 | this.process.on('error', (err) => { 178 | d('error: %s', err) 179 | this.destroy() 180 | }) 181 | this.process.on('exit', (code) => { 182 | d('exited %d', code) 183 | this.destroy() 184 | }) 185 | 186 | this.connected = true 187 | 188 | if (this.pending && this.pending.length > 0) { 189 | d('sending %d pending messages', this.pending.length) 190 | this.pending.forEach((msg) => this.send(msg)) 191 | this.pending = [] 192 | } 193 | } 194 | 195 | private receiveExtensionMessage = (_event: IpcEvent, message: any) => { 196 | this.send(message) 197 | } 198 | 199 | private send(json: any) { 200 | d('send', json) 201 | 202 | if (!this.connected) { 203 | const pending = this.pending || (this.pending = []) 204 | pending.push(json) 205 | d('send: pending') 206 | return 207 | } 208 | 209 | const message = JSON.stringify(json) 210 | const buffer = Buffer.alloc(4 + message.length) 211 | buffer.writeUInt32LE(message.length, 0) 212 | buffer.write(message, 4) 213 | this.process!.stdin!.write(buffer) 214 | } 215 | 216 | private receive = (data: Buffer) => { 217 | const length = data.readUInt32LE(0) 218 | const message = JSON.parse(data.subarray(4, 4 + length).toString()) 219 | d('receive: %s', message) 220 | if (this.keepAlive) { 221 | this.sender.send(`crx-native-msg-${this.connectionId}`, message) 222 | } else { 223 | this.resolveResponse?.(message) 224 | } 225 | } 226 | 227 | sendAndReceive(message: any) { 228 | this.send(message) 229 | return new Promise((resolve) => { 230 | this.resolveResponse = resolve 231 | }) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/lib/winreg.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import debug from 'debug' 3 | 4 | const d = debug('electron-chrome-extensions:winreg') 5 | 6 | export function readRegistryKey(hive: string, path: string, key?: string) { 7 | if (process.platform !== 'win32') { 8 | return Promise.reject('Unsupported platform') 9 | } 10 | 11 | return new Promise((resolve, reject) => { 12 | const args = ['query', `${hive}\\${path}`, ...(key ? ['/v', key] : [])] 13 | d('reg %s', args.join(' ')) 14 | const child = spawn('reg', args) 15 | 16 | let output = '' 17 | let error = '' 18 | 19 | child.stdout.on('data', (data) => { 20 | output += data.toString() 21 | }) 22 | 23 | child.stderr.on('data', (data) => { 24 | error += data.toString() 25 | }) 26 | 27 | child.on('close', (code) => { 28 | if (code !== 0 || error) { 29 | return reject(new Error(`Failed to read registry: ${error}`)) 30 | } 31 | 32 | const lines = output.trim().split('\n') 33 | const resultLine = lines.find((line) => 34 | key ? line.includes(key) : line.includes('(Default)'), 35 | ) 36 | 37 | if (resultLine) { 38 | const parts = resultLine.trim().split(/\s{2,}/) 39 | resolve(parts.pop() || null) 40 | } else { 41 | resolve(null) 42 | } 43 | }) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/notifications.ts: -------------------------------------------------------------------------------- 1 | import { app, Extension, Notification } from 'electron' 2 | import { ExtensionContext } from '../context' 3 | import { ExtensionEvent } from '../router' 4 | import { validateExtensionResource } from './common' 5 | 6 | enum TemplateType { 7 | Basic = 'basic', 8 | Image = 'image', 9 | List = 'list', 10 | Progress = 'progress', 11 | } 12 | 13 | const getBody = (opts: chrome.notifications.NotificationOptions) => { 14 | const { type = TemplateType.Basic } = opts 15 | 16 | switch (type) { 17 | case TemplateType.List: { 18 | if (!Array.isArray(opts.items)) { 19 | throw new Error('List items must be provided for list type') 20 | } 21 | return opts.items.map((item) => `${item.title} - ${item.message}`).join('\n') 22 | } 23 | default: 24 | return opts.message || '' 25 | } 26 | } 27 | 28 | const getUrgency = ( 29 | priority?: number, 30 | ): Required['urgency'] => { 31 | if (typeof priority !== 'number') { 32 | return 'normal' 33 | } else if (priority >= 2) { 34 | return 'critical' 35 | } else if (priority < 0) { 36 | return 'low' 37 | } else { 38 | return 'normal' 39 | } 40 | } 41 | 42 | const createScopedIdentifier = (extension: Extension, id: string) => `${extension.id}-${id}` 43 | const stripScopeFromIdentifier = (id: string) => { 44 | const index = id.indexOf('-') 45 | return id.substr(index + 1) 46 | } 47 | 48 | export class NotificationsAPI { 49 | private registry = new Map() 50 | 51 | constructor(private ctx: ExtensionContext) { 52 | const handle = this.ctx.router.apiHandler() 53 | handle('notifications.clear', this.clear) 54 | handle('notifications.create', this.create) 55 | handle('notifications.getAll', this.getAll) 56 | handle('notifications.getPermissionLevel', this.getPermissionLevel) 57 | handle('notifications.update', this.update) 58 | 59 | this.ctx.session.on('extension-unloaded', (event, extension) => { 60 | for (const [key, notification] of this.registry) { 61 | if (key.startsWith(extension.id)) { 62 | notification.close() 63 | } 64 | } 65 | }) 66 | } 67 | 68 | private clear = ({ extension }: ExtensionEvent, id: string) => { 69 | const notificationId = createScopedIdentifier(extension, id) 70 | if (this.registry.has(notificationId)) { 71 | this.registry.get(notificationId)?.close() 72 | } 73 | } 74 | 75 | private create = async ({ extension }: ExtensionEvent, arg1: unknown, arg2?: unknown) => { 76 | let id: string 77 | let opts: chrome.notifications.NotificationOptions 78 | 79 | if (typeof arg1 === 'object') { 80 | id = 'guid' // TODO: generate uuid 81 | opts = arg1 as chrome.notifications.NotificationOptions 82 | } else if (typeof arg1 === 'string') { 83 | id = arg1 84 | opts = arg2 as chrome.notifications.NotificationOptions 85 | } else { 86 | throw new Error('Invalid arguments') 87 | } 88 | 89 | if (typeof opts !== 'object' || !opts.type || !opts.iconUrl || !opts.title || !opts.message) { 90 | throw new Error('Missing required notification options') 91 | } 92 | 93 | const notificationId = createScopedIdentifier(extension, id) 94 | 95 | if (this.registry.has(notificationId)) { 96 | this.registry.get(notificationId)?.close() 97 | } 98 | 99 | let icon 100 | 101 | if (opts.iconUrl) { 102 | let url 103 | try { 104 | url = new URL(opts.iconUrl) 105 | } catch {} 106 | 107 | if (url?.protocol === 'data:') { 108 | icon = opts.iconUrl 109 | } else { 110 | icon = await validateExtensionResource(extension, opts.iconUrl) 111 | } 112 | 113 | if (!icon) { 114 | throw new Error('Invalid iconUrl') 115 | } 116 | } 117 | 118 | // TODO: buttons, template types 119 | 120 | const notification = new Notification({ 121 | title: opts.title, 122 | subtitle: app.name, 123 | body: getBody(opts), 124 | silent: opts.silent, 125 | icon, 126 | urgency: getUrgency(opts.priority), 127 | timeoutType: opts.requireInteraction ? 'never' : 'default', 128 | }) 129 | 130 | this.registry.set(notificationId, notification) 131 | 132 | notification.on('click', () => { 133 | this.ctx.router.sendEvent(extension.id, 'notifications.onClicked', id) 134 | }) 135 | 136 | notification.once('close', () => { 137 | const byUser = true // TODO 138 | this.ctx.router.sendEvent(extension.id, 'notifications.onClosed', id, byUser) 139 | this.registry.delete(notificationId) 140 | }) 141 | 142 | notification.show() 143 | 144 | return id 145 | } 146 | 147 | private getAll = ({ extension }: ExtensionEvent) => { 148 | return Array.from(this.registry.keys()) 149 | .filter((key) => key.startsWith(extension.id)) 150 | .map(stripScopeFromIdentifier) 151 | } 152 | 153 | private getPermissionLevel = (event: ExtensionEvent) => { 154 | return Notification.isSupported() ? 'granted' : 'denied' 155 | } 156 | 157 | private update = ( 158 | { extension }: ExtensionEvent, 159 | id: string, 160 | opts: chrome.notifications.NotificationOptions, 161 | ) => { 162 | const notificationId = createScopedIdentifier(extension, id) 163 | 164 | const notification = this.registry.get(notificationId) 165 | 166 | if (!notification) { 167 | return false 168 | } 169 | 170 | // TODO: remaining opts 171 | 172 | if (opts.priority) notification.urgency = getUrgency(opts.priority) 173 | if (opts.silent) notification.silent = opts.silent 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/permissions.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from '../context' 2 | import { ExtensionEvent } from '../router' 3 | 4 | /** 5 | * This is a very basic implementation of the permissions API. Likely 6 | * more work will be needed to integrate with the native permissions. 7 | */ 8 | export class PermissionsAPI { 9 | private permissionMap = new Map< 10 | /* extensionId */ string, 11 | { 12 | permissions: chrome.runtime.ManifestPermissions[] 13 | origins: string[] 14 | } 15 | >() 16 | 17 | constructor(private ctx: ExtensionContext) { 18 | const handle = this.ctx.router.apiHandler() 19 | handle('permissions.contains', this.contains) 20 | handle('permissions.getAll', this.getAll) 21 | handle('permissions.remove', this.remove) 22 | handle('permissions.request', this.request) 23 | 24 | ctx.session.getAllExtensions().forEach((ext) => this.processExtension(ext)) 25 | 26 | ctx.session.on('extension-loaded', (_event, extension) => { 27 | this.processExtension(extension) 28 | }) 29 | 30 | ctx.session.on('extension-unloaded', (_event, extension) => { 31 | this.permissionMap.delete(extension.id) 32 | }) 33 | } 34 | 35 | private processExtension(extension: Electron.Extension) { 36 | const manifest: chrome.runtime.Manifest = extension.manifest 37 | this.permissionMap.set(extension.id, { 38 | permissions: (manifest.permissions || []) as chrome.runtime.ManifestPermissions[], 39 | origins: manifest.host_permissions || [], 40 | }) 41 | } 42 | 43 | private contains = ( 44 | { extension }: ExtensionEvent, 45 | permissions: chrome.permissions.Permissions, 46 | ) => { 47 | const currentPermissions = this.permissionMap.get(extension.id)! 48 | const hasPermissions = permissions.permissions 49 | ? permissions.permissions.every((permission) => 50 | currentPermissions.permissions.includes(permission), 51 | ) 52 | : true 53 | const hasOrigins = permissions.origins 54 | ? permissions.origins.every((origin) => currentPermissions.origins.includes(origin)) 55 | : true 56 | return hasPermissions && hasOrigins 57 | } 58 | 59 | private getAll = ({ extension }: ExtensionEvent) => { 60 | return this.permissionMap.get(extension.id) 61 | } 62 | 63 | private remove = ({ extension }: ExtensionEvent, permissions: chrome.permissions.Permissions) => { 64 | // TODO 65 | return true 66 | } 67 | 68 | private request = async ( 69 | { extension }: ExtensionEvent, 70 | request: chrome.permissions.Permissions, 71 | ) => { 72 | const declaredPermissions = new Set([ 73 | ...(extension.manifest.permissions || []), 74 | ...(extension.manifest.optional_permissions || []), 75 | ]) 76 | 77 | if (request.permissions && !request.permissions.every((p) => declaredPermissions.has(p))) { 78 | throw new Error('Permissions request includes undeclared permission') 79 | } 80 | 81 | const granted = await this.ctx.store.requestPermissions(extension, request) 82 | if (!granted) return false 83 | 84 | const permissions = this.permissionMap.get(extension.id)! 85 | if (request.origins) { 86 | for (const origin of request.origins) { 87 | if (!permissions.origins.includes(origin)) { 88 | permissions.origins.push(origin) 89 | } 90 | } 91 | } 92 | if (request.permissions) { 93 | for (const permission of request.permissions) { 94 | if (!permissions.permissions.includes(permission)) { 95 | permissions.permissions.push(permission) 96 | } 97 | } 98 | } 99 | return true 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/runtime.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { EventEmitter } from 'node:events' 3 | import { ExtensionContext } from '../context' 4 | import { ExtensionEvent } from '../router' 5 | import { getExtensionManifest } from './common' 6 | import { NativeMessagingHost } from './lib/native-messaging-host' 7 | 8 | export class RuntimeAPI extends EventEmitter { 9 | private hostMap: Record = {} 10 | 11 | constructor(private ctx: ExtensionContext) { 12 | super() 13 | 14 | const handle = this.ctx.router.apiHandler() 15 | handle('runtime.connectNative', this.connectNative, { permission: 'nativeMessaging' }) 16 | handle('runtime.disconnectNative', this.disconnectNative, { permission: 'nativeMessaging' }) 17 | handle('runtime.openOptionsPage', this.openOptionsPage) 18 | handle('runtime.sendNativeMessage', this.sendNativeMessage, { permission: 'nativeMessaging' }) 19 | } 20 | 21 | private connectNative = async ( 22 | event: ExtensionEvent, 23 | connectionId: string, 24 | application: string, 25 | ) => { 26 | const host = new NativeMessagingHost( 27 | event.extension.id, 28 | event.sender!, 29 | connectionId, 30 | application, 31 | ) 32 | this.hostMap[connectionId] = host 33 | } 34 | 35 | private disconnectNative = (event: ExtensionEvent, connectionId: string) => { 36 | this.hostMap[connectionId]?.destroy() 37 | this.hostMap[connectionId] = undefined 38 | } 39 | 40 | private sendNativeMessage = async (event: ExtensionEvent, application: string, message: any) => { 41 | const connectionId = randomUUID() 42 | const host = new NativeMessagingHost( 43 | event.extension.id, 44 | event.sender!, 45 | connectionId, 46 | application, 47 | false, 48 | ) 49 | await host.ready 50 | return await host.sendAndReceive(message) 51 | } 52 | 53 | private openOptionsPage = async ({ extension }: ExtensionEvent) => { 54 | // TODO: options page shouldn't appear in Tabs API 55 | // https://developer.chrome.com/extensions/options#tabs-api 56 | 57 | const manifest = getExtensionManifest(extension) 58 | 59 | if (manifest.options_ui) { 60 | // Embedded option not support (!options_ui.open_in_new_tab) 61 | const url = `chrome-extension://${extension.id}/${manifest.options_ui.page}` 62 | await this.ctx.store.createTab({ url, active: true }) 63 | } else if (manifest.options_page) { 64 | const url = `chrome-extension://${extension.id}/${manifest.options_page}` 65 | await this.ctx.store.createTab({ url, active: true }) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/api/windows.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from '../context' 2 | import { ExtensionEvent } from '../router' 3 | import debug from 'debug' 4 | 5 | const d = debug('electron-chrome-extensions:windows') 6 | 7 | const getWindowState = (win: Electron.BaseWindow): chrome.windows.Window['state'] => { 8 | if (win.isMaximized()) return 'maximized' 9 | if (win.isMinimized()) return 'minimized' 10 | if (win.isFullScreen()) return 'fullscreen' 11 | return 'normal' 12 | } 13 | 14 | export class WindowsAPI { 15 | static WINDOW_ID_NONE = -1 16 | static WINDOW_ID_CURRENT = -2 17 | 18 | constructor(private ctx: ExtensionContext) { 19 | const handle = this.ctx.router.apiHandler() 20 | handle('windows.get', this.get.bind(this)) 21 | // TODO: how does getCurrent differ from getLastFocused? 22 | handle('windows.getCurrent', this.getLastFocused.bind(this)) 23 | handle('windows.getLastFocused', this.getLastFocused.bind(this)) 24 | handle('windows.getAll', this.getAll.bind(this)) 25 | handle('windows.create', this.create.bind(this)) 26 | handle('windows.update', this.update.bind(this)) 27 | handle('windows.remove', this.remove.bind(this)) 28 | 29 | this.ctx.store.on('window-added', this.observeWindow.bind(this)) 30 | } 31 | 32 | private observeWindow(window: Electron.BrowserWindow) { 33 | const windowId = window.id 34 | 35 | window.on('focus', () => { 36 | this.onFocusChanged(windowId) 37 | }) 38 | 39 | window.on('resized', () => { 40 | this.onBoundsChanged(windowId) 41 | }) 42 | 43 | window.once('closed', () => { 44 | this.ctx.store.windowDetailsCache.delete(windowId) 45 | this.ctx.store.removeWindow(window) 46 | this.onRemoved(windowId) 47 | }) 48 | 49 | this.onCreated(windowId) 50 | 51 | d(`Observing window[${windowId}]`) 52 | } 53 | 54 | private createWindowDetails(win: Electron.BaseWindow) { 55 | const details: Partial = { 56 | id: win.id, 57 | focused: win.isFocused(), 58 | top: win.getPosition()[1], 59 | left: win.getPosition()[0], 60 | width: win.getSize()[0], 61 | height: win.getSize()[1], 62 | tabs: Array.from(this.ctx.store.tabs) 63 | .filter((tab) => { 64 | const ownerWindow = this.ctx.store.tabToWindow.get(tab) 65 | return ownerWindow?.isDestroyed() ? false : ownerWindow?.id === win.id 66 | }) 67 | .map((tab) => this.ctx.store.tabDetailsCache.get(tab.id) as chrome.tabs.Tab) 68 | .filter(Boolean), 69 | incognito: !this.ctx.session.isPersistent(), 70 | type: 'normal', // TODO 71 | state: getWindowState(win), 72 | alwaysOnTop: win.isAlwaysOnTop(), 73 | sessionId: 'default', // TODO 74 | } 75 | 76 | this.ctx.store.windowDetailsCache.set(win.id, details) 77 | return details 78 | } 79 | 80 | private getWindowDetails(win: Electron.BaseWindow) { 81 | if (this.ctx.store.windowDetailsCache.has(win.id)) { 82 | return this.ctx.store.windowDetailsCache.get(win.id) 83 | } 84 | const details = this.createWindowDetails(win) 85 | return details 86 | } 87 | 88 | private getWindowFromId(id: number) { 89 | if (id === WindowsAPI.WINDOW_ID_CURRENT) { 90 | return this.ctx.store.getCurrentWindow() 91 | } else { 92 | return this.ctx.store.getWindowById(id) 93 | } 94 | } 95 | 96 | private get(event: ExtensionEvent, windowId: number) { 97 | const win = this.getWindowFromId(windowId) 98 | if (!win) return { id: WindowsAPI.WINDOW_ID_NONE } 99 | return this.getWindowDetails(win) 100 | } 101 | 102 | private getLastFocused(event: ExtensionEvent) { 103 | const win = this.ctx.store.getLastFocusedWindow() 104 | return win ? this.getWindowDetails(win) : null 105 | } 106 | 107 | private getAll(event: ExtensionEvent) { 108 | return Array.from(this.ctx.store.windows).map(this.getWindowDetails.bind(this)) 109 | } 110 | 111 | private async create(event: ExtensionEvent, details: chrome.windows.CreateData) { 112 | const win = await this.ctx.store.createWindow(event, details) 113 | return this.getWindowDetails(win) 114 | } 115 | 116 | private async update( 117 | event: ExtensionEvent, 118 | windowId: number, 119 | updateProperties: chrome.windows.UpdateInfo = {}, 120 | ) { 121 | const win = this.getWindowFromId(windowId) 122 | if (!win) return 123 | 124 | const props = updateProperties 125 | 126 | if (props.state) { 127 | switch (props.state) { 128 | case 'maximized': 129 | win.maximize() 130 | break 131 | case 'minimized': 132 | win.minimize() 133 | break 134 | case 'normal': { 135 | if (win.isMinimized() || win.isMaximized()) { 136 | win.restore() 137 | } 138 | break 139 | } 140 | } 141 | } 142 | 143 | return this.createWindowDetails(win) 144 | } 145 | 146 | private async remove(event: ExtensionEvent, windowId: number = WindowsAPI.WINDOW_ID_CURRENT) { 147 | const win = this.getWindowFromId(windowId) 148 | if (!win) return 149 | const removedWindowId = win.id 150 | await this.ctx.store.removeWindow(win) 151 | this.onRemoved(removedWindowId) 152 | } 153 | 154 | onCreated(windowId: number) { 155 | const window = this.ctx.store.getWindowById(windowId) 156 | if (!window) return 157 | const windowDetails = this.getWindowDetails(window) 158 | this.ctx.router.broadcastEvent('windows.onCreated', windowDetails) 159 | } 160 | 161 | onRemoved(windowId: number) { 162 | this.ctx.router.broadcastEvent('windows.onRemoved', windowId) 163 | } 164 | 165 | onFocusChanged(windowId: number) { 166 | if (this.ctx.store.lastFocusedWindowId === windowId) return 167 | 168 | this.ctx.store.lastFocusedWindowId = windowId 169 | this.ctx.router.broadcastEvent('windows.onFocusChanged', windowId) 170 | } 171 | 172 | onBoundsChanged(windowId: number) { 173 | const window = this.ctx.store.getWindowById(windowId) 174 | if (!window) return 175 | const windowDetails = this.createWindowDetails(window) 176 | this.ctx.router.broadcastEvent('windows.onBoundsChanged', windowDetails) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/context.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events' 2 | import { ExtensionRouter } from './router' 3 | import { ExtensionStore } from './store' 4 | 5 | /** Shared context for extensions in a session. */ 6 | export interface ExtensionContext { 7 | emit: (typeof EventEmitter)['prototype']['emit'] 8 | router: ExtensionRouter 9 | session: Electron.Session 10 | store: ExtensionStore 11 | } 12 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/deps.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'debug' 2 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/impl.ts: -------------------------------------------------------------------------------- 1 | /** App-specific implementation details for extensions. */ 2 | export interface ChromeExtensionImpl { 3 | createTab?( 4 | details: chrome.tabs.CreateProperties, 5 | ): Promise<[Electron.WebContents, Electron.BaseWindow]> 6 | selectTab?(tab: Electron.WebContents, window: Electron.BaseWindow): void 7 | removeTab?(tab: Electron.WebContents, window: Electron.BaseWindow): void 8 | 9 | /** 10 | * Populate additional details to a tab descriptor which gets passed back to 11 | * background pages and content scripts. 12 | */ 13 | assignTabDetails?(details: chrome.tabs.Tab, tab: Electron.WebContents): void 14 | 15 | createWindow?(details: chrome.windows.CreateData): Promise 16 | removeWindow?(window: Electron.BaseWindow): void 17 | 18 | requestPermissions?( 19 | extension: Electron.Extension, 20 | permissions: chrome.permissions.Permissions, 21 | ): Promise 22 | } 23 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/license.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import * as nodeCrypto from 'node:crypto' 3 | import * as fs from 'node:fs' 4 | import * as path from 'node:path' 5 | 6 | const INTERNAL_LICENSE = 'internal-license-do-not-use' 7 | const VALID_LICENSES_CONST = ['GPL-3.0', 'Patron-License-2020-11-19'] as const 8 | const VALID_LICENSES = new Set(VALID_LICENSES_CONST) 9 | export type License = (typeof VALID_LICENSES_CONST)[number] 10 | 11 | /** 12 | * The following projects are not in compliance with the Patron license. 13 | * 14 | * This is included in the module as an offline check to block these projects 15 | * from freely consuming updates. 16 | */ 17 | const NONCOMPLIANT_PROJECTS = new Set([ 18 | '9588cd7085bc3ae89f2c9cf8b7dee35a77a6747b4717be3d7b6b8f395c9ca1d8', 19 | '8cf1d008c4c5d4e8a6f32de274359cf4ac02fcb82aeffae10ff0b99553c9d745', 20 | ]) 21 | 22 | const getLicenseNotice = 23 | () => `Please select a distribution license compatible with your application. 24 | Valid licenses include: ${Array.from(VALID_LICENSES).join(', ')} 25 | See LICENSE.md for more details.` 26 | 27 | function readPackageJson() { 28 | const appPath = app.getAppPath() 29 | const packageJsonPath = path.join(appPath, 'package.json') 30 | const rawData = fs.readFileSync(packageJsonPath, 'utf-8') 31 | return JSON.parse(rawData) 32 | } 33 | 34 | function generateHash(input: string) { 35 | const hash = nodeCrypto.createHash('sha256') 36 | hash.update('crx' + input) 37 | return hash.digest('hex') 38 | } 39 | 40 | /** 41 | * Check to ensure a valid license is provided. 42 | * @see LICENSE.md 43 | */ 44 | export function checkLicense(license?: unknown) { 45 | // License must be set 46 | if (!license || typeof license !== 'string') { 47 | throw new Error(`ElectronChromeExtensions: Missing 'license' property.\n${getLicenseNotice()}`) 48 | } 49 | 50 | // License must be valid 51 | if (!VALID_LICENSES.has(license as any) && (license as any) !== INTERNAL_LICENSE) { 52 | throw new Error( 53 | `ElectronChromeExtensions: Invalid 'license' property: ${license}\n${getLicenseNotice()}`, 54 | ) 55 | } 56 | 57 | // Project must be in compliance with license 58 | let projectNameHash: string | undefined 59 | try { 60 | const packageJson = readPackageJson() 61 | const projectName = packageJson.name.toLowerCase() 62 | projectNameHash = generateHash(projectName) 63 | } catch {} 64 | if (projectNameHash && NONCOMPLIANT_PROJECTS.has(projectNameHash)) { 65 | throw new Error( 66 | `ElectronChromeExtensions: This application is using a non-compliant license. Contact sam@samuelmaddock.com if you wish to reinstate your license.`, 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/manifest.ts: -------------------------------------------------------------------------------- 1 | import { getExtensionUrl, validateExtensionResource } from './api/common' 2 | import { ExtensionContext } from './context' 3 | 4 | export async function readUrlOverrides(ctx: ExtensionContext, extension: Electron.Extension) { 5 | const manifest = extension.manifest as chrome.runtime.Manifest 6 | const urlOverrides = ctx.store.urlOverrides 7 | let updated = false 8 | 9 | if (typeof manifest.chrome_url_overrides === 'object') { 10 | for (const [name, uri] of Object.entries(manifest.chrome_url_overrides!)) { 11 | const validatedPath = await validateExtensionResource(extension, uri) 12 | if (!validatedPath) { 13 | console.error( 14 | `Extension ${extension.id} attempted to override ${name} with invalid resource: ${uri}`, 15 | ) 16 | continue 17 | } 18 | 19 | const url = getExtensionUrl(extension, uri)! 20 | const currentUrl = urlOverrides[name] 21 | if (currentUrl !== url) { 22 | urlOverrides[name] = url 23 | updated = true 24 | } 25 | } 26 | } 27 | 28 | if (updated) { 29 | ctx.emit('url-overrides-updated', urlOverrides) 30 | } 31 | } 32 | 33 | export function readLoadedExtensionManifest(ctx: ExtensionContext, extension: Electron.Extension) { 34 | readUrlOverrides(ctx, extension) 35 | } 36 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/partition.ts: -------------------------------------------------------------------------------- 1 | import { session } from 'electron' 2 | 3 | type SessionPartitionResolver = (partition: string) => Electron.Session 4 | 5 | let resolvePartitionImpl: SessionPartitionResolver = (partition) => session.fromPartition(partition) 6 | 7 | /** 8 | * Overrides the default `session.fromPartition()` behavior for retrieving Electron Sessions. 9 | * This allows using custom identifiers (e.g., profile IDs) to find sessions, enabling features like 10 | * `` to work with non-standard session management schemes. 11 | * @param handler A function that receives a string identifier and returns the corresponding Electron `Session`. 12 | */ 13 | export function setSessionPartitionResolver(resolver: SessionPartitionResolver) { 14 | resolvePartitionImpl = resolver 15 | } 16 | 17 | export function resolvePartition(partition: string) { 18 | return resolvePartitionImpl(partition) 19 | } 20 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/popup.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, Session } from 'electron' 2 | import { getAllWindows } from './api/common' 3 | import debug from 'debug' 4 | 5 | const d = debug('electron-chrome-extensions:popup') 6 | 7 | export interface PopupAnchorRect { 8 | x: number 9 | y: number 10 | width: number 11 | height: number 12 | } 13 | 14 | interface PopupViewOptions { 15 | extensionId: string 16 | session: Session 17 | parent: Electron.BaseWindow 18 | url: string 19 | anchorRect: PopupAnchorRect 20 | alignment?: string 21 | } 22 | 23 | const supportsPreferredSize = () => { 24 | const major = parseInt(process.versions.electron.split('.').shift() || '', 10) 25 | return major >= 12 26 | } 27 | 28 | export class PopupView { 29 | static POSITION_PADDING = 5 30 | 31 | static BOUNDS = { 32 | minWidth: 25, 33 | minHeight: 25, 34 | maxWidth: 800, 35 | maxHeight: 600, 36 | } 37 | 38 | browserWindow?: BrowserWindow 39 | parent?: Electron.BaseWindow 40 | extensionId: string 41 | 42 | private anchorRect: PopupAnchorRect 43 | private destroyed: boolean = false 44 | private hidden: boolean = true 45 | private alignment?: string 46 | 47 | /** Preferred size changes are only received in Electron v12+ */ 48 | private usingPreferredSize = supportsPreferredSize() 49 | 50 | private readyPromise: Promise 51 | 52 | constructor(opts: PopupViewOptions) { 53 | this.parent = opts.parent 54 | this.extensionId = opts.extensionId 55 | this.anchorRect = opts.anchorRect 56 | this.alignment = opts.alignment 57 | 58 | this.browserWindow = new BrowserWindow({ 59 | show: false, 60 | frame: false, 61 | parent: opts.parent, 62 | movable: false, 63 | maximizable: false, 64 | minimizable: false, 65 | resizable: false, 66 | skipTaskbar: true, 67 | backgroundColor: '#ffffff', 68 | roundedCorners: false, 69 | webPreferences: { 70 | session: opts.session, 71 | sandbox: true, 72 | nodeIntegration: false, 73 | nodeIntegrationInWorker: false, 74 | contextIsolation: true, 75 | enablePreferredSizeMode: true, 76 | }, 77 | }) 78 | 79 | const untypedWebContents = this.browserWindow.webContents as any 80 | untypedWebContents.on('preferred-size-changed', this.updatePreferredSize) 81 | 82 | this.browserWindow.webContents.on('devtools-closed', this.maybeClose) 83 | this.browserWindow.on('blur', this.maybeClose) 84 | this.browserWindow.on('closed', this.destroy) 85 | this.parent.once('closed', this.destroy) 86 | 87 | this.readyPromise = this.load(opts.url) 88 | } 89 | 90 | private show() { 91 | this.hidden = false 92 | this.browserWindow?.show() 93 | } 94 | 95 | private async load(url: string): Promise { 96 | const win = this.browserWindow! 97 | 98 | try { 99 | await win.webContents.loadURL(url) 100 | } catch (e) { 101 | console.error(e) 102 | } 103 | 104 | if (this.destroyed) return 105 | 106 | if (this.usingPreferredSize) { 107 | // Set small initial size so the preferred size grows to what's needed 108 | this.setSize({ width: PopupView.BOUNDS.minWidth, height: PopupView.BOUNDS.minHeight }) 109 | } else { 110 | // Set large initial size to avoid overflow 111 | this.setSize({ width: PopupView.BOUNDS.maxWidth, height: PopupView.BOUNDS.maxHeight }) 112 | 113 | // Wait for content and layout to load 114 | await new Promise((resolve) => setTimeout(resolve, 100)) 115 | if (this.destroyed) return 116 | 117 | await this.queryPreferredSize() 118 | if (this.destroyed) return 119 | 120 | this.show() 121 | } 122 | } 123 | 124 | destroy = () => { 125 | if (this.destroyed) return 126 | 127 | this.destroyed = true 128 | 129 | d(`destroying ${this.extensionId}`) 130 | 131 | if (this.parent) { 132 | if (!this.parent.isDestroyed()) { 133 | this.parent.off('closed', this.destroy) 134 | } 135 | this.parent = undefined 136 | } 137 | 138 | if (this.browserWindow) { 139 | if (!this.browserWindow.isDestroyed()) { 140 | const { webContents } = this.browserWindow 141 | 142 | if (!webContents.isDestroyed() && webContents.isDevToolsOpened()) { 143 | webContents.closeDevTools() 144 | } 145 | 146 | this.browserWindow.off('closed', this.destroy) 147 | this.browserWindow.destroy() 148 | } 149 | 150 | this.browserWindow = undefined 151 | } 152 | } 153 | 154 | isDestroyed() { 155 | return this.destroyed 156 | } 157 | 158 | /** Resolves when the popup finishes loading. */ 159 | whenReady() { 160 | return this.readyPromise 161 | } 162 | 163 | setSize(rect: Partial) { 164 | if (!this.browserWindow || !this.parent) return 165 | 166 | const width = Math.floor( 167 | Math.min(PopupView.BOUNDS.maxWidth, Math.max(rect.width || 0, PopupView.BOUNDS.minWidth)), 168 | ) 169 | 170 | const height = Math.floor( 171 | Math.min(PopupView.BOUNDS.maxHeight, Math.max(rect.height || 0, PopupView.BOUNDS.minHeight)), 172 | ) 173 | 174 | d(`setSize`, { width, height }) 175 | 176 | this.browserWindow?.setBounds({ 177 | ...this.browserWindow.getBounds(), 178 | width, 179 | height, 180 | }) 181 | } 182 | 183 | private maybeClose = () => { 184 | // Keep open if webContents is being inspected 185 | if (!this.browserWindow?.isDestroyed() && this.browserWindow?.webContents.isDevToolsOpened()) { 186 | d('preventing close due to DevTools being open') 187 | return 188 | } 189 | 190 | // For extension popups with a login form, the user may need to access a 191 | // program outside of the app. Closing the popup would then add 192 | // inconvenience. 193 | if (!getAllWindows().some((win) => win.isFocused())) { 194 | d('preventing close due to focus residing outside of the app') 195 | return 196 | } 197 | 198 | this.destroy() 199 | } 200 | 201 | private updatePosition() { 202 | if (!this.browserWindow || !this.parent) return 203 | 204 | const winBounds = this.parent.getBounds() 205 | const viewBounds = this.browserWindow.getBounds() 206 | 207 | let x = winBounds.x + this.anchorRect.x + this.anchorRect.width - viewBounds.width 208 | let y = winBounds.y + this.anchorRect.y + this.anchorRect.height + PopupView.POSITION_PADDING 209 | 210 | // If aligned to a differently then we need to offset the popup position 211 | if (this.alignment?.includes('right')) x = winBounds.x + this.anchorRect.x 212 | if (this.alignment?.includes('top')) 213 | y = winBounds.y - viewBounds.height + this.anchorRect.y - PopupView.POSITION_PADDING 214 | 215 | // Convert to ints 216 | x = Math.floor(x) 217 | y = Math.floor(y) 218 | 219 | d(`updatePosition`, { x, y }) 220 | 221 | this.browserWindow.setBounds({ 222 | ...this.browserWindow.getBounds(), 223 | x, 224 | y, 225 | }) 226 | } 227 | 228 | /** Backwards compat for Electron <12 */ 229 | private async queryPreferredSize() { 230 | if (this.usingPreferredSize || this.destroyed) return 231 | 232 | const rect = await this.browserWindow!.webContents.executeJavaScript( 233 | `((${() => { 234 | const rect = document.body.getBoundingClientRect() 235 | return { width: rect.width, height: rect.height } 236 | }})())`, 237 | ) 238 | 239 | if (this.destroyed) return 240 | 241 | this.setSize({ width: rect.width, height: rect.height }) 242 | this.updatePosition() 243 | } 244 | 245 | private updatePreferredSize = (event: Electron.Event, size: Electron.Size) => { 246 | d('updatePreferredSize', size) 247 | this.usingPreferredSize = true 248 | this.setSize(size) 249 | this.updatePosition() 250 | 251 | // Wait to reveal popup until it's sized and positioned correctly 252 | if (this.hidden) this.show() 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/browser/store.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, webContents } from 'electron' 2 | import { EventEmitter } from 'node:events' 3 | import { ContextMenuType } from './api/common' 4 | import { ChromeExtensionImpl } from './impl' 5 | import { ExtensionEvent } from './router' 6 | 7 | export class ExtensionStore extends EventEmitter { 8 | /** Tabs observed by the extensions system. */ 9 | tabs = new Set() 10 | 11 | /** Windows observed by the extensions system. */ 12 | windows = new Set() 13 | 14 | lastFocusedWindowId?: number 15 | 16 | /** 17 | * Map of tabs to their parent window. 18 | * 19 | * It's not possible to access the parent of a BrowserView so we must manage 20 | * this ourselves. 21 | */ 22 | tabToWindow = new WeakMap() 23 | 24 | /** Map of windows to their active tab. */ 25 | private windowToActiveTab = new WeakMap() 26 | 27 | tabDetailsCache = new Map>() 28 | windowDetailsCache = new Map>() 29 | 30 | urlOverrides: Record = {} 31 | 32 | constructor(public impl: ChromeExtensionImpl) { 33 | super() 34 | } 35 | 36 | getWindowById(windowId: number) { 37 | return Array.from(this.windows).find( 38 | (window) => !window.isDestroyed() && window.id === windowId, 39 | ) 40 | } 41 | 42 | getLastFocusedWindow() { 43 | return this.lastFocusedWindowId ? this.getWindowById(this.lastFocusedWindowId) : null 44 | } 45 | 46 | getCurrentWindow() { 47 | return this.getLastFocusedWindow() 48 | } 49 | 50 | addWindow(window: Electron.BaseWindow) { 51 | if (this.windows.has(window)) return 52 | 53 | this.windows.add(window) 54 | 55 | if (typeof this.lastFocusedWindowId !== 'number') { 56 | this.lastFocusedWindowId = window.id 57 | } 58 | 59 | this.emit('window-added', window) 60 | } 61 | 62 | async createWindow(event: ExtensionEvent, details: chrome.windows.CreateData) { 63 | if (typeof this.impl.createWindow !== 'function') { 64 | throw new Error('createWindow is not implemented') 65 | } 66 | 67 | const win = await this.impl.createWindow(details) 68 | 69 | this.addWindow(win) 70 | 71 | return win 72 | } 73 | 74 | async removeWindow(window: Electron.BaseWindow) { 75 | if (!this.windows.has(window)) return 76 | 77 | this.windows.delete(window) 78 | 79 | if (typeof this.impl.removeWindow === 'function') { 80 | await this.impl.removeWindow(window) 81 | } else { 82 | window.destroy() 83 | } 84 | } 85 | 86 | getTabById(tabId: number) { 87 | return Array.from(this.tabs).find((tab) => !tab.isDestroyed() && tab.id === tabId) 88 | } 89 | 90 | addTab(tab: Electron.WebContents, window: Electron.BaseWindow) { 91 | if (this.tabs.has(tab)) return 92 | 93 | this.tabs.add(tab) 94 | this.tabToWindow.set(tab, window) 95 | this.addWindow(window) 96 | 97 | const activeTab = this.getActiveTabFromWebContents(tab) 98 | if (!activeTab) { 99 | this.setActiveTab(tab) 100 | } 101 | 102 | this.emit('tab-added', tab) 103 | } 104 | 105 | removeTab(tab: Electron.WebContents) { 106 | if (!this.tabs.has(tab)) return 107 | 108 | const tabId = tab.id 109 | const win = this.tabToWindow.get(tab)! 110 | 111 | this.tabs.delete(tab) 112 | this.tabToWindow.delete(tab) 113 | 114 | // TODO: clear active tab 115 | 116 | // Clear window if it has no remaining tabs 117 | const windowHasTabs = Array.from(this.tabs).find((tab) => this.tabToWindow.get(tab) === win) 118 | if (!windowHasTabs) { 119 | this.windows.delete(win) 120 | } 121 | 122 | if (typeof this.impl.removeTab === 'function') { 123 | this.impl.removeTab(tab, win) 124 | } 125 | 126 | this.emit('tab-removed', tabId) 127 | } 128 | 129 | async createTab(details: chrome.tabs.CreateProperties) { 130 | if (typeof this.impl.createTab !== 'function') { 131 | throw new Error('createTab is not implemented') 132 | } 133 | 134 | // Fallback to current window 135 | if (!details.windowId) { 136 | details.windowId = this.lastFocusedWindowId 137 | } 138 | 139 | const result = await this.impl.createTab(details) 140 | 141 | if (!Array.isArray(result)) { 142 | throw new Error('createTab must return an array of [tab, window]') 143 | } 144 | 145 | const [tab, window] = result 146 | 147 | if (typeof tab !== 'object' || !webContents.fromId(tab.id)) { 148 | throw new Error('createTab must return a WebContents') 149 | } else if (typeof window !== 'object') { 150 | throw new Error('createTab must return a BrowserWindow') 151 | } 152 | 153 | this.addTab(tab, window) 154 | 155 | return tab 156 | } 157 | 158 | getActiveTabFromWindow(win: Electron.BaseWindow) { 159 | const activeTab = win && !win.isDestroyed() && this.windowToActiveTab.get(win) 160 | return (activeTab && !activeTab.isDestroyed() && activeTab) || undefined 161 | } 162 | 163 | getActiveTabFromWebContents(wc: Electron.WebContents): Electron.WebContents | undefined { 164 | const win = this.tabToWindow.get(wc) || BrowserWindow.fromWebContents(wc) 165 | const activeTab = win ? this.getActiveTabFromWindow(win) : undefined 166 | return activeTab 167 | } 168 | 169 | getActiveTabOfCurrentWindow() { 170 | const win = this.getCurrentWindow() 171 | return win ? this.getActiveTabFromWindow(win) : undefined 172 | } 173 | 174 | setActiveTab(tab: Electron.WebContents) { 175 | const win = this.tabToWindow.get(tab) 176 | if (!win) { 177 | throw new Error('Active tab has no parent window') 178 | } 179 | 180 | const prevActiveTab = this.getActiveTabFromWebContents(tab) 181 | 182 | this.windowToActiveTab.set(win, tab) 183 | 184 | if (tab.id !== prevActiveTab?.id) { 185 | this.emit('active-tab-changed', tab, win) 186 | 187 | if (typeof this.impl.selectTab === 'function') { 188 | this.impl.selectTab(tab, win) 189 | } 190 | } 191 | } 192 | 193 | buildMenuItems(extensionId: string, menuType: ContextMenuType): Electron.MenuItem[] { 194 | // This function is overwritten by ContextMenusAPI 195 | return [] 196 | } 197 | 198 | async requestPermissions( 199 | extension: Electron.Extension, 200 | permissions: chrome.permissions.Permissions, 201 | ) { 202 | if (typeof this.impl.requestPermissions !== 'function') { 203 | // Default to allowed. 204 | return true 205 | } 206 | const result: unknown = await this.impl.requestPermissions(extension, permissions) 207 | return typeof result === 'boolean' ? result : false 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './browser' 2 | export { setSessionPartitionResolver } from './browser/partition' 3 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/preload.ts: -------------------------------------------------------------------------------- 1 | import { injectExtensionAPIs } from './renderer' 2 | 3 | // Only load within extension page context 4 | if (process.type === 'service-worker' || location.href.startsWith('chrome-extension://')) { 5 | injectExtensionAPIs() 6 | } 7 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/src/renderer/event.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | 3 | const formatIpcName = (name: string) => `crx-${name}` 4 | 5 | const listenerMap = new Map() 6 | 7 | export const addExtensionListener = (extensionId: string, name: string, callback: Function) => { 8 | const listenerCount = listenerMap.get(name) || 0 9 | 10 | if (listenerCount === 0) { 11 | // TODO: should these IPCs be batched in a microtask? 12 | ipcRenderer.send('crx-add-listener', extensionId, name) 13 | } 14 | 15 | listenerMap.set(name, listenerCount + 1) 16 | 17 | ipcRenderer.addListener(formatIpcName(name), function (event, ...args) { 18 | if (process.env.NODE_ENV === 'development') { 19 | console.log(name, '(result)', ...args) 20 | } 21 | callback(...args) 22 | }) 23 | } 24 | 25 | export const removeExtensionListener = (extensionId: string, name: string, callback: any) => { 26 | if (listenerMap.has(name)) { 27 | const listenerCount = listenerMap.get(name) || 0 28 | 29 | if (listenerCount <= 1) { 30 | listenerMap.delete(name) 31 | 32 | ipcRenderer.send('crx-remove-listener', extensionId, name) 33 | } else { 34 | listenerMap.set(name, listenerCount - 1) 35 | } 36 | } 37 | 38 | ipcRenderer.removeListener(formatIpcName(name), callback) 39 | } 40 | -------------------------------------------------------------------------------- /packages/electron-chrome-extensions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "dist/types", 6 | "declaration": true, 7 | "emitDeclarationOnly": true 8 | }, 9 | 10 | "include": ["src/**/*"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.preload.js 3 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | esbuild.config.js 4 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/README.md: -------------------------------------------------------------------------------- 1 | # electron-chrome-web-store 2 | 3 | Install and update Chrome extensions from the Chrome Web Store for Electron. 4 | 5 | ## Usage 6 | 7 | ``` 8 | npm install electron-chrome-web-store 9 | ``` 10 | 11 | > [!TIP] 12 | > To enable full support for Chrome extensions in Electron, install [electron-chrome-extensions](https://www.npmjs.com/package/electron-chrome-extensions). 13 | 14 | ### Enable downloading extensions from the Chrome Web Store 15 | 16 | ```js 17 | const { app, BrowserWindow, session } = require('electron') 18 | const { installChromeWebStore } = require('electron-chrome-web-store') 19 | 20 | app.whenReady().then(async () => { 21 | const browserSession = session.defaultSession 22 | const browserWindow = new BrowserWindow({ 23 | webPreferences: { 24 | session: browserSession, 25 | }, 26 | }) 27 | 28 | // Install Chrome web store and wait for extensions to load 29 | await installChromeWebStore({ session: browserSession }) 30 | 31 | browserWindow.loadURL('https://chromewebstore.google.com/') 32 | }) 33 | ``` 34 | 35 | ### Install and update extensions programmatically 36 | 37 | ```js 38 | const { app, session } = require('electron') 39 | const { installExtension, updateExtensions } = require('electron-chrome-web-store') 40 | 41 | app.whenReady().then(async () => { 42 | // Install Dark Reader 43 | await installExtension('eimadpbcbfnmbkopoojfekhnkhdbieeh') 44 | 45 | // Install React Developer Tools with file:// access 46 | await installExtension('fmkadmapgofadopljbjfkapdkoienihi', { 47 | loadExtensionOptions: { allowFileAccess: true }, 48 | }) 49 | 50 | // Install uBlock Origin Lite to custom session 51 | await installExtension('ddkjiahejlhfcafbddmgiahcphecmpfh', { 52 | session: session.fromPartition('persist:browser'), 53 | }) 54 | 55 | // Check and install updates for all loaded extensions 56 | await updateExtensions() 57 | }) 58 | ``` 59 | 60 | ### Packaging the preload script 61 | 62 | This module uses a [preload script](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload#what-is-a-preload-script). 63 | When packaging your application, it's required that the preload script is included. This can be 64 | handled in two ways: 65 | 66 | 1. Include `node_modules` in your packaged app. This allows `electron-chrome-web-store/preload` to 67 | be resolved. 68 | 2. In the case of using JavaScript bundlers, you may need to copy the preload script next to your 69 | app's entry point script. You can try using 70 | [copy-webpack-plugin](https://github.com/webpack-contrib/copy-webpack-plugin), 71 | [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy), 72 | or [rollup-plugin-copy](https://github.com/vladshcherbin/rollup-plugin-copy) depending on your app's 73 | configuration. 74 | 75 | Here's an example for webpack configurations: 76 | 77 | ```js 78 | module.exports = { 79 | entry: './index.js', 80 | plugins: [ 81 | new CopyWebpackPlugin({ 82 | patterns: [require.resolve('electron-chrome-web-store/preload')], 83 | }), 84 | ], 85 | } 86 | ``` 87 | 88 | ## API 89 | 90 | ### `installChromeWebStore` 91 | 92 | Installs Chrome Web Store support in the specified session. 93 | 94 | - `options` 95 | - `session`: The Electron session to enable the Chrome Web Store in. Defaults to `session.defaultSession`. 96 | - `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path. 97 | - `autoUpdate`: Whether to auto-update web store extensions at startup and once every 5 hours. Defaults to true. 98 | - `loadExtensions`: A boolean indicating whether to load extensions installed by Chrome Web Store. Defaults to true. 99 | - `allowUnpackedExtensions`: A boolean indicating whether to allow loading unpacked extensions. Only loads if `loadExtensions` is also enabled. Defaults to false. 100 | - `allowlist`: An array of allowed extension IDs to install. 101 | - `denylist`: An array of denied extension IDs to install. 102 | - `beforeInstall`: A function which receives install details and returns a promise. Allows for prompting prior to install. 103 | 104 | ### `installExtension` 105 | 106 | Installs Chrome extension from the Chrome Web Store. 107 | 108 | - `extensionId`: The Chrome Web Store extension ID to install. 109 | - `options` 110 | - `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`. 111 | - `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path. 112 | - `loadExtensionOptions`: Extension options passed into `session.loadExtension`. 113 | 114 | ### `uninstallExtension` 115 | 116 | Uninstalls Chrome Web Store extension. 117 | 118 | - `extensionId`: The Chrome Web Store extension ID to uninstall. 119 | - `options` 120 | - `session`: The Electron session where extensions are loaded. Defaults to `session.defaultSession`. 121 | - `extensionsPath`: The path to the extensions directory. Defaults to 'Extensions/' in the app's userData path. 122 | 123 | ### `updateExtensions` 124 | 125 | Checks loaded extensions for updates and installs any if available. 126 | 127 | - `session`: The Electron session to load extensions in. Defaults to `session.defaultSession`. 128 | 129 | ### `loadAllExtensions` 130 | 131 | Loads all extensions from the specified directory. 132 | 133 | - `session`: The Electron session to load extensions in. 134 | - `extensionsPath`: The path to the directory containing the extensions. 135 | - `options`: An object with the following property: 136 | - `allowUnpacked`: A boolean indicating whether to allow loading unpacked extensions. Defaults to false. 137 | 138 | > [!NOTE] 139 | > The `installChromeWebStore` API will automatically load web store extensions by default. 140 | 141 | ## License 142 | 143 | MIT 144 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/esbuild.config.js: -------------------------------------------------------------------------------- 1 | const packageJson = require('./package.json') 2 | const { createConfig, build, EXTERNAL_BASE } = require('../../build/esbuild/esbuild.config.base') 3 | 4 | console.log(`building ${packageJson.name}`) 5 | 6 | const external = [...EXTERNAL_BASE, 'adm-zip', 'pbf', 'electron-chrome-web-store/preload'] 7 | 8 | const esmOnlyModules = ['pbf'] 9 | 10 | const browserConfig = createConfig({ 11 | entryPoints: ['src/browser/index.ts'], 12 | outfile: 'dist/cjs/browser/index.js', 13 | platform: 'node', 14 | external: external.filter((module) => !esmOnlyModules.includes(module)), 15 | }) 16 | 17 | const browserESMConfig = createConfig({ 18 | entryPoints: ['src/browser/index.ts'], 19 | outfile: 'dist/esm/browser/index.mjs', 20 | platform: 'neutral', 21 | external, 22 | format: 'esm', 23 | }) 24 | 25 | build(browserConfig) 26 | build(browserESMConfig) 27 | 28 | const preloadConfig = createConfig({ 29 | entryPoints: ['src/renderer/chrome-web-store.preload.ts'], 30 | outfile: 'dist/chrome-web-store.preload.js', 31 | platform: 'browser', 32 | external, 33 | sourcemap: false, 34 | }) 35 | 36 | build(preloadConfig) 37 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-chrome-web-store", 3 | "version": "0.12.0", 4 | "description": "Install and update Chrome extensions from the Chrome Web Store for Electron", 5 | "main": "./dist/cjs/browser/index.js", 6 | "module": "./dist/esm/browser/index.mjs", 7 | "types": "./dist/types/browser/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types/browser/index.d.ts", 11 | "import": "./dist/esm/browser/index.mjs", 12 | "require": "./dist/cjs/browser/index.js" 13 | }, 14 | "./preload": "./dist/chrome-web-store.preload.js" 15 | }, 16 | "scripts": { 17 | "build": "yarn clean && tsc && node esbuild.config.js", 18 | "clean": "node ../../scripts/clean.js", 19 | "prepublish": "NODE_ENV=production yarn build" 20 | }, 21 | "keywords": [ 22 | "electron", 23 | "chrome", 24 | "web", 25 | "store", 26 | "webstore", 27 | "extensions" 28 | ], 29 | "repository": "https://github.com/samuelmaddock/electron-browser-shell", 30 | "author": "Samuel Maddock ", 31 | "license": "MIT", 32 | "devDependencies": { 33 | "esbuild": "^0.24.0", 34 | "rimraf": "^6.0.1", 35 | "typescript": "^5.6.3" 36 | }, 37 | "dependencies": { 38 | "@types/chrome": "^0.0.287", 39 | "adm-zip": "^0.5.16", 40 | "debug": "^4.3.7", 41 | "pbf": "^4.0.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/crx3.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2017 The Chromium Authors 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | syntax = "proto2"; 6 | 7 | option optimize_for = LITE_RUNTIME; 8 | 9 | package crx_file; 10 | 11 | // A CRX₃ file is a binary file of the following format: 12 | // [4 octets]: "Cr24", a magic number. 13 | // [4 octets]: The version of the *.crx file format used (currently 3). 14 | // [4 octets]: N, little-endian, the length of the header section. 15 | // [N octets]: The header (the binary encoding of a CrxFileHeader). 16 | // [M octets]: The ZIP archive. 17 | // Clients should reject CRX₃ files that contain an N that is too large for the 18 | // client to safely handle in memory. 19 | 20 | message CrxFileHeader { 21 | // PSS signature with RSA public key. The public key is formatted as a 22 | // X.509 SubjectPublicKeyInfo block, as in CRX₂. In the common case of a 23 | // developer key proof, the first 128 bits of the SHA-256 hash of the 24 | // public key must equal the crx_id. 25 | repeated AsymmetricKeyProof sha256_with_rsa = 2; 26 | 27 | // ECDSA signature, using the NIST P-256 curve. Public key appears in 28 | // named-curve format. 29 | // The pinned algorithm will be this, at least on 2017-01-01. 30 | repeated AsymmetricKeyProof sha256_with_ecdsa = 3; 31 | 32 | // A verified contents file containing signatures over the archive contents. 33 | // The verified contents are encoded in UTF-8 and then GZIP-compressed. 34 | // Consult 35 | // https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/verified_contents.h 36 | // for information about the verified contents format. 37 | optional bytes verified_contents = 4; 38 | 39 | // The binary form of a SignedData message. We do not use a nested 40 | // SignedData message, as handlers of this message must verify the proofs 41 | // on exactly these bytes, so it is convenient to parse in two steps. 42 | // 43 | // All proofs in this CrxFile message are on the value 44 | // "CRX3 SignedData\x00" + signed_header_size + signed_header_data + 45 | // archive, where "\x00" indicates an octet with value 0, "CRX3 SignedData" 46 | // is encoded using UTF-8, signed_header_size is the size in octets of the 47 | // contents of this field and is encoded using 4 octets in little-endian 48 | // order, signed_header_data is exactly the content of this field, and 49 | // archive is the remaining contents of the file following the header. 50 | optional bytes signed_header_data = 10000; 51 | } 52 | 53 | message AsymmetricKeyProof { 54 | optional bytes public_key = 1; 55 | optional bytes signature = 2; 56 | } 57 | 58 | message SignedData { 59 | // This is simple binary, not UTF-8 encoded mpdecimal; i.e. it is exactly 60 | // 16 bytes long. 61 | optional bytes crx_id = 1; 62 | } 63 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/crx3.ts: -------------------------------------------------------------------------------- 1 | // code generated by pbf v4.0.1 2 | // modified for electron-chrome-web-store 3 | 4 | import Pbf from 'pbf' 5 | 6 | interface AsymmetricKeyProof { 7 | public_key: Buffer 8 | signature: Buffer 9 | } 10 | 11 | interface CrxFileHeader { 12 | sha256_with_rsa: AsymmetricKeyProof[] 13 | sha256_with_ecdsa: AsymmetricKeyProof[] 14 | verified_contents?: Buffer 15 | signed_header_data?: Buffer 16 | } 17 | 18 | export function readCrxFileHeader(pbf: Pbf, end?: any): CrxFileHeader { 19 | return pbf.readFields( 20 | readCrxFileHeaderField, 21 | { 22 | sha256_with_rsa: [], 23 | sha256_with_ecdsa: [], 24 | verified_contents: undefined, 25 | signed_header_data: undefined, 26 | }, 27 | end, 28 | ) 29 | } 30 | function readCrxFileHeaderField(tag: any, obj: any, pbf: Pbf) { 31 | if (tag === 2) obj.sha256_with_rsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos)) 32 | else if (tag === 3) 33 | obj.sha256_with_ecdsa.push(readAsymmetricKeyProof(pbf, pbf.readVarint() + pbf.pos)) 34 | else if (tag === 4) obj.verified_contents = pbf.readBytes() 35 | else if (tag === 10000) obj.signed_header_data = pbf.readBytes() 36 | } 37 | 38 | export function readAsymmetricKeyProof(pbf: Pbf, end: any) { 39 | return pbf.readFields( 40 | readAsymmetricKeyProofField, 41 | { public_key: undefined, signature: undefined }, 42 | end, 43 | ) 44 | } 45 | function readAsymmetricKeyProofField(tag: any, obj: any, pbf: Pbf) { 46 | if (tag === 1) obj.public_key = pbf.readBytes() 47 | else if (tag === 2) obj.signature = pbf.readBytes() 48 | } 49 | 50 | export function readSignedData(pbf: Pbf, end?: any): { crx_id?: Buffer } { 51 | return pbf.readFields(readSignedDataField, { crx_id: undefined }, end) 52 | } 53 | function readSignedDataField(tag: any, obj: any, pbf: Pbf) { 54 | if (tag === 1) obj.crx_id = pbf.readBytes() 55 | } 56 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/deps.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'adm-zip' 2 | declare module 'debug' 3 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/id.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'node:crypto' 2 | 3 | /** 4 | * Converts a normal hexadecimal string into the alphabet used by extensions. 5 | * We use the characters 'a'-'p' instead of '0'-'f' to avoid ever having a 6 | * completely numeric host, since some software interprets that as an IP address. 7 | * 8 | * @param id - The hexadecimal string to convert. This is modified in place. 9 | */ 10 | export function convertHexadecimalToIDAlphabet(id: string) { 11 | let result = '' 12 | for (const ch of id) { 13 | const val = parseInt(ch, 16) 14 | if (!isNaN(val)) { 15 | result += String.fromCharCode('a'.charCodeAt(0) + val) 16 | } else { 17 | result += 'a' 18 | } 19 | } 20 | return result 21 | } 22 | 23 | function generateIdFromHash(hash: Buffer): string { 24 | const hashedId = hash.subarray(0, 16).toString('hex') 25 | return convertHexadecimalToIDAlphabet(hashedId) 26 | } 27 | 28 | export function generateId(input: string): string { 29 | const hash = createHash('sha256').update(input, 'base64').digest() 30 | return generateIdFromHash(hash) 31 | } 32 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { app, session as electronSession } from 'electron' 2 | import * as path from 'node:path' 3 | import { existsSync } from 'node:fs' 4 | import { createRequire } from 'node:module' 5 | 6 | import { registerWebStoreApi } from './api' 7 | import { loadAllExtensions } from './loader' 8 | export { loadAllExtensions } from './loader' 9 | export { installExtension, uninstallExtension, downloadExtension } from './installer' 10 | import { initUpdater } from './updater' 11 | export { updateExtensions } from './updater' 12 | import { getDefaultExtensionsPath } from './utils' 13 | import { BeforeInstall, ExtensionId, WebStoreState } from './types' 14 | 15 | function resolvePreloadPath(modulePath?: string) { 16 | // Attempt to resolve preload path from module exports 17 | try { 18 | return createRequire(__dirname).resolve('electron-chrome-web-store/preload') 19 | } catch (error) { 20 | if (process.env.NODE_ENV !== 'production') { 21 | console.error(error) 22 | } 23 | } 24 | 25 | const preloadFilename = 'chrome-web-store.preload.js' 26 | 27 | // Deprecated: use modulePath if provided 28 | if (modulePath) { 29 | process.emitWarning( 30 | 'electron-chrome-web-store: "modulePath" is deprecated and will be removed in future versions.', 31 | { type: 'DeprecationWarning' }, 32 | ) 33 | return path.join(modulePath, 'dist', preloadFilename) 34 | } 35 | 36 | // Fallback to preload relative to entrypoint directory 37 | return path.join(__dirname, preloadFilename) 38 | } 39 | 40 | interface ElectronChromeWebStoreOptions { 41 | /** 42 | * Session to enable the Chrome Web Store in. 43 | * Defaults to session.defaultSession 44 | */ 45 | session?: Electron.Session 46 | 47 | /** 48 | * Path to the 'electron-chrome-web-store' module. 49 | * 50 | * @deprecated See "Packaging the preload script" in the readme. 51 | */ 52 | modulePath?: string 53 | 54 | /** 55 | * Path to extensions directory. 56 | * Defaults to 'Extensions/' under app's userData path. 57 | */ 58 | extensionsPath?: string 59 | 60 | /** 61 | * Load extensions installed by Chrome Web Store. 62 | * Defaults to true. 63 | */ 64 | loadExtensions?: boolean 65 | 66 | /** 67 | * Whether to allow loading unpacked extensions. Only loads if 68 | * `loadExtensions` is also enabled. 69 | * Defaults to false. 70 | */ 71 | allowUnpackedExtensions?: boolean 72 | 73 | /** 74 | * List of allowed extension IDs to install. 75 | */ 76 | allowlist?: ExtensionId[] 77 | 78 | /** 79 | * List of denied extension IDs to install. 80 | */ 81 | denylist?: ExtensionId[] 82 | 83 | /** 84 | * Whether extensions should auto-update. 85 | */ 86 | autoUpdate?: boolean 87 | 88 | /** 89 | * Minimum supported version of Chrome extensions. 90 | * Defaults to 3. 91 | */ 92 | minimumManifestVersion?: number 93 | 94 | /** 95 | * Called prior to installing an extension. If implemented, return a Promise 96 | * which resolves with `{ action: 'allow' | 'deny' }` depending on the action 97 | * to be taken. 98 | */ 99 | beforeInstall?: BeforeInstall 100 | } 101 | 102 | /** 103 | * Install Chrome Web Store support. 104 | * 105 | * @param options Chrome Web Store configuration options. 106 | */ 107 | export async function installChromeWebStore(opts: ElectronChromeWebStoreOptions = {}) { 108 | const session = opts.session || electronSession.defaultSession 109 | const extensionsPath = opts.extensionsPath || getDefaultExtensionsPath() 110 | const loadExtensions = typeof opts.loadExtensions === 'boolean' ? opts.loadExtensions : true 111 | const allowUnpackedExtensions = 112 | typeof opts.allowUnpackedExtensions === 'boolean' ? opts.allowUnpackedExtensions : false 113 | const autoUpdate = typeof opts.autoUpdate === 'boolean' ? opts.autoUpdate : true 114 | const minimumManifestVersion = 115 | typeof opts.minimumManifestVersion === 'number' ? opts.minimumManifestVersion : 3 116 | const beforeInstall = typeof opts.beforeInstall === 'function' ? opts.beforeInstall : undefined 117 | 118 | const webStoreState: WebStoreState = { 119 | session, 120 | extensionsPath, 121 | installing: new Set(), 122 | allowlist: opts.allowlist ? new Set(opts.allowlist) : undefined, 123 | denylist: opts.denylist ? new Set(opts.denylist) : undefined, 124 | minimumManifestVersion, 125 | beforeInstall, 126 | } 127 | 128 | // Add preload script to session 129 | const preloadPath = resolvePreloadPath(opts.modulePath) 130 | 131 | if ('registerPreloadScript' in session) { 132 | session.registerPreloadScript({ 133 | id: 'electron-chrome-web-store', 134 | type: 'frame', 135 | filePath: preloadPath, 136 | }) 137 | } else { 138 | // @ts-expect-error Deprecated electron@<35 139 | session.setPreloads([...session.getPreloads(), preloadPath]) 140 | } 141 | 142 | if (!existsSync(preloadPath)) { 143 | console.error( 144 | new Error( 145 | `electron-chrome-web-store: Preload file not found at "${preloadPath}". ` + 146 | 'See "Packaging the preload script" in the readme.', 147 | ), 148 | ) 149 | } 150 | 151 | registerWebStoreApi(webStoreState) 152 | 153 | await app.whenReady() 154 | 155 | if (loadExtensions) { 156 | await loadAllExtensions(session, extensionsPath, { allowUnpacked: allowUnpackedExtensions }) 157 | } 158 | 159 | if (autoUpdate) { 160 | void initUpdater(webStoreState) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/loader.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs' 2 | import * as path from 'node:path' 3 | import debug from 'debug' 4 | 5 | import { generateId } from './id' 6 | import { compareVersions } from './utils' 7 | import { ExtensionId } from './types' 8 | 9 | const d = debug('electron-chrome-web-store:loader') 10 | 11 | type ExtensionPathBaseInfo = { manifest: chrome.runtime.Manifest; path: string } 12 | type ExtensionPathInfo = 13 | | ({ type: 'store'; id: string } & ExtensionPathBaseInfo) 14 | | ({ type: 'unpacked' } & ExtensionPathBaseInfo) 15 | 16 | const manifestExists = async (dirPath: string) => { 17 | if (!dirPath) return false 18 | const manifestPath = path.join(dirPath, 'manifest.json') 19 | try { 20 | return (await fs.promises.stat(manifestPath)).isFile() 21 | } catch { 22 | return false 23 | } 24 | } 25 | 26 | /** 27 | * DFS directories for extension manifests. 28 | */ 29 | async function extensionSearch(dirPath: string, depth: number = 0): Promise { 30 | if (depth >= 2) return [] 31 | const results = [] 32 | const dirEntries = await fs.promises.readdir(dirPath, { withFileTypes: true }) 33 | for (const entry of dirEntries) { 34 | if (entry.isDirectory()) { 35 | if (await manifestExists(path.join(dirPath, entry.name))) { 36 | results.push(path.join(dirPath, entry.name)) 37 | } else { 38 | results.push(...(await extensionSearch(path.join(dirPath, entry.name), depth + 1))) 39 | } 40 | } 41 | } 42 | return results 43 | } 44 | 45 | /** 46 | * Discover list of extensions in the given path. 47 | */ 48 | async function discoverExtensions(extensionsPath: string): Promise { 49 | try { 50 | const stat = await fs.promises.stat(extensionsPath) 51 | if (!stat.isDirectory()) { 52 | d('%s is not a directory', extensionsPath) 53 | return [] 54 | } 55 | } catch { 56 | d('%s does not exist', extensionsPath) 57 | return [] 58 | } 59 | 60 | const extensionDirectories = await extensionSearch(extensionsPath) 61 | const results: ExtensionPathInfo[] = [] 62 | 63 | for (const extPath of extensionDirectories.filter(Boolean)) { 64 | try { 65 | const manifestPath = path.join(extPath!, 'manifest.json') 66 | const manifestJson = (await fs.promises.readFile(manifestPath)).toString() 67 | const manifest: chrome.runtime.Manifest = JSON.parse(manifestJson) 68 | const result = manifest.key 69 | ? { 70 | type: 'store' as const, 71 | path: extPath!, 72 | manifest, 73 | id: generateId(manifest.key), 74 | } 75 | : { 76 | type: 'unpacked' as const, 77 | path: extPath!, 78 | manifest, 79 | } 80 | results.push(result) 81 | } catch (e) { 82 | console.error(e) 83 | } 84 | } 85 | 86 | return results 87 | } 88 | 89 | /** 90 | * Filter any outdated extensions in the case of duplicate installations. 91 | */ 92 | function filterOutdatedExtensions(extensions: ExtensionPathInfo[]): ExtensionPathInfo[] { 93 | const uniqueExtensions: ExtensionPathInfo[] = [] 94 | const storeExtMap = new Map() 95 | 96 | for (const ext of extensions) { 97 | if (ext.type === 'unpacked') { 98 | // Unpacked extensions are always unique to their path 99 | uniqueExtensions.push(ext) 100 | } else if (!storeExtMap.has(ext.id)) { 101 | // New store extension 102 | storeExtMap.set(ext.id, ext) 103 | } else { 104 | // Existing store extension, compare with existing version 105 | const latestExt = storeExtMap.get(ext.id)! 106 | if (compareVersions(latestExt.manifest.version, ext.manifest.version) < 0) { 107 | storeExtMap.set(ext.id, ext) 108 | } 109 | } 110 | } 111 | 112 | // Append up to date store extensions 113 | storeExtMap.forEach((ext) => uniqueExtensions.push(ext)) 114 | 115 | return uniqueExtensions 116 | } 117 | 118 | /** 119 | * Load all extensions from the given directory. 120 | */ 121 | export async function loadAllExtensions( 122 | session: Electron.Session, 123 | extensionsPath: string, 124 | options: { 125 | allowUnpacked?: boolean 126 | } = {}, 127 | ) { 128 | let extensions = await discoverExtensions(extensionsPath) 129 | extensions = filterOutdatedExtensions(extensions) 130 | d('discovered %d extension(s) in %s', extensions.length, extensionsPath) 131 | 132 | for (const ext of extensions) { 133 | try { 134 | let extension: Electron.Extension | undefined 135 | if (ext.type === 'store') { 136 | const existingExt = session.getExtension(ext.id) 137 | if (existingExt) { 138 | d('skipping loading existing extension %s', ext.id) 139 | continue 140 | } 141 | d('loading extension %s', `${ext.id}@${ext.manifest.version}`) 142 | extension = await session.loadExtension(ext.path) 143 | } else if (options.allowUnpacked) { 144 | d('loading unpacked extension %s', ext.path) 145 | extension = await session.loadExtension(ext.path) 146 | } 147 | 148 | if ( 149 | extension && 150 | extension.manifest.manifest_version === 3 && 151 | extension.manifest.background?.service_worker 152 | ) { 153 | const scope = `chrome-extension://${extension.id}` 154 | await session.serviceWorkers.startWorkerForScope(scope).catch(() => { 155 | console.error(`Failed to start worker for extension ${extension.id}`) 156 | }) 157 | } 158 | } catch (error) { 159 | console.error(`Failed to load extension from ${ext.path}`) 160 | console.error(error) 161 | } 162 | } 163 | } 164 | 165 | export async function findExtensionInstall(extensionId: string, extensionsPath: string) { 166 | const extensionPath = path.join(extensionsPath, extensionId) 167 | let extensions = await discoverExtensions(extensionPath) 168 | extensions = filterOutdatedExtensions(extensions) 169 | return extensions.length > 0 ? extensions[0] : null 170 | } 171 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/types.ts: -------------------------------------------------------------------------------- 1 | export type ExtensionId = Electron.Extension['id'] 2 | 3 | export interface ExtensionInstallDetails { 4 | id: string 5 | localizedName: string 6 | manifest: chrome.runtime.Manifest 7 | icon: Electron.NativeImage 8 | browserWindow?: Electron.BrowserWindow 9 | frame: Electron.WebFrameMain 10 | } 11 | 12 | export type BeforeInstall = ( 13 | details: ExtensionInstallDetails, 14 | ) => Promise<{ action: 'allow' | 'deny' }> 15 | 16 | export interface WebStoreState { 17 | session: Electron.Session 18 | extensionsPath: string 19 | installing: Set 20 | allowlist?: Set 21 | denylist?: Set 22 | minimumManifestVersion: number 23 | beforeInstall?: BeforeInstall 24 | } 25 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/browser/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { app, net } from 'electron' 3 | 4 | // Include fallbacks for node environments that aren't Electron 5 | export const fetch = 6 | // Prefer Node's fetch until net.fetch crash is fixed 7 | // https://github.com/electron/electron/pull/45050 8 | globalThis.fetch || 9 | net?.fetch || 10 | (() => { 11 | throw new Error( 12 | 'electron-chrome-web-store: Missing fetch API. Please upgrade Electron or Node.', 13 | ) 14 | }) 15 | export const getChromeVersion = () => process.versions.chrome || '131.0.6778.109' 16 | 17 | export function compareVersions(version1: string, version2: string) { 18 | const v1 = version1.split('.').map(Number) 19 | const v2 = version2.split('.').map(Number) 20 | 21 | for (let i = 0; i < 3; i++) { 22 | if (v1[i] > v2[i]) return 1 23 | if (v1[i] < v2[i]) return -1 24 | } 25 | return 0 26 | } 27 | 28 | export const getDefaultExtensionsPath = () => path.join(app.getPath('userData'), 'Extensions') 29 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const ExtensionInstallStatus = { 2 | BLACKLISTED: 'blacklisted', 3 | BLOCKED_BY_POLICY: 'blocked_by_policy', 4 | CAN_REQUEST: 'can_request', 5 | CORRUPTED: 'corrupted', 6 | CUSTODIAN_APPROVAL_REQUIRED: 'custodian_approval_required', 7 | CUSTODIAN_APPROVAL_REQUIRED_FOR_INSTALLATION: 'custodian_approval_required_for_installation', 8 | DEPRECATED_MANIFEST_VERSION: 'deprecated_manifest_version', 9 | DISABLED: 'disabled', 10 | ENABLED: 'enabled', 11 | FORCE_INSTALLED: 'force_installed', 12 | INSTALLABLE: 'installable', 13 | REQUEST_PENDING: 'request_pending', 14 | TERMINATED: 'terminated', 15 | } 16 | 17 | export const MV2DeprecationStatus = { 18 | INACTIVE: 'inactive', 19 | SOFT_DISABLE: 'soft_disable', 20 | WARNING: 'warning', 21 | } 22 | 23 | export const Result = { 24 | ALREADY_INSTALLED: 'already_installed', 25 | BLACKLISTED: 'blacklisted', 26 | BLOCKED_BY_POLICY: 'blocked_by_policy', 27 | BLOCKED_FOR_CHILD_ACCOUNT: 'blocked_for_child_account', 28 | FEATURE_DISABLED: 'feature_disabled', 29 | ICON_ERROR: 'icon_error', 30 | INSTALL_ERROR: 'install_error', 31 | INSTALL_IN_PROGRESS: 'install_in_progress', 32 | INVALID_ICON_URL: 'invalid_icon_url', 33 | INVALID_ID: 'invalid_id', 34 | LAUNCH_IN_PROGRESS: 'launch_in_progress', 35 | MANIFEST_ERROR: 'manifest_error', 36 | MISSING_DEPENDENCIES: 'missing_dependencies', 37 | SUCCESS: 'success', 38 | UNKNOWN_ERROR: 'unknown_error', 39 | UNSUPPORTED_EXTENSION_TYPE: 'unsupported_extension_type', 40 | USER_CANCELLED: 'user_cancelled', 41 | USER_GESTURE_REQUIRED: 'user_gesture_required', 42 | } 43 | 44 | export const WebGlStatus = { 45 | WEBGL_ALLOWED: 'webgl_allowed', 46 | WEBGL_BLOCKED: 'webgl_blocked', 47 | } 48 | -------------------------------------------------------------------------------- /packages/electron-chrome-web-store/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | 4 | "compilerOptions": { 5 | "moduleResolution": "node", 6 | "outDir": "dist/types", 7 | "declaration": true, 8 | "emitDeclarationOnly": true 9 | }, 10 | 11 | "include": ["src"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/shell/.gitignore: -------------------------------------------------------------------------------- 1 | .webpack 2 | out 3 | 4 | preload.js 5 | preload.js.map 6 | -------------------------------------------------------------------------------- /packages/shell/README.md: -------------------------------------------------------------------------------- 1 | # Shell 2 | 3 | This is a simple browser shell to demonstrate tabs and extension functionality. 4 | 5 | A `WebContentsView` is used for tab contents due to its stability for browsing remote content relative to the [buggy behaviors](https://github.com/electron/electron/issues?q=is%3Aissue+is%3Aopen+webview) found in Electron's `` API. 6 | 7 | ## License 8 | 9 | MIT 10 | -------------------------------------------------------------------------------- /packages/shell/browser/menu.js: -------------------------------------------------------------------------------- 1 | const { Menu } = require('electron') 2 | 3 | const setupMenu = (browser) => { 4 | const isMac = process.platform === 'darwin' 5 | 6 | const tab = () => browser.getFocusedWindow().getFocusedTab() 7 | const tabWc = () => tab().webContents 8 | 9 | const template = [ 10 | ...(isMac ? [{ role: 'appMenu' }] : []), 11 | { role: 'fileMenu' }, 12 | { role: 'editMenu' }, 13 | { 14 | label: 'View', 15 | submenu: [ 16 | { 17 | label: 'Reload', 18 | accelerator: 'CmdOrCtrl+R', 19 | nonNativeMacOSRole: true, 20 | click: () => tabWc().reload(), 21 | }, 22 | { 23 | label: 'Force Reload', 24 | accelerator: 'Shift+CmdOrCtrl+R', 25 | nonNativeMacOSRole: true, 26 | click: () => tabWc().reloadIgnoringCache(), 27 | }, 28 | { 29 | label: 'Toggle Developer Tool asdf', 30 | accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I', 31 | nonNativeMacOSRole: true, 32 | click: () => tabWc().toggleDevTools(), 33 | }, 34 | { type: 'separator' }, 35 | { role: 'resetZoom' }, 36 | { role: 'zoomIn' }, 37 | { role: 'zoomOut' }, 38 | { type: 'separator' }, 39 | { role: 'togglefullscreen' }, 40 | ], 41 | }, 42 | { role: 'windowMenu' }, 43 | ] 44 | 45 | const menu = Menu.buildFromTemplate(template) 46 | Menu.setApplicationMenu(menu) 47 | } 48 | 49 | module.exports = { 50 | setupMenu, 51 | } 52 | -------------------------------------------------------------------------------- /packages/shell/browser/tabs.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events') 2 | const { WebContentsView } = require('electron') 3 | 4 | const toolbarHeight = 64 5 | 6 | class Tab { 7 | constructor(parentWindow, webContentsViewOptions = {}) { 8 | this.invalidateLayout = this.invalidateLayout.bind(this) 9 | 10 | this.view = new WebContentsView(webContentsViewOptions) 11 | this.id = this.view.webContents.id 12 | this.window = parentWindow 13 | this.webContents = this.view.webContents 14 | this.window.contentView.addChildView(this.view) 15 | } 16 | 17 | destroy() { 18 | if (this.destroyed) return 19 | 20 | this.destroyed = true 21 | 22 | this.hide() 23 | 24 | this.window.contentView.removeChildView(this.view) 25 | this.window = undefined 26 | 27 | if (!this.webContents.isDestroyed()) { 28 | if (this.webContents.isDevToolsOpened()) { 29 | this.webContents.closeDevTools() 30 | } 31 | 32 | // TODO: why is this no longer called? 33 | this.webContents.emit('destroyed') 34 | 35 | this.webContents.destroy() 36 | } 37 | 38 | this.webContents = undefined 39 | this.view = undefined 40 | } 41 | 42 | loadURL(url) { 43 | return this.view.webContents.loadURL(url) 44 | } 45 | 46 | show() { 47 | this.invalidateLayout() 48 | this.startResizeListener() 49 | this.view.setVisible(true) 50 | } 51 | 52 | hide() { 53 | this.stopResizeListener() 54 | this.view.setVisible(false) 55 | } 56 | 57 | reload() { 58 | this.view.webContents.reload() 59 | } 60 | 61 | invalidateLayout() { 62 | const [width, height] = this.window.getSize() 63 | const padding = 4 64 | this.view.setBounds({ 65 | x: padding, 66 | y: toolbarHeight, 67 | width: width - padding * 2, 68 | height: height - toolbarHeight - padding, 69 | }) 70 | this.view.setBorderRadius(8) 71 | } 72 | 73 | // Replacement for BrowserView.setAutoResize. This could probably be better... 74 | startResizeListener() { 75 | this.stopResizeListener() 76 | this.window.on('resize', this.invalidateLayout) 77 | } 78 | stopResizeListener() { 79 | this.window.off('resize', this.invalidateLayout) 80 | } 81 | } 82 | 83 | class Tabs extends EventEmitter { 84 | tabList = [] 85 | selected = null 86 | 87 | constructor(browserWindow) { 88 | super() 89 | this.window = browserWindow 90 | } 91 | 92 | destroy() { 93 | this.tabList.forEach((tab) => tab.destroy()) 94 | this.tabList = [] 95 | 96 | this.selected = undefined 97 | 98 | if (this.window) { 99 | this.window.destroy() 100 | this.window = undefined 101 | } 102 | } 103 | 104 | get(tabId) { 105 | return this.tabList.find((tab) => tab.id === tabId) 106 | } 107 | 108 | create(webContentsViewOptions) { 109 | const tab = new Tab(this.window, webContentsViewOptions) 110 | this.tabList.push(tab) 111 | if (!this.selected) this.selected = tab 112 | tab.show() // must be attached to window 113 | this.emit('tab-created', tab) 114 | this.select(tab.id) 115 | return tab 116 | } 117 | 118 | remove(tabId) { 119 | const tabIndex = this.tabList.findIndex((tab) => tab.id === tabId) 120 | if (tabIndex < 0) { 121 | throw new Error(`Tabs.remove: unable to find tab.id = ${tabId}`) 122 | } 123 | const tab = this.tabList[tabIndex] 124 | this.tabList.splice(tabIndex, 1) 125 | tab.destroy() 126 | if (this.selected === tab) { 127 | this.selected = undefined 128 | const nextTab = this.tabList[tabIndex] || this.tabList[tabIndex - 1] 129 | if (nextTab) this.select(nextTab.id) 130 | } 131 | this.emit('tab-destroyed', tab) 132 | if (this.tabList.length === 0) { 133 | this.destroy() 134 | } 135 | } 136 | 137 | select(tabId) { 138 | const tab = this.get(tabId) 139 | if (!tab) return 140 | if (this.selected) this.selected.hide() 141 | tab.show() 142 | this.selected = tab 143 | this.emit('tab-selected', tab) 144 | } 145 | } 146 | 147 | exports.Tabs = Tabs 148 | -------------------------------------------------------------------------------- /packages/shell/browser/ui/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WebUI", 3 | "version": "1.0.0", 4 | "manifest_version": 3, 5 | "permissions": [], 6 | "chrome_url_overrides": { 7 | "newtab": "new-tab.html" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/shell/browser/ui/new-tab.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | New Tab 6 | 11 | 12 | 13 | New Tab 14 | 15 | https://www.google.com 16 | https://www.youtube.com 17 | 18 | https://github.com/electron/electron 19 | 20 | 21 | https://github.com/samuelmaddock/electron-browser-shell 24 | 25 | https://chromewebstore.google.com 26 | https://microsoftedge.microsoft.com/addons/Microsoft-Edge-Extensions-Home 27 | https://permission.site 28 | https://samuelmaddock.com 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /packages/shell/browser/ui/webui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Shell Browser 6 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 🔊 224 | ✕ 225 | 226 | 227 | 228 | 229 | + 230 | 231 | 232 | 🗕 233 | 🗖 234 | 🗙 235 | 236 | 237 | 238 | 239 | 240 | ⬅️ 241 | ➡️ 242 | 🔄 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | -------------------------------------------------------------------------------- /packages/shell/browser/ui/webui.js: -------------------------------------------------------------------------------- 1 | class WebUI { 2 | windowId = -1 3 | activeTabId = -1 4 | tabList = [] 5 | 6 | constructor() { 7 | const $ = document.querySelector.bind(document) 8 | 9 | this.$ = { 10 | tabList: $('#tabstrip .tab-list'), 11 | tabTemplate: $('#tabtemplate'), 12 | createTabButton: $('#createtab'), 13 | goBackButton: $('#goback'), 14 | goForwardButton: $('#goforward'), 15 | reloadButton: $('#reload'), 16 | addressUrl: $('#addressurl'), 17 | 18 | browserActions: $('#actions'), 19 | 20 | minimizeButton: $('#minimize'), 21 | maximizeButton: $('#maximize'), 22 | closeButton: $('#close'), 23 | } 24 | 25 | this.$.createTabButton.addEventListener('click', () => chrome.tabs.create()) 26 | this.$.goBackButton.addEventListener('click', () => chrome.tabs.goBack()) 27 | this.$.goForwardButton.addEventListener('click', () => chrome.tabs.goForward()) 28 | this.$.reloadButton.addEventListener('click', () => chrome.tabs.reload()) 29 | this.$.addressUrl.addEventListener('keypress', this.onAddressUrlKeyPress.bind(this)) 30 | 31 | this.$.minimizeButton.addEventListener('click', () => 32 | chrome.windows.get(chrome.windows.WINDOW_ID_CURRENT, (win) => { 33 | chrome.windows.update(win.id, { state: win.state === 'minimized' ? 'normal' : 'minimized' }) 34 | }), 35 | ) 36 | this.$.maximizeButton.addEventListener('click', () => 37 | chrome.windows.get(chrome.windows.WINDOW_ID_CURRENT, (win) => { 38 | chrome.windows.update(win.id, { state: win.state === 'maximized' ? 'normal' : 'maximized' }) 39 | }), 40 | ) 41 | this.$.closeButton.addEventListener('click', () => chrome.windows.remove()) 42 | 43 | const platformClass = `platform-${navigator.userAgentData.platform.toLowerCase()}` 44 | document.body.classList.add(platformClass) 45 | 46 | this.initTabs() 47 | } 48 | 49 | async initTabs() { 50 | const tabs = await new Promise((resolve) => chrome.tabs.query({ windowId: -2 }, resolve)) 51 | this.tabList = [...tabs] 52 | this.renderTabs() 53 | 54 | const activeTab = this.tabList.find((tab) => tab.active) 55 | if (activeTab) { 56 | this.setActiveTab(activeTab) 57 | } 58 | 59 | // Wait to setup tabs and windowId prior to listening for updates. 60 | this.setupBrowserListeners() 61 | } 62 | 63 | setupBrowserListeners() { 64 | if (!chrome.tabs.onCreated) { 65 | throw new Error(`chrome global not setup. Did the extension preload not get run?`) 66 | } 67 | 68 | const findTab = (tabId) => { 69 | const existingTab = this.tabList.find((tab) => tab.id === tabId) 70 | return existingTab 71 | } 72 | 73 | const findOrCreateTab = (tabId) => { 74 | const existingTab = findTab(tabId) 75 | if (existingTab) return existingTab 76 | 77 | const newTab = { id: tabId } 78 | this.tabList.push(newTab) 79 | return newTab 80 | } 81 | 82 | chrome.tabs.onCreated.addListener((tab) => { 83 | if (tab.windowId !== this.windowId) return 84 | const newTab = findOrCreateTab(tab.id) 85 | Object.assign(newTab, tab) 86 | this.renderTabs() 87 | }) 88 | 89 | chrome.tabs.onActivated.addListener((activeInfo) => { 90 | if (activeInfo.windowId !== this.windowId) return 91 | 92 | this.setActiveTab(activeInfo) 93 | }) 94 | 95 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, details) => { 96 | const tab = findTab(tabId) 97 | if (!tab) return 98 | Object.assign(tab, details) 99 | this.renderTabs() 100 | if (tabId === this.activeTabId) this.renderToolbar(tab) 101 | }) 102 | 103 | chrome.tabs.onRemoved.addListener((tabId) => { 104 | const tabIndex = this.tabList.findIndex((tab) => tab.id === tabId) 105 | if (tabIndex > -1) { 106 | this.tabList.splice(tabIndex, 1) 107 | this.$.tabList.querySelector(`[data-tab-id="${tabId}"]`).remove() 108 | } 109 | }) 110 | } 111 | 112 | setActiveTab(activeTab) { 113 | this.activeTabId = activeTab.id || activeTab.tabId 114 | this.windowId = activeTab.windowId 115 | 116 | for (const tab of this.tabList) { 117 | if (tab.id === this.activeTabId) { 118 | tab.active = true 119 | this.renderTab(tab) 120 | this.renderToolbar(tab) 121 | } else { 122 | tab.active = false 123 | } 124 | } 125 | } 126 | 127 | onAddressUrlKeyPress(event) { 128 | if (event.code === 'Enter') { 129 | const url = this.$.addressUrl.value 130 | chrome.tabs.update({ url }) 131 | } 132 | } 133 | 134 | createTabNode(tab) { 135 | const tabElem = this.$.tabTemplate.content.cloneNode(true).firstElementChild 136 | tabElem.dataset.tabId = tab.id 137 | 138 | tabElem.addEventListener('click', () => { 139 | chrome.tabs.update(tab.id, { active: true }) 140 | }) 141 | tabElem.querySelector('.close').addEventListener('click', () => { 142 | chrome.tabs.remove(tab.id) 143 | }) 144 | const faviconElem = tabElem.querySelector('.favicon') 145 | faviconElem?.addEventListener('load', () => { 146 | faviconElem.classList.toggle('loaded', true) 147 | }) 148 | faviconElem?.addEventListener('error', () => { 149 | faviconElem.classList.toggle('loaded', false) 150 | }) 151 | 152 | this.$.tabList.appendChild(tabElem) 153 | return tabElem 154 | } 155 | 156 | renderTab(tab) { 157 | let tabElem = this.$.tabList.querySelector(`[data-tab-id="${tab.id}"]`) 158 | if (!tabElem) tabElem = this.createTabNode(tab) 159 | 160 | if (tab.active) { 161 | tabElem.dataset.active = '' 162 | } else { 163 | delete tabElem.dataset.active 164 | } 165 | 166 | const favicon = tabElem.querySelector('.favicon') 167 | if (tab.favIconUrl) { 168 | favicon.src = tab.favIconUrl 169 | } else { 170 | delete favicon.src 171 | } 172 | 173 | tabElem.querySelector('.title').textContent = tab.title 174 | tabElem.querySelector('.audio').disabled = !tab.audible 175 | } 176 | 177 | renderTabs() { 178 | this.tabList.forEach((tab) => { 179 | this.renderTab(tab) 180 | }) 181 | } 182 | 183 | renderToolbar(tab) { 184 | this.$.addressUrl.value = tab.url 185 | // this.$.browserActions.tab = tab.id 186 | } 187 | } 188 | 189 | window.webui = new WebUI() 190 | -------------------------------------------------------------------------------- /packages/shell/forge.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | packagerConfig: { 3 | name: 'Shell', 4 | asar: true, 5 | extraResource: ['browser/ui'], 6 | }, 7 | rebuildConfig: {}, 8 | makers: [ 9 | { 10 | name: '@electron-forge/maker-zip', 11 | platforms: ['darwin', 'win32'], 12 | }, 13 | { 14 | name: '@electron-forge/maker-dmg', 15 | platforms: ['darwin'], 16 | }, 17 | ], 18 | plugins: [ 19 | { 20 | name: '@electron-forge/plugin-webpack', 21 | config: { 22 | mainConfig: './webpack.main.config.js', 23 | renderer: { 24 | config: './webpack.renderer.config.js', 25 | entryPoints: [ 26 | { 27 | name: 'browser', 28 | preload: { 29 | js: './preload.ts', 30 | }, 31 | }, 32 | ], 33 | }, 34 | devServer: { 35 | client: { 36 | overlay: false, 37 | }, 38 | }, 39 | }, 40 | }, 41 | ].filter(Boolean), 42 | } 43 | -------------------------------------------------------------------------------- /packages/shell/index.js: -------------------------------------------------------------------------------- 1 | const Browser = require('./browser/main') 2 | new Browser() 3 | -------------------------------------------------------------------------------- /packages/shell/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shell", 3 | "productName": "Shell", 4 | "version": "2.2.0", 5 | "description": "Minimum Viable Browser shell built on Electron.", 6 | "main": ".webpack/main", 7 | "scripts": { 8 | "build": "npm run package", 9 | "start": "electron-forge start", 10 | "start:trace": "electron-forge start -- --trace-startup=\"*\" --trace-startup-file=\"$HOME/Desktop/trace.json\"", 11 | "package": "electron-forge package", 12 | "make": "electron-forge make" 13 | }, 14 | "license": "MIT", 15 | "author": "Samuel Maddock ", 16 | "private": true, 17 | "dependencies": { 18 | "electron-chrome-context-menu": "^1.1.0", 19 | "electron-chrome-extensions": "^4.6.0", 20 | "electron-chrome-web-store": "^0.10.0", 21 | "electron-squirrel-startup": "^1.0.0" 22 | }, 23 | "devDependencies": { 24 | "@electron-forge/cli": "^7.5.0", 25 | "@electron-forge/maker-dmg": "^7.5.0", 26 | "@electron-forge/maker-squirrel": "^7.5.0", 27 | "@electron-forge/maker-zip": "^7.5.0", 28 | "@electron-forge/plugin-auto-unpack-natives": "^7.5.0", 29 | "@electron-forge/plugin-webpack": "^7.5.0", 30 | "copy-webpack-plugin": "^11.0.0", 31 | "cross-env": "^7.0.2", 32 | "electron": "35.0.0-beta.12" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/shell/preload.ts: -------------------------------------------------------------------------------- 1 | import { injectBrowserAction } from 'electron-chrome-extensions/browser-action' 2 | 3 | // Inject element into WebUI 4 | if (location.protocol === 'chrome-extension:' && location.pathname === '/webui.html') { 5 | injectBrowserAction() 6 | } 7 | -------------------------------------------------------------------------------- /packages/shell/webpack.main.config.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require('copy-webpack-plugin') 2 | 3 | module.exports = { 4 | entry: './index.js', 5 | module: { 6 | rules: [], 7 | }, 8 | resolve: { 9 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css', '.json'], 10 | }, 11 | plugins: [ 12 | new CopyWebpackPlugin({ 13 | patterns: [ 14 | require.resolve('electron-chrome-extensions/preload'), 15 | require.resolve('electron-chrome-web-store/preload'), 16 | ], 17 | }), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /packages/shell/webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devtool: false, 3 | resolve: { 4 | extensions: ['.js', '.ts', '.jsx', '.tsx', '.css'], 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelmaddock/electron-browser-shell/3ed5c698d71d9c92fda00544730a0981c13cce36/screenshot.png -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | function cleanDirectory(dirPath) { 5 | const resolvedPath = path.resolve(dirPath) 6 | 7 | const parentDir = path.basename(path.dirname(resolvedPath)) 8 | if (parentDir !== 'packages') { 9 | console.error(`Error: Directory "${resolvedPath}" is not inside a "packages" folder`) 10 | return 11 | } 12 | 13 | const distPath = path.join(resolvedPath, 'dist') 14 | 15 | if (fs.existsSync(distPath)) { 16 | fs.rmSync(distPath, { recursive: true, force: true }) 17 | console.log(`deleted: ${distPath}`) 18 | } 19 | } 20 | 21 | cleanDirectory(process.cwd()) 22 | -------------------------------------------------------------------------------- /scripts/find_chrome_apis.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script searches through all .js files in the specified directory 4 | # (and its subdirectories) to find references to Chrome extension APIs 5 | # or another specified object pattern used in place of 'chrome'. 6 | # It uses ripgrep (rg) to locate calls like objectName().x.y(...) or objectName.x.y(...), 7 | # extracts only the matching parts, removes the trailing parenthesis, 8 | # and then outputs a sorted list of unique API calls. 9 | # 10 | # Usage: 11 | # ./find_chrome_apis.sh /path/to/directory [OBJECT_NAME] 12 | # 13 | # Examples: 14 | # ./find_chrome_apis.sh /path/to/directory # defaults to 'chrome' 15 | # ./find_chrome_apis.sh /path/to/directory chrome # explicitly 'chrome' 16 | # ./find_chrome_apis.sh /path/to/directory browser # firefox extensions 17 | # ./find_chrome_apis.sh /path/to/directory "browser_polyfill_default()" 18 | # 19 | # Requirements: 20 | # - ripgrep (rg) 21 | # - sed 22 | # - sort & uniq (usually available by default on most Unix systems) 23 | 24 | if [ -z "$1" ]; then 25 | echo "Usage: $0 DIRECTORY [OBJECT_NAME]" 26 | exit 1 27 | fi 28 | 29 | DIRECTORY="$1" 30 | OBJECT_NAME="${2:-chrome}" # Default to 'chrome' if not provided 31 | 32 | # Escape all regex special characters in the object name 33 | ESCAPED_OBJECT_NAME=$(printf '%s' "$OBJECT_NAME" | sed 's/[][(){}|.^$*+?\\/-]/\\&/g') 34 | 35 | rg --glob '*.js' --only-matching --no-filename "${ESCAPED_OBJECT_NAME}\.[A-Za-z_]\w*(\.[A-Za-z_]\w*)*\(" "$DIRECTORY" \ 36 | | sed 's/($//' \ 37 | | sort \ 38 | | uniq 39 | -------------------------------------------------------------------------------- /scripts/generate-hash.js: -------------------------------------------------------------------------------- 1 | const nodeCrypto = require('node:crypto') 2 | 3 | if (process.argv.length !== 3) { 4 | console.error('Usage: generate-hash.js ') 5 | process.exit(1) 6 | } 7 | 8 | function generateHash(input) { 9 | const hash = nodeCrypto.createHash('sha256') 10 | hash.update('crx' + input) 11 | return hash.digest('hex') 12 | } 13 | 14 | const arg = process.argv[2] 15 | const hash = generateHash(arg) 16 | console.log(hash) 17 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["DOM", "es2019", "es2020.promise", "es2020.bigint", "es2020.string"], 4 | "module": "commonjs", 5 | "target": "es2019", 6 | 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | --------------------------------------------------------------------------------