From 2000d57f4191ab442e761b309edad94fee0e69fc Mon Sep 17 00:00:00 2001 From: Samuel Maddock Date: Mon, 3 Feb 2025 20:05:43 -0500 Subject: [PATCH] feat: runtime.connectNative --- package.json | 2 +- .../script/native-messaging-host/.gitignore | 3 + .../script/native-messaging-host/build.js | 82 ++++++++++++ .../script/native-messaging-host/main.js | 26 ++++ .../spec/chrome-nativeMessaging-spec.ts | 30 +++++ .../spec/crx-helpers.ts | 7 + .../src/browser/api/browser-action.ts | 4 +- .../browser/api/lib/native-messaging-host.ts | 125 ++++++++++++++++++ .../src/browser/api/runtime.ts | 24 ++++ .../src/browser/router.ts | 26 +++- .../src/renderer/index.ts | 95 +++++++++++++ .../src/browser/loader.ts | 15 ++- packages/shell/browser/main.js | 9 ++ 13 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 packages/electron-chrome-extensions/script/native-messaging-host/.gitignore create mode 100755 packages/electron-chrome-extensions/script/native-messaging-host/build.js create mode 100644 packages/electron-chrome-extensions/script/native-messaging-host/main.js create mode 100644 packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts create mode 100644 packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts diff --git a/package.json b/package.json index 6e8d7b1..c2af7ab 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "start": "yarn build:context-menu && yarn build:extensions && yarn build:chrome-web-store && yarn --cwd ./packages/shell start", "start:debug": "cross-env SHELL_DEBUG=true DEBUG='electron*' yarn start", "start:electron-dev": "cross-env ELECTRON_OVERRIDE_DIST_PATH=$(e show out --path) ELECTRON_ENABLE_LOGGING=1 yarn start", - "start:electron-dev:debug": "cross-env DEBUG='electron*' yarn start:electron-dev", + "start:electron-dev:debug": "cross-env SHELL_DEBUG=true DEBUG='electron*' yarn start:electron-dev", "start:electron-dev:trace": "cross-env ELECTRON_OVERRIDE_DIST_PATH=$(e show out --path) ELECTRON_ENABLE_LOGGING=1 yarn --cwd ./packages/shell start:trace", "start:skip-build": "cross-env SHELL_DEBUG=true DEBUG='electron-chrome-extensions*' yarn --cwd ./packages/shell start", "test": "yarn test:extensions", diff --git a/packages/electron-chrome-extensions/script/native-messaging-host/.gitignore b/packages/electron-chrome-extensions/script/native-messaging-host/.gitignore new file mode 100644 index 0000000..001c831 --- /dev/null +++ b/packages/electron-chrome-extensions/script/native-messaging-host/.gitignore @@ -0,0 +1,3 @@ +crxtesthost +crxtesthost.blob +sea-config.json diff --git a/packages/electron-chrome-extensions/script/native-messaging-host/build.js b/packages/electron-chrome-extensions/script/native-messaging-host/build.js new file mode 100755 index 0000000..25725d0 --- /dev/null +++ b/packages/electron-chrome-extensions/script/native-messaging-host/build.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +const { promises: fs } = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const util = require('node:util') +const cp = require('node:child_process') +const exec = util.promisify(cp.exec) + +const basePath = 'script/native-messaging-host/' +const outDir = path.join(__dirname, '.') +const exeName = `crxtesthost${process.platform === 'win32' ? '.exe' : ''}` +const seaBlobName = 'crxtesthost.blob' + +async function createSEA() { + await fs.rm(path.join(outDir, seaBlobName), { force: true }) + await fs.rm(path.join(outDir, exeName), { force: true }) + + await exec('node --experimental-sea-config sea-config.json', { cwd: outDir }) + await fs.cp(process.execPath, path.join(outDir, exeName)) + + if (process.platform === 'darwin') { + await exec(`codesign --remove-signature ${exeName}`, { cwd: outDir }) + } + + console.info(`Building ${exeName}…`) + await exec( + `npx postject ${basePath}${exeName} NODE_SEA_BLOB ${basePath}${seaBlobName} --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --macho-segment-name NODE_SEA`, + { cwd: outDir }, + ) + + if (process.platform === 'darwin') { + await exec(`codesign --sign - ${exeName}`, { cwd: outDir }) + } +} + +async function installConfig(extensionIds) { + console.info(`Installing config…`) + + const name = 'com.crx.test' + const config = { + name, + description: 'electron-chrome-extensions test', + path: path.join(outDir, exeName), + type: 'stdio', + allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`), + } + + switch (process.platform) { + case 'darwin': { + const configPath = path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Electron', + 'NativeMessagingHosts', + ) + await fs.mkdir(configPath, { recursive: true }) + const filePath = path.join(configPath, `${name}.json`) + const data = Buffer.from(JSON.stringify(config, null, 2)) + await fs.writeFile(filePath, data) + break + } + default: + return + } +} + +async function main() { + const extensionIdsArg = process.argv[2] + if (!extensionIdsArg) { + console.error('Must pass in csv of allowed extension IDs') + process.exit(1) + } + + const extensionIds = extensionIdsArg.split(',') + console.log(extensionIds) + await createSEA() + await installConfig(extensionIds) +} + +main() diff --git a/packages/electron-chrome-extensions/script/native-messaging-host/main.js b/packages/electron-chrome-extensions/script/native-messaging-host/main.js new file mode 100644 index 0000000..e98d6ec --- /dev/null +++ b/packages/electron-chrome-extensions/script/native-messaging-host/main.js @@ -0,0 +1,26 @@ +const fs = require('node:fs') + +function readMessage() { + let buffer = Buffer.alloc(4) + if (fs.readSync(0, buffer, 0, 4, null) !== 4) { + process.exit(1) + } + + let messageLength = buffer.readUInt32LE(0) + let messageBuffer = Buffer.alloc(messageLength) + fs.readSync(0, messageBuffer, 0, messageLength, null) + + return JSON.parse(messageBuffer.toString()) +} + +function sendMessage(message) { + let json = JSON.stringify(message) + let buffer = Buffer.alloc(4 + json.length) + buffer.writeUInt32LE(json.length, 0) + buffer.write(json, 4) + + fs.writeSync(1, buffer) +} + +const message = readMessage() +sendMessage(message) diff --git a/packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts b/packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts new file mode 100644 index 0000000..c5b717f --- /dev/null +++ b/packages/electron-chrome-extensions/spec/chrome-nativeMessaging-spec.ts @@ -0,0 +1,30 @@ +import { promisify } from 'node:util' +import * as cp from 'node:child_process' +import * as path from 'node:path' +const exec = promisify(cp.exec) + +import { useExtensionBrowser, useServer } from './hooks' +import { getExtensionId } from './crx-helpers' + +// TODO: +describe.skip('nativeMessaging', () => { + const server = useServer() + const browser = useExtensionBrowser({ + url: server.getUrl, + extensionName: 'rpc', + }) + const hostApplication = 'com.crx.test' + + before(async () => { + const extensionId = await getExtensionId('rpc') + const scriptPath = path.join(__dirname, '..', 'script', 'native-messaging-host', 'build.js') + await exec(`${scriptPath} ${extensionId}`) + }) + + describe('connectNative()', () => { + it('returns tab details', async () => { + const result = await browser.crx.exec('runtime.connectNative', hostApplication) + console.log({ result }) + }) + }) +}) diff --git a/packages/electron-chrome-extensions/spec/crx-helpers.ts b/packages/electron-chrome-extensions/spec/crx-helpers.ts index 9e3f3b2..0d1f1b8 100644 --- a/packages/electron-chrome-extensions/spec/crx-helpers.ts +++ b/packages/electron-chrome-extensions/spec/crx-helpers.ts @@ -101,3 +101,10 @@ export async function waitForBackgroundScriptEvaluated( backgroundHost.on('console-message', onConsoleMessage) }) } + +export async function getExtensionId(name: string) { + const extensionPath = path.join(__dirname, 'fixtures', name) + const ses = createCrxSession().session + const extension = await ses.loadExtension(extensionPath) + return extension.id +} diff --git a/packages/electron-chrome-extensions/src/browser/api/browser-action.ts b/packages/electron-chrome-extensions/src/browser/api/browser-action.ts index bfded03..1193882 100644 --- a/packages/electron-chrome-extensions/src/browser/api/browser-action.ts +++ b/packages/electron-chrome-extensions/src/browser/api/browser-action.ts @@ -163,7 +163,7 @@ export class BrowserActionAPI { handle( 'browserAction.addObserver', (event) => { - const { sender: observer } = event + const observer = event.sender as any this.observers.add(observer) // TODO(mv3): need a destroyed event on workers observer.once?.('destroyed', () => { @@ -371,7 +371,7 @@ export class BrowserActionAPI { const { eventType, extensionId, tabId } = details debug( - `activate [eventType: ${eventType}, extensionId: '${extensionId}', tabId: ${tabId}, senderId: ${sender.id}]`, + `activate [eventType: ${eventType}, extensionId: '${extensionId}', tabId: ${tabId}, senderId: ${sender!.id}]`, ) switch (eventType) { diff --git a/packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts b/packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts new file mode 100644 index 0000000..3e577ed --- /dev/null +++ b/packages/electron-chrome-extensions/src/browser/api/lib/native-messaging-host.ts @@ -0,0 +1,125 @@ +import { spawn } from 'node:child_process' +import { promises as fs } from 'node:fs' +import * as path from 'node:path' +import { app } from 'electron' +import { ExtensionSender } from '../../router' + +const d = require('debug')('electron-chrome-extensions:nativeMessaging') + +interface NativeConfig { + name: string + description: string + path: string + type: 'stdio' + allowed_origins: string[] +} + +async function readNativeMessagingHostConfig( + application: string, +): Promise { + let searchPaths = [path.join(app.getPath('userData'), 'NativeMessagingHosts')] + switch (process.platform) { + case 'darwin': + searchPaths.push('/Library/Google/Chrome/NativeMessagingHosts') + break + default: + throw new Error('Unsupported platform') + } + + for (const basePath of searchPaths) { + const filePath = path.join(basePath, `${application}.json`) + try { + const data = await fs.readFile(filePath) + return JSON.parse(data.toString()) + } catch { + continue + } + } +} +export class NativeMessagingHost { + private process?: ReturnType + private sender: ExtensionSender + private connectionId: string + private connected: boolean = false + private pending?: any[] + + constructor( + extensionId: string, + sender: ExtensionSender, + connectionId: string, + application: string, + ) { + this.sender = sender + this.sender.ipc.on(`crx-native-msg-${connectionId}`, this.receiveExtensionMessage) + this.connectionId = connectionId + this.launch(application, extensionId) + } + + destroy() { + this.connected = false + if (this.process) { + this.process.disconnect() + this.process = undefined + } + this.sender.ipc.off(`crx-native-msg-${this.connectionId}`, this.receiveExtensionMessage) + // TODO: send disconnect + } + + private async launch(application: string, extensionId: string) { + const config = await readNativeMessagingHostConfig(application) + if (!config) { + d('launch: unable to find %s for %s', application, extensionId) + this.destroy() + return + } + + d('launch: spawning %s for %s', config.path, extensionId) + // TODO: must be a binary executable + this.process = spawn(config.path, [`chrome-extension://${extensionId}/`], { + shell: false, + }) + + this.process.stdout!.on('data', this.receive) + this.process.stderr!.on('data', (data) => { + d('stderr: %s', data.toString()) + }) + this.process.on('error', (err) => d('error: %s', err)) + this.process.on('exit', (code) => d('exited %d', code)) + + this.connected = true + + if (this.pending && this.pending.length > 0) { + d('sending %d pending messages', this.pending.length) + this.pending.forEach((msg) => this.send(msg)) + this.pending = [] + } + } + + private receiveExtensionMessage = (_event: Electron.IpcMainEvent, message: any) => { + this.send(message) + } + + private send(json: any) { + d('send', json) + + if (!this.connected) { + const pending = this.pending || (this.pending = []) + pending.push(json) + d('send: pending') + return + } + + const message = JSON.stringify(json) + const buffer = Buffer.alloc(4 + message.length) + buffer.writeUInt32LE(message.length, 0) + buffer.write(message, 4) + this.process!.stdin!.write(buffer) + } + + private receive = (data: Buffer) => { + const length = data.readUInt32LE(0) + const message = JSON.parse(data.subarray(4, 4 + length).toString()) + d('receive: %s', message) + this.sender.send(`crx-native-msg-${this.connectionId}`, message) + } +} diff --git a/packages/electron-chrome-extensions/src/browser/api/runtime.ts b/packages/electron-chrome-extensions/src/browser/api/runtime.ts index 5847206..cc26bc5 100644 --- a/packages/electron-chrome-extensions/src/browser/api/runtime.ts +++ b/packages/electron-chrome-extensions/src/browser/api/runtime.ts @@ -2,15 +2,39 @@ import { EventEmitter } from 'node:events' import { ExtensionContext } from '../context' import { ExtensionEvent } from '../router' import { getExtensionManifest } from './common' +import { NativeMessagingHost } from './lib/native-messaging-host' export class RuntimeAPI extends EventEmitter { + private hostMap: Record = {} + constructor(private ctx: ExtensionContext) { super() const handle = this.ctx.router.apiHandler() + handle('runtime.connectNative', this.connectNative, { permission: 'nativeMessaging' }) + handle('runtime.disconnectNative', this.disconnectNative, { permission: 'nativeMessaging' }) handle('runtime.openOptionsPage', this.openOptionsPage) } + private connectNative = async ( + event: ExtensionEvent, + connectionId: string, + application: string, + ) => { + const host = new NativeMessagingHost( + event.extension.id, + event.sender!, + connectionId, + application, + ) + this.hostMap[connectionId] = host + } + + private disconnectNative = (event: ExtensionEvent, connectionId: string) => { + this.hostMap[connectionId]?.destroy() + this.hostMap[connectionId] = undefined + } + private openOptionsPage = async ({ extension }: ExtensionEvent) => { // TODO: options page shouldn't appear in Tabs API // https://developer.chrome.com/extensions/options#tabs-api diff --git a/packages/electron-chrome-extensions/src/browser/router.ts b/packages/electron-chrome-extensions/src/browser/router.ts index 0c598d0..a15d3e2 100644 --- a/packages/electron-chrome-extensions/src/browser/router.ts +++ b/packages/electron-chrome-extensions/src/browser/router.ts @@ -152,8 +152,14 @@ class RoutingDelegate { } } +export interface ExtensionSender { + id: number + ipc: Electron.IpcMain + send: Electron.WebFrameMain['send'] +} + export interface ExtensionEvent { - sender?: any // TODO(mv3): types + sender?: ExtensionSender extension: Extension } @@ -164,6 +170,8 @@ export interface HandlerOptions { allowRemote?: boolean /** Whether an extension context is required to invoke the handler. */ extensionContext: boolean + /** Required extension permission to run the handler. */ + permission?: chrome.runtime.ManifestPermissions } interface Handler extends HandlerOptions { @@ -342,9 +350,18 @@ export class ExtensionRouter { throw new Error(`${handlerName} was sent from an unknown extension context`) } + if (handler.permission) { + const manifest: chrome.runtime.Manifest = extension?.manifest + if (!extension || !manifest.permissions?.includes(handler.permission)) { + throw new Error( + `${handlerName} requires an extension with ${handler.permission} permissions`, + ) + } + } + const extEvent: ExtensionEvent = { // TODO(mv3): handle types - sender: event.sender || (event as any).worker, + sender: event.sender || (event as any).serviceWorker, extension: extension!, } @@ -355,17 +372,18 @@ export class ExtensionRouter { return result } - private handle(name: string, callback: HandlerCallback, opts?: HandlerOptions): void { + private handle(name: string, callback: HandlerCallback, opts?: Partial): void { this.handlers.set(name, { callback, extensionContext: typeof opts?.extensionContext === 'boolean' ? opts.extensionContext : true, allowRemote: typeof opts?.allowRemote === 'boolean' ? opts.allowRemote : false, + permission: typeof opts?.permission === 'string' ? opts.permission : undefined, }) } /** Returns a callback to register API handlers for the given context. */ apiHandler() { - return (name: string, callback: HandlerCallback, opts?: HandlerOptions) => { + return (name: string, callback: HandlerCallback, opts?: Partial) => { this.handle(name, callback, opts) } } diff --git a/packages/electron-chrome-extensions/src/renderer/index.ts b/packages/electron-chrome-extensions/src/renderer/index.ts index 504b2dd..78ca6b2 100644 --- a/packages/electron-chrome-extensions/src/renderer/index.ts +++ b/packages/electron-chrome-extensions/src/renderer/index.ts @@ -51,10 +51,36 @@ export const injectExtensionAPIs = () => { } } + type ConnectNativeCallback = (connectionId: string, send: (message: any) => void) => void + const connectNative = ( + extensionId: string, + application: string, + receive: (message: any) => void, + callback: ConnectNativeCallback, + ) => { + const connectionId = (contextBridge as any).executeInMainWorld({ + func: () => crypto.randomUUID(), + }) + invokeExtension(extensionId, 'runtime.connectNative', {}, connectionId, application) + ipcRenderer.on(`crx-native-msg-${connectionId}`, (_event, message) => { + receive(message) + }) + const send = (message: any) => { + ipcRenderer.send(`crx-native-msg-${connectionId}`, message) + } + callback(connectionId, send) + } + + const disconnectNative = (extensionId: string, connectionId: string) => { + invokeExtension(extensionId, 'runtime.disconnectNative', {}, connectionId) + } + const electronContext = { invokeExtension, addExtensionListener, removeExtensionListener, + connectNative, + disconnectNative, } // Function body to run in the main world. @@ -134,6 +160,66 @@ export const injectExtensionAPIs = () => { } } + class Event implements Partial> { + private listeners: T[] = [] + + _emit(...args: any[]) { + this.listeners.forEach((listener) => { + listener(...args) + }) + } + + addListener(callback: T): void { + this.listeners.push(callback) + } + removeListener(callback: T): void { + const index = this.listeners.indexOf(callback) + if (index > -1) { + this.listeners.splice(index, 1) + } + } + } + + class NativePort implements chrome.runtime.Port { + private connectionId: string = '' + private pending: any[] = [] + + name: string = 'NativePort' + + _init(connectionId: string, send: (message: any) => void) { + this.connectionId = connectionId + this._send = send + + this.pending.forEach((msg) => { + console.log('sending pending', JSON.stringify(msg)) + this.postMessage(msg) + }) + this.pending = [] + + console.log('***NativePort._init') + } + + _send(message: any) { + this.pending.push(message) + } + + _receive(message: any) { + console.log('NativePort received', JSON.stringify(message)) + ;(this.onMessage as any)._emit(message) + } + + postMessage(message: any) { + console.log('***NativePort.postMessage', JSON.stringify(message)) + this._send(message) + } + disconnect() { + electron.disconnectNative(extensionId, this.connectionId) + ;(this.onDisconnect as any)._emit() + } + onMessage: chrome.runtime.PortMessageEvent = new Event() as any + onDisconnect: chrome.runtime.PortDisconnectEvent = new Event() as any + } + type DeepPartial = { [P in keyof T]?: DeepPartial } @@ -410,6 +496,15 @@ export const injectExtensionAPIs = () => { factory: (base) => { return { ...base, + connectNative: (application: string) => { + const port = new NativePort() + const receive = port._receive.bind(port) + const callback: ConnectNativeCallback = (connectionId, send) => { + port._init(connectionId, send) + } + electron.connectNative(extensionId, application, receive, callback) + return port + }, openOptionsPage: invokeExtension('runtime.openOptionsPage'), } }, diff --git a/packages/electron-chrome-web-store/src/browser/loader.ts b/packages/electron-chrome-web-store/src/browser/loader.ts index e1639d0..2f9ed1b 100644 --- a/packages/electron-chrome-web-store/src/browser/loader.ts +++ b/packages/electron-chrome-web-store/src/browser/loader.ts @@ -131,6 +131,7 @@ export async function loadAllExtensions( for (const ext of extensions) { try { + let extension: Electron.Extension | undefined if (ext.type === 'store') { const existingExt = session.getExtension(ext.id) if (existingExt) { @@ -138,10 +139,20 @@ export async function loadAllExtensions( continue } d('loading extension %s', `${ext.id}@${ext.manifest.version}`) - await session.loadExtension(ext.path) + extension = await session.loadExtension(ext.path) } else if (options.allowUnpacked) { d('loading unpacked extension %s', ext.path) - await session.loadExtension(ext.path) + extension = await session.loadExtension(ext.path) + } + + if ( + extension && + extension.manifest.manifest_version === 3 && + extension.manifest.background?.service_worker + ) { + // TODO(mv3): electron 35 types + const scope = `chrome-extension://${extension.id}` + await (session.serviceWorkers as any).startWorkerForScope(scope) } } catch (error) { console.error(`Failed to load extension from ${ext.path}`) diff --git a/packages/shell/browser/main.js b/packages/shell/browser/main.js index 277c5d4..60507cc 100644 --- a/packages/shell/browser/main.js +++ b/packages/shell/browser/main.js @@ -224,6 +224,15 @@ class Browser { .replace(/\sElectron\/\S+/, '') .replace(new RegExp(`\\s${app.getName()}/\\S+`), '') this.session.setUserAgent(userAgent) + + if (process.env.SHELL_DEBUG) { + this.session.serviceWorkers.once('running-status-changed', () => { + const tab = this.windows[0]?.getFocusedTab() + if (tab) { + tab.webContents.inspectServiceWorker() + } + }) + } } createWindow(options) {