Skip to content

Commit

Permalink
feat: runtime.connectNative
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelmaddock committed Feb 5, 2025
1 parent c2d58c8 commit 2000d57
Show file tree
Hide file tree
Showing 13 changed files with 439 additions and 9 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
crxtesthost
crxtesthost.blob
sea-config.json
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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 })
})
})
})
7 changes: 7 additions & 0 deletions packages/electron-chrome-extensions/spec/crx-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NativeConfig | undefined> {
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<typeof spawn>
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)
}
}
24 changes: 24 additions & 0 deletions packages/electron-chrome-extensions/src/browser/api/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, NativeMessagingHost | undefined> = {}

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
Expand Down
Loading

0 comments on commit 2000d57

Please sign in to comment.