@narumitw/pi-chrome-devtools 0.54.0 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +6 -5
- package/dist/chunks/{browser-settings-menu-GQAXLWZ7.ts → browser-settings-menu-7M3VM7QN.ts} +3 -3
- package/dist/chunks/{chunk-WOFI2A72.ts → chunk-JKPJYUVG.ts} +6 -23
- package/dist/chunks/chunk-JKPJYUVG.ts.map +7 -0
- package/dist/chunks/{chunk-5T2WEIYN.ts → chunk-JLEJ6WFO.ts} +2 -2
- package/dist/chunks/{chunk-TVVYQ5KM.ts → chunk-WTMZID3G.ts} +328 -240
- package/dist/chunks/chunk-WTMZID3G.ts.map +7 -0
- package/dist/chunks/{menu-Z26D6XSI.ts → menu-HGG26E3X.ts} +4 -4
- package/dist/chunks/{tools-DSYQPFF3.ts → tools-O5N4KIAK.ts} +3 -3
- package/dist/index.ts +8 -8
- package/docs/browser-setup.md +7 -1
- package/package.json +6 -5
- package/src/browser-candidates.ts +184 -0
- package/src/browser-manager.ts +93 -276
- package/src/browser-startup.ts +122 -0
- package/src/lazy-tools.ts +10 -24
- package/src/runtime.ts +2 -2
- package/dist/chunks/chunk-TVVYQ5KM.ts.map +0 -7
- package/dist/chunks/chunk-WOFI2A72.ts.map +0 -7
- /package/dist/chunks/{browser-settings-menu-GQAXLWZ7.ts.map → browser-settings-menu-7M3VM7QN.ts.map} +0 -0
- /package/dist/chunks/{chunk-5T2WEIYN.ts.map → chunk-JLEJ6WFO.ts.map} +0 -0
- /package/dist/chunks/{menu-Z26D6XSI.ts.map → menu-HGG26E3X.ts.map} +0 -0
- /package/dist/chunks/{tools-DSYQPFF3.ts.map → tools-O5N4KIAK.ts.map} +0 -0
package/src/browser-manager.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type ChildProcess, execFile, spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { access, mkdtemp, rm } from "node:fs/promises";
|
|
4
3
|
import { createServer } from "node:net";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
|
-
import {
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { browserCandidateDefinitions, resolveBrowserCandidates } from "./browser-candidates.js";
|
|
7
|
+
import { browserWebSocketIdentity, waitForBrowserStartup } from "./browser-startup.js";
|
|
7
8
|
import {
|
|
8
9
|
BROWSER_SHUTDOWN_WAIT_MS,
|
|
9
10
|
type BrowserCandidate,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
DEFAULT_ENDPOINT_RETRY_MS,
|
|
13
14
|
DEFAULT_ENDPOINT_WAIT_MS,
|
|
14
15
|
DEFAULT_HTTP_TIMEOUT_MS,
|
|
15
|
-
DEVTOOLS_ACTIVE_PORT_FILE,
|
|
16
16
|
type DevToolsPage,
|
|
17
17
|
invalidateWebMcpOperations,
|
|
18
18
|
MANAGED_BROWSER_PROFILE_PREFIX,
|
|
@@ -21,17 +21,17 @@ import {
|
|
|
21
21
|
} from "./runtime.js";
|
|
22
22
|
import type { EffectiveBrowserSettings } from "./settings.js";
|
|
23
23
|
|
|
24
|
-
// Endpoint attachment, managed launch,
|
|
24
|
+
// Endpoint attachment, managed launch, selected-port publication, and shutdown intentionally remain
|
|
25
25
|
// together because they form one ownership state machine behind the testable operations boundary.
|
|
26
26
|
interface BrowserSpawnOptions {
|
|
27
27
|
shell: false;
|
|
28
|
-
stdio: "ignore";
|
|
28
|
+
stdio: ["ignore", "ignore", "pipe"];
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface BrowserManagerOperations {
|
|
32
32
|
access(path: string, mode: number): Promise<void>;
|
|
33
33
|
mkdtemp(prefix: string): Promise<string>;
|
|
34
|
-
|
|
34
|
+
allocatePort(host: string): Promise<number>;
|
|
35
35
|
rm(path: string, options: { recursive: true; force: true }): Promise<void>;
|
|
36
36
|
fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
37
37
|
spawn(executable: string, args: string[], options: BrowserSpawnOptions): ChildProcess;
|
|
@@ -43,7 +43,7 @@ export interface BrowserManagerOperations {
|
|
|
43
43
|
const DEFAULT_BROWSER_MANAGER_OPERATIONS: BrowserManagerOperations = {
|
|
44
44
|
access,
|
|
45
45
|
mkdtemp,
|
|
46
|
-
|
|
46
|
+
allocatePort: allocateBrowserPort,
|
|
47
47
|
rm: (path, options) => rm(path, options),
|
|
48
48
|
fetch: (input, init) => fetch(input, init),
|
|
49
49
|
spawn,
|
|
@@ -59,6 +59,7 @@ interface ManagedBrowserScope {
|
|
|
59
59
|
controller: AbortController;
|
|
60
60
|
generation: number;
|
|
61
61
|
launchPromise?: Promise<void>;
|
|
62
|
+
latestLaunchPromise?: Promise<void>;
|
|
62
63
|
managedBrowser?: ManagedBrowser;
|
|
63
64
|
port: number;
|
|
64
65
|
lastLaunchAttempt?: BrowserLaunchAttempt;
|
|
@@ -143,9 +144,15 @@ function launchPromiseForOwner(owner?: object) {
|
|
|
143
144
|
return owner ? managedBrowserScope(owner).launchPromise : state.launchPromise;
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
function latestLaunchPromiseForOwner(owner?: object) {
|
|
148
|
+
return owner ? managedBrowserScope(owner).latestLaunchPromise : state.latestLaunchPromise;
|
|
149
|
+
}
|
|
150
|
+
|
|
146
151
|
function setLaunchPromise(owner: object | undefined, launchPromise: Promise<void> | undefined) {
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
const scope = owner ? managedBrowserScope(owner) : state;
|
|
153
|
+
scope.launchPromise = launchPromise;
|
|
154
|
+
// Retain one settled result so attach probes can observe a launch after cleanup.
|
|
155
|
+
if (launchPromise) scope.latestLaunchPromise = launchPromise;
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
function managedBrowserPort(owner?: object) {
|
|
@@ -186,14 +193,6 @@ function formatError(error: unknown) {
|
|
|
186
193
|
return error instanceof Error ? error.message : String(error);
|
|
187
194
|
}
|
|
188
195
|
|
|
189
|
-
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
190
|
-
return error instanceof Error && "code" in error;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function normalizePathForComparison(value: string) {
|
|
194
|
-
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
196
|
export async function ensureDevToolsEndpoint(
|
|
198
197
|
waitMs = DEFAULT_ENDPOINT_WAIT_MS,
|
|
199
198
|
callerSignal?: AbortSignal,
|
|
@@ -205,14 +204,19 @@ export async function ensureDevToolsEndpoint(
|
|
|
205
204
|
await ensureManagedBrowserLaunched(waitMs, callerSignal, owner);
|
|
206
205
|
return;
|
|
207
206
|
}
|
|
207
|
+
const pendingLaunch = launchPromiseForOwner(owner);
|
|
208
|
+
if (pendingLaunch) {
|
|
209
|
+
await waitForCaller(pendingLaunch, callerSignal);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
208
212
|
if (canAutoLaunchBrowser(owner)) {
|
|
213
|
+
const launchBeforeProbe = latestLaunchPromiseForOwner(owner);
|
|
209
214
|
try {
|
|
210
215
|
await withEndpointRetry(
|
|
211
216
|
() => fetchDevToolsJson<unknown>("/json/version", { signal: callerSignal }, owner),
|
|
212
217
|
waitMs,
|
|
213
218
|
callerSignal,
|
|
214
219
|
);
|
|
215
|
-
return;
|
|
216
220
|
} catch (error) {
|
|
217
221
|
if (shouldAutoLaunchAfterEndpointError(error, owner)) {
|
|
218
222
|
await ensureManagedBrowserLaunched(waitMs, callerSignal, owner);
|
|
@@ -220,6 +224,16 @@ export async function ensureDevToolsEndpoint(
|
|
|
220
224
|
}
|
|
221
225
|
throw error;
|
|
222
226
|
}
|
|
227
|
+
// A retry can reach a managed port whose launch has already completed cleanup.
|
|
228
|
+
// Observe its result even after the active launch promise has been cleared.
|
|
229
|
+
let observedLaunch = launchBeforeProbe;
|
|
230
|
+
while (true) {
|
|
231
|
+
const concurrentLaunch = latestLaunchPromiseForOwner(owner);
|
|
232
|
+
if (!concurrentLaunch || concurrentLaunch === observedLaunch) break;
|
|
233
|
+
observedLaunch = concurrentLaunch;
|
|
234
|
+
await waitForCaller(concurrentLaunch, callerSignal);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
223
237
|
}
|
|
224
238
|
|
|
225
239
|
try {
|
|
@@ -303,7 +317,7 @@ async function prepareManagedBrowserLaunch(waitMs: number, generation: number, s
|
|
|
303
317
|
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
304
318
|
if (!available) {
|
|
305
319
|
throw new DevToolsEndpointError(
|
|
306
|
-
`Cannot launch the extension-owned browser because explicit port ${managedBrowserPort(owner)} is already in use. Choose a free browser.endpoint port or reset the endpoint to use
|
|
320
|
+
`Cannot launch the extension-owned browser because explicit port ${managedBrowserPort(owner)} is already in use. Choose a free browser.endpoint port or reset the endpoint to use an automatically selected managed port.`,
|
|
307
321
|
);
|
|
308
322
|
}
|
|
309
323
|
}
|
|
@@ -312,12 +326,14 @@ async function prepareManagedBrowserLaunch(waitMs: number, generation: number, s
|
|
|
312
326
|
|
|
313
327
|
async function launchManagedBrowser(waitMs: number, generation: number, signal: AbortSignal, owner?: object) {
|
|
314
328
|
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
315
|
-
const candidateDefinitions = browserCandidateDefinitions(owner);
|
|
316
|
-
const candidates = await resolveBrowserCandidates(candidateDefinitions,
|
|
329
|
+
const candidateDefinitions = browserCandidateDefinitions(browserSettingsForOwner(owner));
|
|
330
|
+
const candidates = await resolveBrowserCandidates(candidateDefinitions, browserManagerOperations.access, () =>
|
|
331
|
+
throwIfBrowserLaunchCancelled(generation, signal, owner),
|
|
332
|
+
);
|
|
317
333
|
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
318
334
|
setLastLaunchAttempt(owner, {
|
|
319
335
|
candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
|
|
320
|
-
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "
|
|
336
|
+
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
|
|
321
337
|
});
|
|
322
338
|
|
|
323
339
|
if (candidates.length === 0) {
|
|
@@ -334,7 +350,7 @@ async function launchManagedBrowser(waitMs: number, generation: number, signal:
|
|
|
334
350
|
setLastLaunchAttempt(owner, {
|
|
335
351
|
...lastLaunchAttempt(owner),
|
|
336
352
|
candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
|
|
337
|
-
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "
|
|
353
|
+
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
|
|
338
354
|
selectedCandidate: formatBrowserCandidate(candidate),
|
|
339
355
|
userDataDir: managedBrowserForOwner(owner)?.userDataDir,
|
|
340
356
|
});
|
|
@@ -346,7 +362,7 @@ async function launchManagedBrowser(waitMs: number, generation: number, signal:
|
|
|
346
362
|
setLastLaunchAttempt(owner, {
|
|
347
363
|
...(lastLaunchAttempt(owner) ?? {
|
|
348
364
|
candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
|
|
349
|
-
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "
|
|
365
|
+
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
|
|
350
366
|
}),
|
|
351
367
|
lastError: formatError(cancellation),
|
|
352
368
|
});
|
|
@@ -356,7 +372,7 @@ async function launchManagedBrowser(waitMs: number, generation: number, signal:
|
|
|
356
372
|
setLastLaunchAttempt(owner, {
|
|
357
373
|
...(lastLaunchAttempt(owner) ?? {
|
|
358
374
|
candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
|
|
359
|
-
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "
|
|
375
|
+
mode: managedBrowserPortConfigured(owner) ? "explicit-port" : "automatic-port",
|
|
360
376
|
}),
|
|
361
377
|
lastError: formatError(error),
|
|
362
378
|
});
|
|
@@ -425,15 +441,22 @@ async function launchBrowserCandidate(
|
|
|
425
441
|
let managedBrowser: ManagedBrowser | undefined;
|
|
426
442
|
try {
|
|
427
443
|
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
428
|
-
const
|
|
429
|
-
|
|
444
|
+
const port = managedBrowserPortConfigured(owner)
|
|
445
|
+
? managedBrowserPort(owner)
|
|
446
|
+
: await browserManagerOperations.allocatePort(browserSettingsForOwner(owner).host);
|
|
447
|
+
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
448
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
449
|
+
throw new DevToolsEndpointError("Cannot launch a browser without a valid nonzero DevTools port.");
|
|
450
|
+
}
|
|
451
|
+
const args = buildManagedBrowserLaunchArguments(userDataDir, String(port), managedBrowserExtensionPaths(owner));
|
|
430
452
|
const child = browserManagerOperations.spawn(candidate.resolvedExecutable, args, {
|
|
431
453
|
shell: false,
|
|
432
|
-
stdio: "ignore",
|
|
454
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
433
455
|
});
|
|
434
456
|
const launchedBrowser: ManagedBrowser = {
|
|
435
457
|
process: child,
|
|
436
458
|
userDataDir,
|
|
459
|
+
port,
|
|
437
460
|
exited: false,
|
|
438
461
|
ready: false,
|
|
439
462
|
ownerGeneration: generation,
|
|
@@ -453,24 +476,12 @@ async function launchBrowserCandidate(
|
|
|
453
476
|
}
|
|
454
477
|
});
|
|
455
478
|
|
|
456
|
-
await
|
|
479
|
+
const browserIdentity = await waitForBrowserStartup(child, port, waitMs, signal);
|
|
457
480
|
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
} else {
|
|
461
|
-
launchedBrowser.port = await readManagedBrowserPort(
|
|
462
|
-
userDataDir,
|
|
463
|
-
launchedBrowser,
|
|
464
|
-
waitMs,
|
|
465
|
-
generation,
|
|
466
|
-
signal,
|
|
467
|
-
owner,
|
|
468
|
-
);
|
|
469
|
-
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
470
|
-
setManagedBrowserPort(owner, launchedBrowser.port);
|
|
471
|
-
}
|
|
472
|
-
await waitForDevToolsEndpoint(waitMs, launchedBrowser, generation, signal, owner);
|
|
481
|
+
setManagedBrowserPort(owner, port);
|
|
482
|
+
await waitForDevToolsEndpoint(waitMs, launchedBrowser, browserIdentity, generation, signal, owner);
|
|
473
483
|
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
484
|
+
throwIfManagedBrowserExited(launchedBrowser);
|
|
474
485
|
launchedBrowser.ready = true;
|
|
475
486
|
} catch (error) {
|
|
476
487
|
if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false, owner });
|
|
@@ -503,61 +514,10 @@ export function buildManagedBrowserLaunchArguments(
|
|
|
503
514
|
];
|
|
504
515
|
}
|
|
505
516
|
|
|
506
|
-
function waitForBrowserSpawn(child: ChildProcess) {
|
|
507
|
-
return new Promise<void>((resolve, reject) => {
|
|
508
|
-
let settled = false;
|
|
509
|
-
const settle = (callback: () => void) => {
|
|
510
|
-
if (settled) return;
|
|
511
|
-
settled = true;
|
|
512
|
-
child.off("error", onError);
|
|
513
|
-
child.off("spawn", onSpawn);
|
|
514
|
-
callback();
|
|
515
|
-
};
|
|
516
|
-
const onError = (error: Error) => settle(() => reject(error));
|
|
517
|
-
const onSpawn = () => settle(resolve);
|
|
518
|
-
child.once("error", onError);
|
|
519
|
-
child.once("spawn", onSpawn);
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
async function readManagedBrowserPort(
|
|
524
|
-
userDataDir: string,
|
|
525
|
-
managedBrowser: ManagedBrowser,
|
|
526
|
-
waitMs: number,
|
|
527
|
-
generation: number,
|
|
528
|
-
signal: AbortSignal,
|
|
529
|
-
owner?: object,
|
|
530
|
-
) {
|
|
531
|
-
const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
|
|
532
|
-
const deadline = Date.now() + waitMs;
|
|
533
|
-
while (true) {
|
|
534
|
-
throwIfManagedBrowserExited(managedBrowser);
|
|
535
|
-
const text = await browserManagerOperations.readFile(activePortFile, "utf8").catch((error: unknown) => {
|
|
536
|
-
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
537
|
-
throw error;
|
|
538
|
-
});
|
|
539
|
-
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
540
|
-
const portText = text?.split(/\r?\n/, 1)[0]?.trim();
|
|
541
|
-
const port = Number(portText);
|
|
542
|
-
if (Number.isInteger(port) && port > 0) return port;
|
|
543
|
-
|
|
544
|
-
const remainingMs = deadline - Date.now();
|
|
545
|
-
if (remainingMs <= 0) {
|
|
546
|
-
throw new DevToolsEndpointError(
|
|
547
|
-
[
|
|
548
|
-
"Timed out waiting for auto-launched browser DevToolsActivePort.",
|
|
549
|
-
`Expected file: ${activePortFile}`,
|
|
550
|
-
launchHint(owner),
|
|
551
|
-
].join("\n"),
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs), signal);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
517
|
async function waitForDevToolsEndpoint(
|
|
559
518
|
waitMs: number,
|
|
560
519
|
managedBrowser: ManagedBrowser,
|
|
520
|
+
browserIdentity: string,
|
|
561
521
|
generation: number,
|
|
562
522
|
signal: AbortSignal,
|
|
563
523
|
owner?: object,
|
|
@@ -566,8 +526,16 @@ async function waitForDevToolsEndpoint(
|
|
|
566
526
|
while (true) {
|
|
567
527
|
throwIfManagedBrowserExited(managedBrowser);
|
|
568
528
|
try {
|
|
569
|
-
await fetchDevToolsJson<
|
|
529
|
+
const version = await fetchDevToolsJson<{ webSocketDebuggerUrl?: unknown } | null>(
|
|
530
|
+
"/json/version",
|
|
531
|
+
{ signal },
|
|
532
|
+
owner,
|
|
533
|
+
);
|
|
570
534
|
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
535
|
+
throwIfManagedBrowserExited(managedBrowser);
|
|
536
|
+
if (browserWebSocketIdentity(version?.webSocketDebuggerUrl, managedBrowser.port ?? 0) !== browserIdentity) {
|
|
537
|
+
throw new DevToolsEndpointError("DevTools endpoint does not match the auto-launched browser.");
|
|
538
|
+
}
|
|
571
539
|
return;
|
|
572
540
|
} catch (error) {
|
|
573
541
|
if (!isRetryableEndpointError(error)) throw error;
|
|
@@ -749,7 +717,7 @@ function shouldAutoLaunchAfterEndpointError(error: unknown, owner?: object) {
|
|
|
749
717
|
if (isLaunchableEndpointError(error)) return true;
|
|
750
718
|
|
|
751
719
|
// After the attach-first attempt (including its retry window, when applicable) fails, treat any
|
|
752
|
-
// DevTools endpoint error on an unpinned port as a conflict we can avoid with
|
|
720
|
+
// DevTools endpoint error on an unpinned port as a conflict we can avoid with an automatically selected port.
|
|
753
721
|
return !managedBrowserPortConfigured(owner) && error instanceof DevToolsEndpointError;
|
|
754
722
|
}
|
|
755
723
|
|
|
@@ -807,20 +775,22 @@ export function launchModeLabel(owner?: object) {
|
|
|
807
775
|
if (managedBrowser && !managedBrowser.exited) {
|
|
808
776
|
return managedBrowserPortConfigured(owner)
|
|
809
777
|
? "extension-owned browser on explicit port"
|
|
810
|
-
: "extension-owned browser on
|
|
778
|
+
: "extension-owned browser on automatically selected port";
|
|
811
779
|
}
|
|
812
780
|
return managedBrowserPortConfigured(owner)
|
|
813
781
|
? "force extension-owned launch on explicit port"
|
|
814
|
-
: "force extension-owned launch on
|
|
782
|
+
: "force extension-owned launch on automatically selected port";
|
|
815
783
|
}
|
|
816
784
|
if (!isLocalDevToolsHost(browser.host)) return "manual remote endpoint";
|
|
817
785
|
if (!browser.autoLaunchEnabled) return "manual; auto-launch disabled";
|
|
818
786
|
if (managedBrowser && !managedBrowser.exited) {
|
|
819
|
-
return managedBrowserPortConfigured(owner)
|
|
787
|
+
return managedBrowserPortConfigured(owner)
|
|
788
|
+
? "auto-launched on explicit port"
|
|
789
|
+
: "auto-launched on automatically selected port";
|
|
820
790
|
}
|
|
821
791
|
return managedBrowserPortConfigured(owner)
|
|
822
792
|
? "attach first; auto-launch explicit port"
|
|
823
|
-
: "attach first; auto-launch
|
|
793
|
+
: "attach first; auto-launch on an automatically selected port";
|
|
824
794
|
}
|
|
825
795
|
|
|
826
796
|
export function launchAttemptLines(owner?: object) {
|
|
@@ -851,7 +821,7 @@ export function launchHint(owner?: object) {
|
|
|
851
821
|
}
|
|
852
822
|
const managedMode = managedBrowserPortConfigured(owner)
|
|
853
823
|
? `port ${managedBrowserPort(owner)}`
|
|
854
|
-
: "
|
|
824
|
+
: "an automatically selected nonzero DevTools port";
|
|
855
825
|
return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand(owner)}`;
|
|
856
826
|
}
|
|
857
827
|
|
|
@@ -859,7 +829,7 @@ export function browserCandidateHint(owner?: object) {
|
|
|
859
829
|
if (extensionsConfigured(owner)) {
|
|
860
830
|
return "Extension browser requirement: configure Chrome for Testing or Chromium with browser.executablePath; branded Chrome is rejected because it may ignore unpacked-extension flags.";
|
|
861
831
|
}
|
|
862
|
-
return `Browser candidates: ${browserCandidateDefinitions(owner)
|
|
832
|
+
return `Browser candidates: ${browserCandidateDefinitions(browserSettingsForOwner(owner))
|
|
863
833
|
.map((candidate) => candidate.label)
|
|
864
834
|
.join(", ")}`;
|
|
865
835
|
}
|
|
@@ -891,173 +861,6 @@ export function isLocalDevToolsHost(host: string) {
|
|
|
891
861
|
return ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"].includes(normalizedHost);
|
|
892
862
|
}
|
|
893
863
|
|
|
894
|
-
function browserCandidateDefinitions(owner?: object): BrowserCandidateDefinition[] {
|
|
895
|
-
const explicitCandidate = explicitBrowserCandidateDefinition(owner);
|
|
896
|
-
if (explicitCandidate.length > 0) return explicitCandidate;
|
|
897
|
-
|
|
898
|
-
return uniqueBrowserCandidates(platformBrowserCandidateDefinitions());
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
function explicitBrowserCandidateDefinition(owner?: object): BrowserCandidateDefinition[] {
|
|
902
|
-
const browser = browserSettingsForOwner(owner);
|
|
903
|
-
if (!browser.executablePath) return [];
|
|
904
|
-
return [
|
|
905
|
-
{
|
|
906
|
-
label: browser.executablePathSource === "environment" ? "PI_CHROME_DEVTOOLS_BROWSER" : "browser.executablePath",
|
|
907
|
-
executable: browser.executablePath,
|
|
908
|
-
source: "env",
|
|
909
|
-
},
|
|
910
|
-
];
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
|
|
914
|
-
if (process.platform === "darwin") {
|
|
915
|
-
return [
|
|
916
|
-
{
|
|
917
|
-
label: "Google Chrome",
|
|
918
|
-
executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
919
|
-
source: "wellKnownPath",
|
|
920
|
-
},
|
|
921
|
-
{
|
|
922
|
-
label: "Chromium",
|
|
923
|
-
executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
924
|
-
source: "wellKnownPath",
|
|
925
|
-
},
|
|
926
|
-
{
|
|
927
|
-
label: "Brave Browser",
|
|
928
|
-
executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
929
|
-
source: "wellKnownPath",
|
|
930
|
-
},
|
|
931
|
-
{
|
|
932
|
-
label: "Microsoft Edge",
|
|
933
|
-
executable: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
934
|
-
source: "wellKnownPath",
|
|
935
|
-
},
|
|
936
|
-
];
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
if (process.platform === "win32") {
|
|
940
|
-
return windowsBrowserCandidateDefinitions();
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
return [
|
|
944
|
-
{ label: "Google Chrome", executable: "google-chrome", source: "path" },
|
|
945
|
-
{ label: "Google Chrome Stable", executable: "google-chrome-stable", source: "path" },
|
|
946
|
-
{ label: "Chromium", executable: "chromium", source: "path" },
|
|
947
|
-
{ label: "Chromium Browser", executable: "chromium-browser", source: "path" },
|
|
948
|
-
{ label: "Brave Browser", executable: "brave-browser", source: "path" },
|
|
949
|
-
{ label: "Brave", executable: "brave", source: "path" },
|
|
950
|
-
{ label: "Microsoft Edge", executable: "microsoft-edge", source: "path" },
|
|
951
|
-
{ label: "Microsoft Edge Stable", executable: "microsoft-edge-stable", source: "path" },
|
|
952
|
-
];
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
function windowsBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
|
|
956
|
-
const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(
|
|
957
|
-
(value): value is string => typeof value === "string" && value.length > 0,
|
|
958
|
-
);
|
|
959
|
-
const wellKnownPaths = programFiles.flatMap((root) => [
|
|
960
|
-
join(root, "Google", "Chrome", "Application", "chrome.exe"),
|
|
961
|
-
join(root, "Chromium", "Application", "chrome.exe"),
|
|
962
|
-
join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
963
|
-
join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
964
|
-
]);
|
|
965
|
-
return [
|
|
966
|
-
...wellKnownPaths.map((executable) => ({
|
|
967
|
-
label: browserLabelFromExecutable(executable),
|
|
968
|
-
executable,
|
|
969
|
-
source: "wellKnownPath" as const,
|
|
970
|
-
})),
|
|
971
|
-
{ label: "Google Chrome", executable: "chrome.exe", source: "path" },
|
|
972
|
-
{ label: "Chromium", executable: "chromium.exe", source: "path" },
|
|
973
|
-
{ label: "Brave Browser", executable: "brave.exe", source: "path" },
|
|
974
|
-
{ label: "Microsoft Edge", executable: "msedge.exe", source: "path" },
|
|
975
|
-
];
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
function browserLabelFromExecutable(executable: string) {
|
|
979
|
-
const normalizedExecutable = normalizePathForComparison(executable);
|
|
980
|
-
if (normalizedExecutable.includes("brave")) return "Brave Browser";
|
|
981
|
-
if (normalizedExecutable.includes("edge") || normalizedExecutable.includes("msedge")) {
|
|
982
|
-
return "Microsoft Edge";
|
|
983
|
-
}
|
|
984
|
-
if (normalizedExecutable.includes("chromium")) return "Chromium";
|
|
985
|
-
return "Google Chrome";
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
|
|
989
|
-
const seen = new Set<string>();
|
|
990
|
-
return candidates.filter((candidate) => {
|
|
991
|
-
const key = normalizePathForComparison(candidate.executable);
|
|
992
|
-
if (seen.has(key)) return false;
|
|
993
|
-
seen.add(key);
|
|
994
|
-
return true;
|
|
995
|
-
});
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
async function resolveBrowserCandidates(
|
|
999
|
-
definitions: BrowserCandidateDefinition[],
|
|
1000
|
-
generation: number,
|
|
1001
|
-
signal: AbortSignal,
|
|
1002
|
-
owner?: object,
|
|
1003
|
-
) {
|
|
1004
|
-
const candidates: BrowserCandidate[] = [];
|
|
1005
|
-
for (const definition of definitions) {
|
|
1006
|
-
const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
|
|
1007
|
-
throwIfBrowserLaunchCancelled(generation, signal, owner);
|
|
1008
|
-
if (!resolvedExecutable) continue;
|
|
1009
|
-
candidates.push({ ...definition, resolvedExecutable });
|
|
1010
|
-
}
|
|
1011
|
-
return uniqueBrowserCandidatesByResolvedPath(candidates);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function uniqueBrowserCandidatesByResolvedPath(candidates: BrowserCandidate[]) {
|
|
1015
|
-
const seen = new Set<string>();
|
|
1016
|
-
return candidates.filter((candidate) => {
|
|
1017
|
-
const key = normalizePathForComparison(resolve(candidate.resolvedExecutable));
|
|
1018
|
-
if (seen.has(key)) return false;
|
|
1019
|
-
seen.add(key);
|
|
1020
|
-
return true;
|
|
1021
|
-
});
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
async function resolveBrowserExecutable(executable: string) {
|
|
1025
|
-
if (hasPathSeparator(executable) || isAbsolute(executable)) {
|
|
1026
|
-
const resolvedExecutable = isAbsolute(executable) ? executable : resolve(executable);
|
|
1027
|
-
return (await canAccessExecutable(resolvedExecutable)) ? resolvedExecutable : undefined;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
for (const directory of executableSearchPath()) {
|
|
1031
|
-
for (const executableName of executableSearchNames(executable)) {
|
|
1032
|
-
const candidate = join(directory, executableName);
|
|
1033
|
-
if (await canAccessExecutable(candidate)) return candidate;
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
return undefined;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
function hasPathSeparator(path: string) {
|
|
1040
|
-
return path.includes("/") || path.includes("\\");
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
function executableSearchPath() {
|
|
1044
|
-
return (process.env.PATH ?? "").split(delimiter).filter((part) => part.length > 0);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function executableSearchNames(executable: string) {
|
|
1048
|
-
if (process.platform !== "win32" || /\.[a-z0-9]+$/i.test(executable)) return [executable];
|
|
1049
|
-
return [executable, `${executable}.exe`, `${executable}.cmd`, `${executable}.bat`];
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
async function canAccessExecutable(path: string) {
|
|
1053
|
-
try {
|
|
1054
|
-
await browserManagerOperations.access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
1055
|
-
return true;
|
|
1056
|
-
} catch {
|
|
1057
|
-
return false;
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
864
|
function formatBrowserCandidate(candidate: BrowserCandidate) {
|
|
1062
865
|
return `${candidate.label} (${candidate.resolvedExecutable})`;
|
|
1063
866
|
}
|
|
@@ -1121,19 +924,33 @@ function inspectBrowserVersion(executable: string, signal: AbortSignal) {
|
|
|
1121
924
|
});
|
|
1122
925
|
}
|
|
1123
926
|
|
|
1124
|
-
function
|
|
1125
|
-
|
|
927
|
+
export async function allocateBrowserPort(host: string) {
|
|
928
|
+
const port = await probeBrowserPort(host, 0);
|
|
929
|
+
if (port === undefined) throw new Error("Cannot allocate an available DevTools port.");
|
|
930
|
+
return port;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function isPortAvailable(host: string, port: number) {
|
|
934
|
+
return (await probeBrowserPort(host, port)) !== undefined;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// The probe always closes before publication. Chrome's startup identity check handles the
|
|
938
|
+
// unavoidable bind-close-spawn race without attaching to a competing CDP browser.
|
|
939
|
+
function probeBrowserPort(host: string, port: number) {
|
|
940
|
+
return new Promise<number | undefined>((resolvePort, reject) => {
|
|
1126
941
|
const server = createServer();
|
|
1127
942
|
const normalizedHost = host.replace(/^\[(.*)]$/, "$1");
|
|
1128
943
|
server.unref();
|
|
1129
944
|
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
1130
|
-
if (error.code === "EADDRINUSE")
|
|
945
|
+
if (error.code === "EADDRINUSE") resolvePort(undefined);
|
|
1131
946
|
else reject(error);
|
|
1132
947
|
});
|
|
1133
948
|
server.listen(port, normalizedHost, () => {
|
|
949
|
+
const address = server.address();
|
|
1134
950
|
server.close((error) => {
|
|
1135
951
|
if (error) reject(error);
|
|
1136
|
-
else
|
|
952
|
+
else if (!address || typeof address === "string") reject(new Error("DevTools port probe has no address."));
|
|
953
|
+
else resolvePort(address.port);
|
|
1137
954
|
});
|
|
1138
955
|
});
|
|
1139
956
|
});
|