Skip to content

Commit

Permalink
feat: add <browser-action-list>
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelmaddock committed Oct 18, 2020
1 parent 5f1e695 commit 7a50897
Show file tree
Hide file tree
Showing 19 changed files with 429 additions and 141 deletions.
36 changes: 36 additions & 0 deletions build/webpack/webpack.config.base.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const webpack = require('webpack')

const base = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
devtool: 'source-map',

module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
},
],
},

resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
modules: ['node_modules'],
},

plugins: [
// new webpack.EnvironmentPlugin({
// NODE_ENV: 'production',
// }),

new webpack.NamedModulesPlugin(),
],
}

module.exports = base
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
"packages/electron-chrome-extensions"
],
"scripts": {
"start": "cd packages/electron-chrome-extensions && yarn build && cd ../shell && npm start"
"build": "yarn run build-extensions && yarn run build-shell",
"build-extensions": "yarn --cwd ./packages/electron-chrome-extensions build",
"build-shell": "yarn --cwd ./packages/shell build",
"start": "yarn build && yarn --cwd ./packages/shell start"
},
"license": "GPL-3.0",
"author": "Samuel Maddock <[email protected]>",
Expand Down
4 changes: 3 additions & 1 deletion packages/electron-chrome-extensions/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
dist
dist
*.js
*.map
41 changes: 37 additions & 4 deletions packages/electron-chrome-extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const { Extensions } = require('electron-chrome-extensions')

### Advanced

Intended for multi-tab browsers with full support for Chrome extension APIs.
Multi-tab browser with full support for Chrome extension APIs.

> For a complete example, see the [`electron-browser-shell`](https://github.com/samuelmaddock/electron-browser-shell) project.
Expand Down Expand Up @@ -155,6 +155,36 @@ the extension to appear as a button in the browser top bar.

This method will soon go away and no longer be necessary.

### Element: `<browser-action-list>`

The `<browser-action-list>` element provides a row of [browser actions](https://developer.chrome.com/extensions/browserAction) which may be pressed to activate the `chrome.browserAction.onClicked` event or display the extension popup.

To enable the element on a webpage, you must define a preload script which injects the API on specific pages.

#### Attributes

- `tab` string - The tab's `Electron.WebContents` ID to use for displaying
the relevant browser action state.

#### Browser action example

##### Preload
```js
import { injectBrowserAction } from 'electron-chrome-extensions/dist/browser-action'

// Inject <browser-action-list> element into our browser
if (location.href === 'webui://browser-chrome.html') {
injectBrowserAction()
}
```

> The use of `import` implies that your preload script must be compiled using a JavaScript bundler like Webpack.
##### Webpage
```html
<browser-action-list tab="1"></browser-action-list>
```

## Supported `chrome.*` APIs

The following APIs are supported, in addition to [those already built-in to Electron.](https://www.electronjs.org/docs/api/extensions)
Expand Down Expand Up @@ -271,11 +301,14 @@ Although certain APIs may not be implemented, some methods and properties are st

## Limitations

- Currently targeting Electron v11. For minimum support, Electron v9 is required.
- Only one session can be supported currently.
- Usage of Electron's `webRequest` API will prevent `chrome.webRequest` listeners from being called.
### electron-chrome-extensions
- Uses features which will land in Electron v11 stable. Minimum support requires Electron v9.
- Currently only one session can be supported.
- Chrome's Manifest V3 extensions are not yet supported.

### electron
- Usage of Electron's `webRequest` API will prevent `chrome.webRequest` listeners from being called.

## License

GPL-3
Expand Down
4 changes: 2 additions & 2 deletions packages/electron-chrome-extensions/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "electron-chrome-extensions",
"version": "1.0.0",
"version": "1.1.0",
"description": "Chrome extension support for Electron",
"main": "dist/index.js",
"scripts": {
Expand Down Expand Up @@ -40,7 +40,7 @@
"@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-proposal-class-properties",
["@babel/plugin-proposal-class-properties", { "loose": true }],
"@babel/plugin-proposal-optional-chaining"
]
}
Expand Down
232 changes: 232 additions & 0 deletions packages/electron-chrome-extensions/src/browser-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { ipcRenderer, contextBridge, webFrame } from 'electron'
import { EventEmitter } from 'events'

export const injectBrowserAction = () => {
const actionMap = new Map<string, any>()
const internalEmitter = new EventEmitter()

const browserActionImpl = {
addEventListener(name: string, listener: (...args: any[]) => void) {
internalEmitter.addListener(name, listener)
},
removeEventListener(name: string, listener: (...args: any[]) => void) {
internalEmitter.removeListener(name, listener)
},

getAction(extensionId: string, partition: string = '') {
return actionMap.get(extensionId)
},
async getAll(): Promise<any> {
const actions = await ipcRenderer.invoke('browserAction.getAll')
for (const action of actions) {
actionMap.set(action.id, action)
}
queueMicrotask(() => internalEmitter.emit('update'))
return actions
},

activate: (extensionId: string) => {
ipcRenderer.invoke('browserAction.activate', extensionId)
},
}

ipcRenderer.invoke('browserAction.addObserver')

ipcRenderer.on('browserAction.update', () => {
browserActionImpl.getAll()
})

// Function body to run in the main world.
// IMPORTANT: This must be self-contained, no closure variables can be used!
function mainWorldScript() {
const browserAction = (window as any).browserAction as typeof browserActionImpl

class BrowserActionElement extends HTMLButtonElement {
private badge?: HTMLDivElement

get id(): string {
return this.getAttribute('id') || ''
}

set id(id: string) {
this.setAttribute('id', id)
}

get tab(): number {
const tabId = parseInt(this.getAttribute('tab') || '', 10)
return typeof tabId === 'number' && !isNaN(tabId) ? tabId : -1
}

set tab(tab: number) {
this.setAttribute('tab', `${tab}`)
}

static get observedAttributes() {
return ['id', 'tab']
}

constructor() {
super()

this.addEventListener('click', this.onClick.bind(this))

browserAction.addEventListener('update', this.update.bind(this))

const style = document.createElement('style')
style.textContent = `
button {
width: 24px;
height: 24px;
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: 80%;
border: none;
padding: 0;
position: relative;
}
.badge {
box-sizing: border-box;
max-width: 100%;
height: 12px;
padding: 0 4px;
border-radius: 2px;
position: absolute;
bottom: 0;
right: 0;
pointer-events: none;
line-height: 1.2;
font-size: 10px;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
}`
this.appendChild(style)
}

attributeChangedCallback() {
this.update()
}

private onClick() {
browserAction.activate(this.id)
}

private getBadge() {
let badge = this.badge
if (!badge) {
this.badge = badge = document.createElement('div')
badge.className = 'badge'
this.appendChild(badge)
}
return badge
}

private update() {
const action = browserAction.getAction(this.id)

const activeTabId = this.tab
const tabInfo = activeTabId > -1 ? action.tabs[activeTabId] : {}
const info = { ...tabInfo, ...action }

this.title = typeof info.title === 'string' ? info.title : ''

if (info.imageData) {
this.style.backgroundImage = info.imageData ? `url(${info.imageData['32']})` : ''
} else if (info.icon) {
this.style.backgroundImage = `url(${info.icon})`
}

if (info.text) {
const badge = this.getBadge()
badge.textContent = info.text
badge.style.color = '#fff' // TODO: determine bg lightness?
badge.style.backgroundColor = info.color
} else if (this.badge) {
this.badge.remove()
}
}
}

customElements.define('browser-action', BrowserActionElement, { extends: 'button' })

class BrowserActionListElement extends HTMLElement {
get tab(): number {
const tabId = parseInt(this.getAttribute('tab') || '', 10)
return typeof tabId === 'number' && !isNaN(tabId) ? tabId : -1
}

set tab(tab: number) {
this.setAttribute('tab', `${tab}`)
}

get partition(): string {
return this.getAttribute('partition') || ''
}

set partition(partition: string) {
this.setAttribute('partition', partition)
}

static get observedAttributes() {
return ['tab', 'partition']
}

constructor() {
super()

const shadowRoot = this.attachShadow({ mode: 'open' })

const style = document.createElement('style')
style.textContent = `
:host {
display: flex;
flex-direction: row;
gap: 5px;
}`
shadowRoot.appendChild(style)

this.update()
}

attributeChangedCallback() {
this.update()
}

private async update() {
// TODO: filter with `partition` attribute
const actions = await browserAction.getAll()
const activeTabId = this.tab

for (const action of actions) {
let browserActionNode = this.shadowRoot?.querySelector(
`[id=${action.id}]`
) as BrowserActionElement
if (!browserActionNode) {
const node = document.createElement('button', {
is: 'browser-action',
}) as BrowserActionElement
node.id = action.id
browserActionNode = node
this.shadowRoot?.appendChild(browserActionNode)
}
browserActionNode.tab = activeTabId
}
}
}

customElements.define('browser-action-list', BrowserActionListElement)
}

try {
contextBridge.exposeInMainWorld('browserAction', browserActionImpl)

// Mutate global 'chrome' object with additional APIs in the main world
webFrame.executeJavaScript(`(${mainWorldScript}());`)
} catch {
// contextBridge threw an error which means we're in the main world so we
// can just execute our function.
mainWorldScript()
}
}
Loading

0 comments on commit 7a50897

Please sign in to comment.