Skip to content

Commit ba0537f

Browse files
committed
feat: auto-reload support for Chrome using CDP
This is a re-implementation of auto-reload support in Chrome, by opening chrome://extensions/ and using a private API. It has been confirmed to work with Chrome 69 until 139. The functional unit tests only work in Chrome 75 until 133: - Chrome 74 and earlier do not support MV3 background service_worker, and the test extension uses it. Rewriting the extension to the MV2 variant causes it to pass in Chrome 69. - Chrome 134 disables unpacked extensions upon reload when Developer Mode is disabled: https://issues.chromium.org/issues/362756477 The next commit will introduce a more reliable mechanism of reloading extensions, using the same mechanism as loading the extension, which works in Chrome 126+. Tested as follows for various relevant versions of Chromium: CHROME_PATH=/path/to/chrome75/chromium TEST_LOG_VERBOSE=1 TEST_WEBEXT_USE_REAL_CHROME=1 ./node_modules/.bin/mocha tests/functional/test.cli.run-target-chromium.js --grep='with auto-reload run real' Unit tests with fake Chrome binary that simulates the real binary: ./node_modules/.bin/mocha tests/functional/test.cli.run-target-chromium.js --grep=auto-reload Pre-existing unit test that tested web-ext internals: ./node_modules/.bin/mocha tests/unit/test-extension-runners/test.chromium.js
1 parent 085481f commit ba0537f

File tree

4 files changed

+345
-21
lines changed

4 files changed

+345
-21
lines changed

src/extension-runners/chromium.js

Lines changed: 272 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,119 @@ export const DEFAULT_CHROME_FLAGS = ChromeLauncher.defaultFlags().filter(
2828
(flag) => !EXCLUDED_CHROME_FLAGS.includes(flag),
2929
);
3030

31+
// This is a client for the Chrome Devtools protocol. The methods and results
32+
// are documented at https://chromedevtools.github.io/devtools-protocol/tot/
33+
class ChromeDevtoolsProtocolClient {
34+
#receivedData = '';
35+
#isProcessingMessage = false;
36+
#lastId = 0;
37+
#deferredResponses = new Map();
38+
#disconnected = false;
39+
#disconnectedPromise;
40+
#resolveDisconnectedPromise;
41+
42+
// Print all exchanged CDP messages to ease debugging.
43+
TEST_LOG_VERBOSE_CDP = process.env.TEST_LOG_VERBOSE_CDP;
44+
45+
constructor(chromiumInstance) {
46+
// remoteDebuggingPipes is from chrome-launcher, see
47+
// https://github.com/GoogleChrome/chrome-launcher/pull/347
48+
const { incoming, outgoing } = chromiumInstance.remoteDebuggingPipes;
49+
this.#disconnectedPromise = new Promise((resolve) => {
50+
this.#resolveDisconnectedPromise = resolve;
51+
});
52+
if (incoming.closed) {
53+
// Strange. Did Chrome fail to start, or exit on startup?
54+
log.warn('CDP already disconnected at initialization');
55+
this.#finalizeDisconnect();
56+
return;
57+
}
58+
incoming.on('data', (data) => {
59+
this.#receivedData += data;
60+
this.#processNextMessage();
61+
});
62+
incoming.on('error', (error) => {
63+
log.error(error);
64+
this.#finalizeDisconnect();
65+
});
66+
incoming.on('close', () => this.#finalizeDisconnect());
67+
this.outgoingPipe = outgoing;
68+
}
69+
70+
waitUntilDisconnected() {
71+
return this.#disconnectedPromise;
72+
}
73+
74+
async sendCommand(method, params, sessionId = undefined) {
75+
if (this.#disconnected) {
76+
throw new Error(`CDP disconnected, cannot send: command ${method}`);
77+
}
78+
const message = {
79+
id: ++this.#lastId,
80+
method,
81+
params,
82+
sessionId,
83+
};
84+
const rawMessage = `${JSON.stringify(message)}\x00`;
85+
if (this.TEST_LOG_VERBOSE_CDP) {
86+
process.stderr.write(`[CDP] [SEND] ${rawMessage}\n`);
87+
}
88+
return new Promise((resolve, reject) => {
89+
// CDP will always send a response.
90+
this.#deferredResponses.set(message.id, { method, resolve, reject });
91+
this.outgoingPipe.write(rawMessage);
92+
});
93+
}
94+
95+
#processNextMessage() {
96+
if (this.#isProcessingMessage) {
97+
return;
98+
}
99+
this.#isProcessingMessage = true;
100+
let end = this.#receivedData.indexOf('\x00');
101+
while (end !== -1) {
102+
const rawMessage = this.#receivedData.slice(0, end);
103+
this.#receivedData = this.#receivedData.slice(end + 1); // +1 skips \x00.
104+
try {
105+
if (this.TEST_LOG_VERBOSE_CDP) {
106+
process.stderr.write(`[CDP] [RECV] ${rawMessage}\n`);
107+
}
108+
const { id, error, result } = JSON.parse(rawMessage);
109+
const deferredResponse = this.#deferredResponses.get(id);
110+
if (deferredResponse) {
111+
this.#deferredResponses.delete(id);
112+
if (error) {
113+
const err = new Error(error.message || 'Unexpected CDP response');
114+
deferredResponse.reject(err);
115+
} else {
116+
deferredResponse.resolve(result);
117+
}
118+
} else {
119+
// Dropping events and non-response messages since we don't need it.
120+
}
121+
} catch (e) {
122+
log.error(e);
123+
}
124+
end = this.#receivedData.indexOf('\x00');
125+
}
126+
this.#isProcessingMessage = false;
127+
if (this.#disconnected) {
128+
for (const { method, reject } of this.#deferredResponses.values()) {
129+
reject(new Error(`CDP connection closed before response to ${method}`));
130+
}
131+
this.#deferredResponses.clear();
132+
this.#resolveDisconnectedPromise();
133+
}
134+
}
135+
136+
#finalizeDisconnect() {
137+
if (!this.#disconnected) {
138+
this.#disconnected = true;
139+
this.#processNextMessage();
140+
}
141+
}
142+
}
143+
31144
/**
32145
* Implements an IExtensionRunner which manages a Chromium instance.
33146
*/
@@ -36,13 +149,17 @@ export class ChromiumExtensionRunner {
36149
params;
37150
chromiumInstance;
38151
chromiumLaunch;
152+
// --load-extension is deprecated, but only supported in Chrome 126+, see:
153+
// https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117
154+
forceUseDeprecatedLoadExtension;
39155
exiting;
40156
_promiseSetupDone;
41157

42158
constructor(params) {
43159
const { chromiumLaunch = defaultChromiumLaunch } = params;
44160
this.params = params;
45161
this.chromiumLaunch = chromiumLaunch;
162+
this.forceUseDeprecatedLoadExtension = true;
46163
this.cleanupCallbacks = new Set();
47164
}
48165

@@ -108,9 +225,7 @@ export class ChromiumExtensionRunner {
108225
*/
109226
async setupInstance() {
110227
// Start chrome pointing it to a given profile dir
111-
const extensions = this.params.extensions
112-
.map(({ sourceDir }) => sourceDir)
113-
.join(',');
228+
const extensions = this.params.extensions.map(({ sourceDir }) => sourceDir);
114229

115230
const { chromiumBinary } = this.params;
116231

@@ -121,8 +236,11 @@ export class ChromiumExtensionRunner {
121236
}
122237

123238
const chromeFlags = [...DEFAULT_CHROME_FLAGS];
239+
chromeFlags.push('--remote-debugging-pipe');
124240

125-
chromeFlags.push(`--load-extension=${extensions}`);
241+
if (this.forceUseDeprecatedLoadExtension) {
242+
chromeFlags.push(`--load-extension=${extensions.join(',')}`);
243+
}
126244

127245
if (this.params.args) {
128246
chromeFlags.push(...this.params.args);
@@ -189,6 +307,7 @@ export class ChromiumExtensionRunner {
189307
// Ignore default flags to keep the extension enabled.
190308
ignoreDefaultFlags: true,
191309
});
310+
this.cdp = new ChromeDevtoolsProtocolClient(this.chromiumInstance);
192311

193312
this.chromiumInstance.process.once('close', () => {
194313
this.chromiumInstance = null;
@@ -198,6 +317,17 @@ export class ChromiumExtensionRunner {
198317
this.exit();
199318
}
200319
});
320+
321+
// Connect with the CDP to verify that we are indeed connected to Chrome.
322+
// This works with Chrome 69 and later, see
323+
// https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117
324+
try {
325+
log.debug('Verifying chrome devtools protocol connection...');
326+
const { userAgent } = await this.cdp.sendCommand('Browser.getVersion');
327+
log.info(`Launched Chromium: ${userAgent}`);
328+
} catch (e) {
329+
log.error(e);
330+
}
201331
}
202332

203333
/**
@@ -207,7 +337,9 @@ export class ChromiumExtensionRunner {
207337
async reloadAllExtensions() {
208338
const runnerName = this.getName();
209339

210-
// TODO: Restore reload functionality using the remote debugging protocol.
340+
if (this.forceUseDeprecatedLoadExtension) {
341+
this.reloadAllExtensionsFallbackForChrome125andEarlier();
342+
}
211343

212344
process.stdout.write(
213345
`\rLast extension reload: ${new Date().toTimeString()}`,
@@ -217,6 +349,136 @@ export class ChromiumExtensionRunner {
217349
return [{ runnerName }];
218350
}
219351

352+
async reloadAllExtensionsFallbackForChrome125andEarlier() {
353+
// Ideally, we'd like to use the "Extensions.loadUnpacked" CDP command to
354+
// reload an extension, but that is unsupported in Chrome 125 and earlier.
355+
//
356+
// As a fallback, connect to chrome://extensions/ and reload from there.
357+
// Since we are targeting old Chrome versions, we can safely use the
358+
// chrome.developerPrivate APIs, because these are never going to change
359+
// for the old browser versions. Do NOT use this for newer versions!
360+
//
361+
// Target.* CDP methods documented at: https://chromedevtools.github.io/devtools-protocol/tot/Target/
362+
// developerPrivate documented at:
363+
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl
364+
//
365+
// Specific revision that exposed developerPrivate to chrome://extensions/:
366+
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
367+
// https://chromium.googlesource.com/chromium/src.git/+/69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
368+
//
369+
// Specific revision that introduced developerPrivate.getExtensionsInfo:
370+
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e
371+
//
372+
// The above changes are from 2015; The --remote-debugging-pipe feature
373+
// that we rely on for CDP was added in 2018; this is the version of the
374+
// developerPrivate API at that time:
375+
// https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=c9ae59c8f37d487f1f01c222deb6b7d1f51c99c2
376+
377+
// Find an existing chrome://extensions/ tab, if it exists.
378+
let { targetInfos: targets } = await this.cdp.sendCommand(
379+
'Target.getTargets',
380+
{ filter: [{ type: 'tab' }] },
381+
);
382+
targets = targets.filter((t) => t.url.startsWith('chrome://extensions/'));
383+
let targetId;
384+
const hasExistingTarget = targets.length > 0;
385+
if (hasExistingTarget) {
386+
targetId = targets[0].targetId;
387+
} else {
388+
const result = await this.cdp.sendCommand('Target.createTarget', {
389+
url: 'chrome://extensions/',
390+
newWindow: true,
391+
background: true,
392+
windowState: 'minimized',
393+
});
394+
targetId = result.targetId;
395+
}
396+
const codeToEvaluateInChrome = async () => {
397+
// This function is serialized and executed in Chrome. Designed for
398+
// compatibility with Chrome 69 - 125. Do not use JS syntax of functions
399+
// that are not supported in these versions!
400+
401+
// eslint-disable-next-line no-undef
402+
const developerPrivate = chrome.developerPrivate;
403+
if (!developerPrivate || !developerPrivate.getExtensionsInfo) {
404+
// When chrome://extensions/ is still loading, its document URL may be
405+
// about:blank and the chrome.developerPrivate API is not exposed.
406+
return 'NOT_READY_PLEASE_RETRY';
407+
}
408+
const extensionIds = [];
409+
await new Promise((resolve) => {
410+
developerPrivate.getExtensionsInfo((extensions) => {
411+
for (const extension of extensions || []) {
412+
if (extension.location === 'UNPACKED') {
413+
// We only care about those loaded via --load-extension.
414+
extensionIds.push(extension.id);
415+
}
416+
}
417+
resolve();
418+
});
419+
});
420+
const reloadPromises = extensionIds.map((extensionId) => {
421+
return new Promise((resolve, reject) => {
422+
developerPrivate.reload(
423+
extensionId,
424+
// Suppress alert dialog when load fails.
425+
{ failQuietly: true, populateErrorForUnpacked: true },
426+
(loadError) => {
427+
if (loadError) {
428+
reject(new Error(loadError.error));
429+
} else {
430+
resolve();
431+
}
432+
},
433+
);
434+
});
435+
});
436+
await Promise.all(reloadPromises);
437+
return reloadPromises.length;
438+
};
439+
try {
440+
const targetResult = await this.cdp.sendCommand('Target.attachToTarget', {
441+
targetId,
442+
flatten: true,
443+
});
444+
if (!targetResult.sessionId) {
445+
throw new Error('Unexpectedly, no sessionId from attachToTarget');
446+
}
447+
// In practice, we're going to run the logic only once. But if we are
448+
// unlucky, chrome://extensions is still loading, so we will then retry.
449+
for (let i = 0; i < 3; ++i) {
450+
const evalResult = await this.cdp.sendCommand(
451+
'Runtime.evaluate',
452+
{
453+
expression: `(${codeToEvaluateInChrome})();`,
454+
awaitPromise: true,
455+
},
456+
targetResult.sessionId,
457+
);
458+
const evalResultReturnValue = evalResult.result?.value;
459+
if (evalResultReturnValue === 'NOT_READY_PLEASE_RETRY') {
460+
await new Promise((r) => setTimeout(r, 200 * i));
461+
continue;
462+
}
463+
if (evalResult.exceptionDetails) {
464+
log.error(`Failed to reload: ${evalResult.exceptionDetails.text}`);
465+
}
466+
if (evalResultReturnValue !== this.params.extensions.length) {
467+
log.warn(`Failed to reload extensions: ${evalResultReturnValue}`);
468+
}
469+
break;
470+
}
471+
} finally {
472+
if (!hasExistingTarget && targetId) {
473+
try {
474+
await this.cdp.sendCommand('Target.closeTarget', { targetId });
475+
} catch (e) {
476+
log.error(e);
477+
}
478+
}
479+
}
480+
}
481+
220482
/**
221483
* Reloads a single extension, collect any reload error and resolves to
222484
* an array composed by a single ExtensionRunnerReloadResult object.
@@ -259,6 +521,11 @@ export class ChromiumExtensionRunner {
259521
this.chromiumInstance = null;
260522
}
261523

524+
if (this.cdp) {
525+
await this.cdp.waitUntilDisconnected();
526+
this.cdp = null;
527+
}
528+
262529
// Call all the registered cleanup callbacks.
263530
for (const fn of this.cleanupCallbacks) {
264531
try {

0 commit comments

Comments
 (0)