Skip to content

Commit f8fc222

Browse files
committed
chore(extension): find installed chrome
1 parent eeeab4f commit f8fc222

3 files changed

Lines changed: 103 additions & 8 deletions

File tree

src/extension/browserRegistry.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import path from 'path';
18+
import fs from 'fs';
19+
import debug from 'debug';
20+
21+
const debugLogger = debug('pw:mcp:relay:browser');
22+
23+
export function findChromeExecutable() {
24+
const lookAt: Record<'linux' | 'darwin' | 'win32', string> = {
25+
'linux': '/opt/google/chrome/chrome',
26+
'darwin': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
27+
'win32': `\\Google\\Chrome\\Application\\chrome.exe`,
28+
};
29+
const suffix = lookAt[process.platform as 'linux' | 'darwin' | 'win32'];
30+
if (!suffix)
31+
throw new Error(`Chrome distribution is not supported on ${process.platform}`);
32+
const prefixes = (process.platform === 'win32' ? [
33+
process.env.LOCALAPPDATA,
34+
process.env.PROGRAMFILES,
35+
process.env['PROGRAMFILES(X86)'],
36+
// In some cases there is no PROGRAMFILES/(86) env var set but HOMEDRIVE is set.
37+
process.env.HOMEDRIVE + '\\Program Files',
38+
process.env.HOMEDRIVE + '\\Program Files (x86)',
39+
].filter(Boolean) : ['']) as string[];
40+
41+
for (const prefix of prefixes) {
42+
const executablePath = path.join(prefix, suffix);
43+
debugLogger(`Checking ${executablePath}`);
44+
if (canAccessFile(executablePath))
45+
return executablePath;
46+
}
47+
throw new Error(`Chrome distribution not found. Make sure it is installed at a standard location.`);
48+
}
49+
50+
function canAccessFile(file: string) {
51+
if (!file)
52+
return false;
53+
54+
try {
55+
fs.accessSync(file);
56+
return true;
57+
} catch (e) {
58+
return false;
59+
}
60+
}

src/extension/cdpRelay.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ import { WebSocket, WebSocketServer } from 'ws';
2626
import type websocket from 'ws';
2727
import http from 'node:http';
2828
import debug from 'debug';
29-
import { promisify } from 'node:util';
30-
import { exec } from 'node:child_process';
3129
import { httpAddressToString, startHttpServer } from '../transport.js';
3230
import { BrowserContextFactory } from '../browserContextFactory.js';
3331
import { Browser, chromium, type BrowserContext } from 'playwright';
32+
import { spawnAsync } from './spawnAsync.js';
33+
import { findChromeExecutable } from './browserRegistry.js';
3434

3535
const debugLogger = debug('pw:mcp:relay');
3636

@@ -88,10 +88,13 @@ export class CDPRelayServer {
8888
}
8989

9090
async ensureExtensionConnectionForMCPContext(clientInfo: { name: string, version: string }) {
91+
debugLogger('Ensuring extension connection for MCP context');
9192
if (this._extensionConnection)
9293
return;
9394
await this._connectBrowser(clientInfo);
95+
debugLogger('Waiting for incoming extension connection');
9496
await this._extensionConnectionPromise;
97+
debugLogger('Extension connection established');
9598
}
9699

97100
private async _connectBrowser(clientInfo: { name: string, version: string }) {
@@ -101,12 +104,10 @@ export class CDPRelayServer {
101104
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
102105
url.searchParams.set('client', JSON.stringify(clientInfo));
103106
const href = url.toString();
104-
const command = `'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' '${href}'`;
105-
try {
106-
await promisify(exec)(command);
107-
} catch (err) {
107+
const chromeExecutablePath = findChromeExecutable();
108+
void spawnAsync(chromeExecutablePath, [href], { detached: true, shell: false }).catch(err => {
108109
debugLogger('Failed to run command:', err);
109-
}
110+
});
110111
}
111112

112113
stop(): void {
@@ -332,7 +333,7 @@ class ExtensionConnection {
332333

333334
async send(method: string, params?: any, sessionId?: string): Promise<any> {
334335
if (this._ws.readyState !== WebSocket.OPEN)
335-
throw new Error('WebSocket closed');
336+
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
336337
const id = ++this._lastId;
337338
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
338339
return new Promise((resolve, reject) => {

src/extension/spawnAsync.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { spawn } from 'child_process';
18+
19+
import type { SpawnOptions } from 'child_process';
20+
21+
export function spawnAsync(cmd: string, args: string[], options: SpawnOptions = {}): Promise<{stdout: string, stderr: string, code: number | null, error?: Error}> {
22+
const process = spawn(cmd, args, { windowsHide: true, ...options });
23+
24+
return new Promise(resolve => {
25+
let stdout = '';
26+
let stderr = '';
27+
if (process.stdout)
28+
process.stdout.on('data', data => stdout += data.toString());
29+
if (process.stderr)
30+
process.stderr.on('data', data => stderr += data.toString());
31+
process.on('close', code => resolve({ stdout, stderr, code }));
32+
process.on('error', error => resolve({ stdout, stderr, code: 0, error }));
33+
});
34+
}

0 commit comments

Comments
 (0)