Skip to content

Commit 2000d57

Browse files
committed
feat: runtime.connectNative
1 parent c2d58c8 commit 2000d57

File tree

13 files changed

+439
-9
lines changed

13 files changed

+439
-9
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"start": "yarn build:context-menu && yarn build:extensions && yarn build:chrome-web-store && yarn --cwd ./packages/shell start",
1919
"start:debug": "cross-env SHELL_DEBUG=true DEBUG='electron*' yarn start",
2020
"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",
21+
"start:electron-dev:debug": "cross-env SHELL_DEBUG=true DEBUG='electron*' yarn start:electron-dev",
2222
"start:electron-dev:trace": "cross-env ELECTRON_OVERRIDE_DIST_PATH=$(e show out --path) ELECTRON_ENABLE_LOGGING=1 yarn --cwd ./packages/shell start:trace",
2323
"start:skip-build": "cross-env SHELL_DEBUG=true DEBUG='electron-chrome-extensions*' yarn --cwd ./packages/shell start",
2424
"test": "yarn test:extensions",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
crxtesthost
2+
crxtesthost.blob
3+
sea-config.json
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
await exec(
28+
`npx postject ${basePath}${exeName} NODE_SEA_BLOB ${basePath}${seaBlobName} --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 --macho-segment-name NODE_SEA`,
29+
{ cwd: outDir },
30+
)
31+
32+
if (process.platform === 'darwin') {
33+
await exec(`codesign --sign - ${exeName}`, { cwd: outDir })
34+
}
35+
}
36+
37+
async function installConfig(extensionIds) {
38+
console.info(`Installing config…`)
39+
40+
const name = 'com.crx.test'
41+
const config = {
42+
name,
43+
description: 'electron-chrome-extensions test',
44+
path: path.join(outDir, exeName),
45+
type: 'stdio',
46+
allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`),
47+
}
48+
49+
switch (process.platform) {
50+
case 'darwin': {
51+
const configPath = path.join(
52+
os.homedir(),
53+
'Library',
54+
'Application Support',
55+
'Electron',
56+
'NativeMessagingHosts',
57+
)
58+
await fs.mkdir(configPath, { recursive: true })
59+
const filePath = path.join(configPath, `${name}.json`)
60+
const data = Buffer.from(JSON.stringify(config, null, 2))
61+
await fs.writeFile(filePath, data)
62+
break
63+
}
64+
default:
65+
return
66+
}
67+
}
68+
69+
async function main() {
70+
const extensionIdsArg = process.argv[2]
71+
if (!extensionIdsArg) {
72+
console.error('Must pass in csv of allowed extension IDs')
73+
process.exit(1)
74+
}
75+
76+
const extensionIds = extensionIdsArg.split(',')
77+
console.log(extensionIds)
78+
await createSEA()
79+
await installConfig(extensionIds)
80+
}
81+
82+
main()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { promisify } from 'node:util'
2+
import * as cp from 'node:child_process'
3+
import * as path from 'node:path'
4+
const exec = promisify(cp.exec)
5+
6+
import { useExtensionBrowser, useServer } from './hooks'
7+
import { getExtensionId } from './crx-helpers'
8+
9+
// TODO:
10+
describe.skip('nativeMessaging', () => {
11+
const server = useServer()
12+
const browser = useExtensionBrowser({
13+
url: server.getUrl,
14+
extensionName: 'rpc',
15+
})
16+
const hostApplication = 'com.crx.test'
17+
18+
before(async () => {
19+
const extensionId = await getExtensionId('rpc')
20+
const scriptPath = path.join(__dirname, '..', 'script', 'native-messaging-host', 'build.js')
21+
await exec(`${scriptPath} ${extensionId}`)
22+
})
23+
24+
describe('connectNative()', () => {
25+
it('returns tab details', async () => {
26+
const result = await browser.crx.exec('runtime.connectNative', hostApplication)
27+
console.log({ result })
28+
})
29+
})
30+
})

packages/electron-chrome-extensions/spec/crx-helpers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,10 @@ export async function waitForBackgroundScriptEvaluated(
101101
backgroundHost.on('console-message', onConsoleMessage)
102102
})
103103
}
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+
}

packages/electron-chrome-extensions/src/browser/api/browser-action.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export class BrowserActionAPI {
163163
handle(
164164
'browserAction.addObserver',
165165
(event) => {
166-
const { sender: observer } = event
166+
const observer = event.sender as any
167167
this.observers.add(observer)
168168
// TODO(mv3): need a destroyed event on workers
169169
observer.once?.('destroyed', () => {
@@ -371,7 +371,7 @@ export class BrowserActionAPI {
371371
const { eventType, extensionId, tabId } = details
372372

373373
debug(
374-
`activate [eventType: ${eventType}, extensionId: '${extensionId}', tabId: ${tabId}, senderId: ${sender.id}]`,
374+
`activate [eventType: ${eventType}, extensionId: '${extensionId}', tabId: ${tabId}, senderId: ${sender!.id}]`,
375375
)
376376

377377
switch (eventType) {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { spawn } from 'node:child_process'
2+
import { promises as fs } from 'node:fs'
3+
import * as path from 'node:path'
4+
import { app } from 'electron'
5+
import { ExtensionSender } from '../../router'
6+
7+
const d = require('debug')('electron-chrome-extensions:nativeMessaging')
8+
9+
interface NativeConfig {
10+
name: string
11+
description: string
12+
path: string
13+
type: 'stdio'
14+
allowed_origins: string[]
15+
}
16+
17+
async function readNativeMessagingHostConfig(
18+
application: string,
19+
): Promise<NativeConfig | undefined> {
20+
let searchPaths = [path.join(app.getPath('userData'), 'NativeMessagingHosts')]
21+
switch (process.platform) {
22+
case 'darwin':
23+
searchPaths.push('/Library/Google/Chrome/NativeMessagingHosts')
24+
break
25+
default:
26+
throw new Error('Unsupported platform')
27+
}
28+
29+
for (const basePath of searchPaths) {
30+
const filePath = path.join(basePath, `${application}.json`)
31+
try {
32+
const data = await fs.readFile(filePath)
33+
return JSON.parse(data.toString())
34+
} catch {
35+
continue
36+
}
37+
}
38+
}
39+
export class NativeMessagingHost {
40+
private process?: ReturnType<typeof spawn>
41+
private sender: ExtensionSender
42+
private connectionId: string
43+
private connected: boolean = false
44+
private pending?: any[]
45+
46+
constructor(
47+
extensionId: string,
48+
sender: ExtensionSender,
49+
connectionId: string,
50+
application: string,
51+
) {
52+
this.sender = sender
53+
this.sender.ipc.on(`crx-native-msg-${connectionId}`, this.receiveExtensionMessage)
54+
this.connectionId = connectionId
55+
this.launch(application, extensionId)
56+
}
57+
58+
destroy() {
59+
this.connected = false
60+
if (this.process) {
61+
this.process.disconnect()
62+
this.process = undefined
63+
}
64+
this.sender.ipc.off(`crx-native-msg-${this.connectionId}`, this.receiveExtensionMessage)
65+
// TODO: send disconnect
66+
}
67+
68+
private async launch(application: string, extensionId: string) {
69+
const config = await readNativeMessagingHostConfig(application)
70+
if (!config) {
71+
d('launch: unable to find %s for %s', application, extensionId)
72+
this.destroy()
73+
return
74+
}
75+
76+
d('launch: spawning %s for %s', config.path, extensionId)
77+
// TODO: must be a binary executable
78+
this.process = spawn(config.path, [`chrome-extension://${extensionId}/`], {
79+
shell: false,
80+
})
81+
82+
this.process.stdout!.on('data', this.receive)
83+
this.process.stderr!.on('data', (data) => {
84+
d('stderr: %s', data.toString())
85+
})
86+
this.process.on('error', (err) => d('error: %s', err))
87+
this.process.on('exit', (code) => d('exited %d', code))
88+
89+
this.connected = true
90+
91+
if (this.pending && this.pending.length > 0) {
92+
d('sending %d pending messages', this.pending.length)
93+
this.pending.forEach((msg) => this.send(msg))
94+
this.pending = []
95+
}
96+
}
97+
98+
private receiveExtensionMessage = (_event: Electron.IpcMainEvent, message: any) => {
99+
this.send(message)
100+
}
101+
102+
private send(json: any) {
103+
d('send', json)
104+
105+
if (!this.connected) {
106+
const pending = this.pending || (this.pending = [])
107+
pending.push(json)
108+
d('send: pending')
109+
return
110+
}
111+
112+
const message = JSON.stringify(json)
113+
const buffer = Buffer.alloc(4 + message.length)
114+
buffer.writeUInt32LE(message.length, 0)
115+
buffer.write(message, 4)
116+
this.process!.stdin!.write(buffer)
117+
}
118+
119+
private receive = (data: Buffer) => {
120+
const length = data.readUInt32LE(0)
121+
const message = JSON.parse(data.subarray(4, 4 + length).toString())
122+
d('receive: %s', message)
123+
this.sender.send(`crx-native-msg-${this.connectionId}`, message)
124+
}
125+
}

packages/electron-chrome-extensions/src/browser/api/runtime.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,39 @@ import { EventEmitter } from 'node:events'
22
import { ExtensionContext } from '../context'
33
import { ExtensionEvent } from '../router'
44
import { getExtensionManifest } from './common'
5+
import { NativeMessagingHost } from './lib/native-messaging-host'
56

67
export class RuntimeAPI extends EventEmitter {
8+
private hostMap: Record<string, NativeMessagingHost | undefined> = {}
9+
710
constructor(private ctx: ExtensionContext) {
811
super()
912

1013
const handle = this.ctx.router.apiHandler()
14+
handle('runtime.connectNative', this.connectNative, { permission: 'nativeMessaging' })
15+
handle('runtime.disconnectNative', this.disconnectNative, { permission: 'nativeMessaging' })
1116
handle('runtime.openOptionsPage', this.openOptionsPage)
1217
}
1318

19+
private connectNative = async (
20+
event: ExtensionEvent,
21+
connectionId: string,
22+
application: string,
23+
) => {
24+
const host = new NativeMessagingHost(
25+
event.extension.id,
26+
event.sender!,
27+
connectionId,
28+
application,
29+
)
30+
this.hostMap[connectionId] = host
31+
}
32+
33+
private disconnectNative = (event: ExtensionEvent, connectionId: string) => {
34+
this.hostMap[connectionId]?.destroy()
35+
this.hostMap[connectionId] = undefined
36+
}
37+
1438
private openOptionsPage = async ({ extension }: ExtensionEvent) => {
1539
// TODO: options page shouldn't appear in Tabs API
1640
// https://developer.chrome.com/extensions/options#tabs-api

0 commit comments

Comments
 (0)