@narumitw/pi-chrome-devtools 0.51.0 → 0.52.0
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/README.md +64 -41
- package/package.json +1 -1
- package/src/browser-manager.ts +3 -5
- package/src/browser-settings-menu.ts +311 -0
- package/src/chrome-devtools.ts +22 -2
- package/src/menu.ts +12 -14
- package/src/runtime.ts +28 -23
- package/src/settings.ts +204 -38
- package/src/tool-selector.ts +3 -1
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
|
|
|
27
27
|
- Retries briefly while Chrome is starting and reports actionable endpoint errors.
|
|
28
28
|
- Shows statusline activity only while Chrome DevTools tools are running.
|
|
29
29
|
- Keeps one loader tool active and exposes matching browser capabilities only when the agent needs them.
|
|
30
|
-
- Provides a state-first `/chrome-devtools` menu for tool availability, browser
|
|
30
|
+
- Provides a state-first `/chrome-devtools` menu for tool availability, editable browser settings,
|
|
31
|
+
browser status, setup, and help.
|
|
31
32
|
- Stages menu-based availability changes for exact review before one confirmed apply.
|
|
32
33
|
- Uses `@narumitw/pi-tui-kit` for width-safe TUI menus and equivalent RPC dialogs.
|
|
33
34
|
- Persists the Chrome DevTools lazy-load catalog across Pi restarts.
|
|
@@ -52,15 +53,33 @@ pi -e ./packages/pi-chrome-devtools
|
|
|
52
53
|
|
|
53
54
|
## 🚀 Browser startup
|
|
54
55
|
|
|
55
|
-
Without unpacked extensions, the extension first tries
|
|
56
|
-
`127.0.0.1:9222`. If that local endpoint is unavailable
|
|
57
|
-
Chromium-family browser with an isolated temp profile and retries
|
|
58
|
-
are reused and are never terminated by the extension.
|
|
56
|
+
Without unpacked extensions, the extension first tries `browser.endpoint`, defaulting to
|
|
57
|
+
`http://127.0.0.1:9222`. If that local endpoint is unavailable and `browser.autoLaunch` is `true`, it
|
|
58
|
+
lazily launches an extension-owned Chromium-family browser with an isolated temp profile and retries
|
|
59
|
+
the CDP request. Existing endpoints are reused and are never terminated by the extension.
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
Configure the canonical user file at
|
|
62
|
+
`${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"browser": {
|
|
67
|
+
"endpoint": "http://127.0.0.1:9222",
|
|
68
|
+
"autoLaunch": true,
|
|
69
|
+
"executablePath": "/absolute/path/to/chromium"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`browser.endpoint` must be an HTTP origin with an explicit port and no credentials, path, query, or
|
|
75
|
+
fragment. Omitting it keeps attach-first behavior on `127.0.0.1:9222` and lets a managed launch use
|
|
76
|
+
Chrome's dynamic DevTools port mode (`--remote-debugging-port=0`). Explicitly saving an endpoint pins
|
|
77
|
+
managed launches to that port. `browser.autoLaunch` defaults to `true`. `browser.executablePath` is
|
|
78
|
+
optional and must be an absolute path; when absent, normal browser discovery applies.
|
|
79
|
+
|
|
80
|
+
The configured endpoint must expose the standard CDP HTTP discovery routes such as `/json/version`
|
|
81
|
+
and `/json/list`. Chrome's newer built-in permission flow can listen on port `9222` while returning
|
|
82
|
+
`404` from those routes; setting the same HTTP origin does not by itself make that flow compatible.
|
|
64
83
|
|
|
65
84
|
### Unpacked extensions
|
|
66
85
|
|
|
@@ -68,8 +87,7 @@ fall back to the default attach-first behavior.
|
|
|
68
87
|
> An unpacked extension executes privileged browser code. Load only code you trust. Project settings
|
|
69
88
|
> are honored only when Pi reports the project as trusted.
|
|
70
89
|
|
|
71
|
-
|
|
72
|
-
`${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json`:
|
|
90
|
+
Add trusted unpacked-extension paths to the same canonical user file:
|
|
73
91
|
|
|
74
92
|
```json
|
|
75
93
|
{
|
|
@@ -101,9 +119,10 @@ A trusted project can replace the user extension list in
|
|
|
101
119
|
```
|
|
102
120
|
|
|
103
121
|
Project `extensionPaths` replace, rather than append to, the user array. A project file cannot
|
|
104
|
-
override `browser.
|
|
105
|
-
Effective precedence is defaults, user settings,
|
|
106
|
-
|
|
122
|
+
override `browser.endpoint`, `browser.autoLaunch`, or `browser.executablePath`; browser connection
|
|
123
|
+
settings remain machine-owned user configuration. Effective precedence is defaults, user settings,
|
|
124
|
+
trusted project extension paths, then deprecated environment overrides. No new environment variable
|
|
125
|
+
is required.
|
|
107
126
|
|
|
108
127
|
When `extensionPaths` is non-empty, the extension skips attach-first behavior and starts an isolated,
|
|
109
128
|
extension-owned managed browser with `--disable-extensions-except` and `--load-extension`. It fails
|
|
@@ -116,24 +135,17 @@ old managed browser is closed before the new configuration is applied. Missing f
|
|
|
116
135
|
existing no-extension behavior. Invalid JSON, invalid browser values, and missing manifests are left
|
|
117
136
|
unchanged and ignored with an actionable warning.
|
|
118
137
|
|
|
119
|
-
###
|
|
120
|
-
|
|
121
|
-
`PI_CHROME_DEVTOOLS_BROWSER` remains the explicit runtime executable override. Without unpacked
|
|
122
|
-
extensions, browser discovery still checks platform-specific Chrome, Chromium, Brave, and Microsoft
|
|
123
|
-
Edge candidates. Disable auto-launch to keep the manual flow:
|
|
138
|
+
### Deprecated environment overrides and manual endpoints
|
|
124
139
|
|
|
125
|
-
|
|
126
|
-
PI_CHROME_DEVTOOLS_AUTO_LAUNCH
|
|
127
|
-
|
|
140
|
+
The existing `PI_CHROME_DEVTOOLS_HOST`, `PI_CHROME_DEVTOOLS_PORT`,
|
|
141
|
+
`PI_CHROME_DEVTOOLS_AUTO_LAUNCH`, and `PI_CHROME_DEVTOOLS_BROWSER` variables remain temporary
|
|
142
|
+
compatibility overrides. They still take precedence over JSON, but every session that sees one emits
|
|
143
|
+
a deprecation warning. Move their values to `browser.endpoint`, `browser.autoLaunch`, and
|
|
144
|
+
`browser.executablePath`; the variables will be removed in a future version.
|
|
128
145
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
PI_CHROME_DEVTOOLS_BROWSER=/usr/bin/chromium pi -e ./packages/pi-chrome-devtools
|
|
133
|
-
PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./packages/pi-chrome-devtools
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Manual launch remains available when no unpacked extensions are configured:
|
|
146
|
+
Without unpacked extensions, browser discovery still checks platform-specific Chrome, Chromium,
|
|
147
|
+
Brave, and Microsoft Edge candidates. Manual launch remains available when no unpacked extensions are
|
|
148
|
+
configured:
|
|
137
149
|
|
|
138
150
|
```bash
|
|
139
151
|
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/pi-chrome-devtools
|
|
@@ -212,7 +224,9 @@ an action. The five actions stay on one level:
|
|
|
212
224
|
context-appropriate bulk change before applying it.
|
|
213
225
|
- **Browser status** — inspect runtime, endpoint, launch mode, and the last launch attempt without
|
|
214
226
|
probing the endpoint or starting Chrome.
|
|
215
|
-
- **
|
|
227
|
+
- **Browser settings** — immediately save the endpoint, auto-launch policy, or browser executable;
|
|
228
|
+
inspect unpacked-extension paths and effective sources. A deprecated environment override remains
|
|
229
|
+
effective until removed even when its underlying JSON value changes.
|
|
216
230
|
- **Help** — view command usage and return to the menu.
|
|
217
231
|
|
|
218
232
|
In the tool screen, **Select all** and **Select none** are unambiguous shortcuts; individual rows use
|
|
@@ -229,6 +243,7 @@ Direct subcommands are also available:
|
|
|
229
243
|
/chrome-devtools help
|
|
230
244
|
/chrome-devtools quickstart
|
|
231
245
|
/chrome-devtools status
|
|
246
|
+
/chrome-devtools settings
|
|
232
247
|
/chrome-devtools tools
|
|
233
248
|
/chrome-devtools toggle
|
|
234
249
|
/chrome-devtools enable
|
|
@@ -244,6 +259,7 @@ Compatibility aliases remain available: `toggle` and `select` mean `tools`, `on`
|
|
|
244
259
|
- `status` shows available and loaded capability counts, loader state, the persisted catalog,
|
|
245
260
|
settings file path, endpoint source, launch mode, last launch attempt, and active non-Chrome tool
|
|
246
261
|
count.
|
|
262
|
+
- `settings` opens the same immediate-save browser settings flow used by the menu.
|
|
247
263
|
- `tools` opens the same staged, width-safe availability and review flow used by the menu.
|
|
248
264
|
- `toggle` and `select` are compatibility aliases for `tools`.
|
|
249
265
|
- `enable` makes all five capability tools available to the loader and saves that catalog; `on` is a
|
|
@@ -251,11 +267,13 @@ Compatibility aliases remain available: `toggle` and `select` mean `tools`, `on`
|
|
|
251
267
|
- `disable` makes all five capability tools unavailable and saves the empty catalog; `off` is a
|
|
252
268
|
compatibility alias. The slash command and `chrome_devtools_load` remain available.
|
|
253
269
|
|
|
254
|
-
The menu, `tools`, `help`, `quickstart`, and `status` require TUI or RPC mode so their
|
|
255
|
-
observable. TUI uses keyboard navigation and injected Pi keybindings; RPC receives
|
|
256
|
-
standard dialogs. In print and JSON modes, interactive and informational routes reject
|
|
257
|
-
instead of silently opening unavailable UI. The immediate `enable`/`disable` routes remain
|
|
258
|
-
for deterministic non-interactive use.
|
|
270
|
+
The menu, `settings`, `tools`, `help`, `quickstart`, and `status` require TUI or RPC mode so their
|
|
271
|
+
result is observable. TUI uses keyboard navigation and injected Pi keybindings; RPC receives
|
|
272
|
+
equivalent standard dialogs. In print and JSON modes, interactive and informational routes reject
|
|
273
|
+
explicitly instead of silently opening unavailable UI. The immediate `enable`/`disable` routes remain
|
|
274
|
+
available for deterministic non-interactive use.
|
|
275
|
+
|
|
276
|
+
## ⚙️ Settings
|
|
259
277
|
|
|
260
278
|
The available capability names are saved to:
|
|
261
279
|
|
|
@@ -263,14 +281,19 @@ The available capability names are saved to:
|
|
|
263
281
|
${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-chrome-devtools.json
|
|
264
282
|
```
|
|
265
283
|
|
|
284
|
+
The same file owns `browser.endpoint`, `browser.autoLaunch`, `browser.executablePath`, and
|
|
285
|
+
`browser.extensionPaths`. Browser connection fields are machine-owned user settings; trusted project
|
|
286
|
+
files may replace only `browser.extensionPaths`. Confirmed menu changes apply before the next browser
|
|
287
|
+
connection and close only an extension-owned managed browser. Manual JSON edits and unpacked-extension
|
|
288
|
+
changes apply after `/reload` or session replacement.
|
|
289
|
+
|
|
266
290
|
When the file is missing or invalid, the extension preserves Pi's current Chrome DevTools
|
|
267
291
|
availability policy instead of replacing it. A valid saved catalog is restored on Pi startup and
|
|
268
292
|
`/reload`, while its capability definitions remain deferred. A missing file is created by the first
|
|
269
|
-
confirmed
|
|
270
|
-
|
|
271
|
-
JSON or invalid recognized fields make menu mutation unavailable and block direct
|
|
272
|
-
replacement; a failed save restores the prior
|
|
273
|
-
while preserving other extensions' current tools.
|
|
293
|
+
confirmed browser or tool setting. Within one Pi process, all browser and tool saves run in invocation
|
|
294
|
+
order, reread the latest valid document, publish by temporary-file rename, and preserve unknown
|
|
295
|
+
fields. Malformed JSON or invalid recognized fields make menu mutation unavailable and block direct
|
|
296
|
+
saves without replacement; a failed save restores the prior displayed and effective state.
|
|
274
297
|
|
|
275
298
|
Compatibility: older versions used `pi-chrome-devtools-settings.json`. A legacy-only file remains
|
|
276
299
|
readable with a warning and is never modified automatically; rename it to
|
package/package.json
CHANGED
package/src/browser-manager.ts
CHANGED
|
@@ -143,7 +143,7 @@ async function prepareManagedBrowserLaunch(
|
|
|
143
143
|
throwIfBrowserLaunchCancelled(generation, signal);
|
|
144
144
|
if (!available) {
|
|
145
145
|
throw new DevToolsEndpointError(
|
|
146
|
-
`Cannot launch the extension-owned browser because explicit port ${state.port} is already in use. Choose a free
|
|
146
|
+
`Cannot launch the extension-owned browser because explicit port ${state.port} is already in use. Choose a free browser.endpoint port or reset the endpoint to use a dynamic managed port.`,
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
}
|
|
@@ -609,9 +609,7 @@ export function formatHostForUrl(host: string) {
|
|
|
609
609
|
}
|
|
610
610
|
|
|
611
611
|
export function endpointSourceLabel() {
|
|
612
|
-
|
|
613
|
-
const portSource = state.portConfigured ? "PI_CHROME_DEVTOOLS_PORT" : "default/dynamic port";
|
|
614
|
-
return `${hostSource}; ${portSource}`;
|
|
612
|
+
return state.endpointSource;
|
|
615
613
|
}
|
|
616
614
|
|
|
617
615
|
export type BrowserLifecycleState = "starting" | "running" | "exited" | "failed" | "unobserved";
|
|
@@ -710,7 +708,7 @@ export function quoteCommandPart(value: string) {
|
|
|
710
708
|
}
|
|
711
709
|
|
|
712
710
|
export function endpointConfigHint() {
|
|
713
|
-
return "
|
|
711
|
+
return "Configure browser.endpoint, browser.executablePath, and browser.autoLaunch in pi-chrome-devtools.json or the /chrome-devtools Settings flow.";
|
|
714
712
|
}
|
|
715
713
|
|
|
716
714
|
export function isLocalDevToolsHost(host: string) {
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
4
|
+
import { shutdownManagedBrowser } from "./browser-manager.js";
|
|
5
|
+
import { applyRuntimeBrowserSettings, state } from "./runtime.js";
|
|
6
|
+
import {
|
|
7
|
+
type BrowserSettingsPatch,
|
|
8
|
+
type BrowserSettingsSource,
|
|
9
|
+
loadSettings,
|
|
10
|
+
parseBrowserEndpoint,
|
|
11
|
+
type SettingsLoadResult,
|
|
12
|
+
saveBrowserSettings,
|
|
13
|
+
} from "./settings.js";
|
|
14
|
+
import { sanitizeChromeDevtoolsDisplay } from "./tool-selector.js";
|
|
15
|
+
|
|
16
|
+
type BrowserSettingsScreen = "settings" | "endpoint" | "executable" | "extensions";
|
|
17
|
+
type BrowserSettingsAction =
|
|
18
|
+
| "open-endpoint"
|
|
19
|
+
| "save-endpoint"
|
|
20
|
+
| "set-auto-launch"
|
|
21
|
+
| "open-executable"
|
|
22
|
+
| "save-executable"
|
|
23
|
+
| "show-extensions";
|
|
24
|
+
|
|
25
|
+
interface BrowserSettingsMenuState {
|
|
26
|
+
load: SettingsLoadResult;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type CommandContext = ExtensionCommandContext;
|
|
30
|
+
|
|
31
|
+
export async function showChromeDevtoolsBrowserSettings(
|
|
32
|
+
ctx: CommandContext,
|
|
33
|
+
generation: number,
|
|
34
|
+
): Promise<{ closeParent: boolean }> {
|
|
35
|
+
const ownerSignal = state.sessionController.signal;
|
|
36
|
+
const isCurrent = () => generation === state.sessionGeneration && !ownerSignal.aborted;
|
|
37
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
38
|
+
const menu = defineMenu<BrowserSettingsMenuState, BrowserSettingsScreen, BrowserSettingsAction>({
|
|
39
|
+
start: "settings",
|
|
40
|
+
screens: {
|
|
41
|
+
settings: ({ state: current }) => {
|
|
42
|
+
if (current.load.userFile.kind === "invalid") return invalidSettingsScreen(current.load);
|
|
43
|
+
const browser = current.load.effectiveBrowser;
|
|
44
|
+
return {
|
|
45
|
+
kind: "settings",
|
|
46
|
+
title: "Chrome DevTools Browser settings",
|
|
47
|
+
lines: settingsContextLines(current.load),
|
|
48
|
+
items: [
|
|
49
|
+
{
|
|
50
|
+
id: "endpoint",
|
|
51
|
+
label: "DevTools endpoint",
|
|
52
|
+
description:
|
|
53
|
+
'HTTP origin with an explicit port; enter "default" to restore 127.0.0.1:9222.',
|
|
54
|
+
currentValue: sanitizeChromeDevtoolsDisplay(browser.endpoint),
|
|
55
|
+
action: "open-endpoint",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "auto-launch",
|
|
59
|
+
label: "Auto-launch",
|
|
60
|
+
description:
|
|
61
|
+
"Launch an isolated managed browser when a local endpoint is unavailable.",
|
|
62
|
+
currentValue: browser.autoLaunchEnabled ? "On" : "Off",
|
|
63
|
+
values: ["On", "Off"],
|
|
64
|
+
action: "set-auto-launch",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "executable",
|
|
68
|
+
label: "Browser executable",
|
|
69
|
+
description:
|
|
70
|
+
'Absolute executable path; enter "automatic" to restore browser discovery.',
|
|
71
|
+
currentValue: sanitizeChromeDevtoolsDisplay(browser.executablePath ?? "Automatic"),
|
|
72
|
+
action: "open-executable",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "extensions",
|
|
76
|
+
label: "Unpacked extensions",
|
|
77
|
+
description: "Inspect trusted extension paths configured in user or project JSON.",
|
|
78
|
+
currentValue: `${browser.extensionPaths.length} configured`,
|
|
79
|
+
action: "show-extensions",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
endpoint: ({ state: current }) => ({
|
|
85
|
+
kind: "input",
|
|
86
|
+
title: "DevTools endpoint",
|
|
87
|
+
lines: [
|
|
88
|
+
sanitizeChromeDevtoolsDisplay(
|
|
89
|
+
`Current effective endpoint: ${current.load.effectiveBrowser.endpoint}`,
|
|
90
|
+
),
|
|
91
|
+
'Enter an HTTP origin with an explicit port, or "default".',
|
|
92
|
+
],
|
|
93
|
+
placeholder: "http://127.0.0.1:9222",
|
|
94
|
+
action: "save-endpoint",
|
|
95
|
+
hint: "back",
|
|
96
|
+
}),
|
|
97
|
+
executable: ({ state: current }) => ({
|
|
98
|
+
kind: "input",
|
|
99
|
+
title: "Browser executable",
|
|
100
|
+
lines: [
|
|
101
|
+
sanitizeChromeDevtoolsDisplay(
|
|
102
|
+
`Current effective executable: ${current.load.effectiveBrowser.executablePath ?? "automatic discovery"}`,
|
|
103
|
+
),
|
|
104
|
+
'Enter an absolute path, or "automatic".',
|
|
105
|
+
],
|
|
106
|
+
placeholder: "absolute browser executable path",
|
|
107
|
+
action: "save-executable",
|
|
108
|
+
hint: "back",
|
|
109
|
+
}),
|
|
110
|
+
extensions: ({ state: current }) => ({
|
|
111
|
+
kind: "detail",
|
|
112
|
+
title: "Unpacked extensions",
|
|
113
|
+
lines: extensionDetailLines(current.load),
|
|
114
|
+
hint: "back",
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
actions: {
|
|
118
|
+
"open-endpoint": () => ({ kind: "to", screen: "endpoint" }),
|
|
119
|
+
"save-endpoint": async ({ value, signal }) => {
|
|
120
|
+
if (value === undefined) return { kind: "rejected" };
|
|
121
|
+
let endpoint: string | null;
|
|
122
|
+
try {
|
|
123
|
+
const trimmed = value.trim();
|
|
124
|
+
endpoint =
|
|
125
|
+
trimmed.toLowerCase() === "default" ? null : parseBrowserEndpoint(trimmed).endpoint;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
notifySaveFailure(ctx, error);
|
|
128
|
+
return { kind: "rejected" };
|
|
129
|
+
}
|
|
130
|
+
const saved = await saveAndApplyBrowserPatch(
|
|
131
|
+
ctx,
|
|
132
|
+
generation,
|
|
133
|
+
ownerSignal,
|
|
134
|
+
signal,
|
|
135
|
+
projectTrusted,
|
|
136
|
+
{ endpoint },
|
|
137
|
+
);
|
|
138
|
+
if (!saved) return { kind: "rejected" };
|
|
139
|
+
notifyEffectiveSave(
|
|
140
|
+
ctx,
|
|
141
|
+
"endpoint",
|
|
142
|
+
saved.effectiveBrowser.endpoint,
|
|
143
|
+
saved.effectiveBrowser.endpointSource,
|
|
144
|
+
);
|
|
145
|
+
return { kind: "back" };
|
|
146
|
+
},
|
|
147
|
+
"set-auto-launch": async ({ value, signal }) => {
|
|
148
|
+
if (value !== "On" && value !== "Off") return { kind: "rejected" };
|
|
149
|
+
const saved = await saveAndApplyBrowserPatch(
|
|
150
|
+
ctx,
|
|
151
|
+
generation,
|
|
152
|
+
ownerSignal,
|
|
153
|
+
signal,
|
|
154
|
+
projectTrusted,
|
|
155
|
+
{ autoLaunch: value === "On" },
|
|
156
|
+
);
|
|
157
|
+
if (!saved) return { kind: "rejected" };
|
|
158
|
+
notifyEffectiveSave(
|
|
159
|
+
ctx,
|
|
160
|
+
"auto-launch",
|
|
161
|
+
saved.effectiveBrowser.autoLaunchEnabled ? "On" : "Off",
|
|
162
|
+
saved.effectiveBrowser.autoLaunchSource,
|
|
163
|
+
);
|
|
164
|
+
return { kind: "stay" };
|
|
165
|
+
},
|
|
166
|
+
"open-executable": () => ({ kind: "to", screen: "executable" }),
|
|
167
|
+
"save-executable": async ({ value, signal }) => {
|
|
168
|
+
if (value === undefined) return { kind: "rejected" };
|
|
169
|
+
const trimmed = value.trim();
|
|
170
|
+
const reset = ["automatic", "default", "none"].includes(trimmed.toLowerCase());
|
|
171
|
+
if (!reset && (!trimmed || !isAbsolute(trimmed))) {
|
|
172
|
+
notifySaveFailure(ctx, new Error('Enter an absolute path or "automatic".'));
|
|
173
|
+
return { kind: "rejected" };
|
|
174
|
+
}
|
|
175
|
+
const executablePath = reset ? null : trimmed;
|
|
176
|
+
const saved = await saveAndApplyBrowserPatch(
|
|
177
|
+
ctx,
|
|
178
|
+
generation,
|
|
179
|
+
ownerSignal,
|
|
180
|
+
signal,
|
|
181
|
+
projectTrusted,
|
|
182
|
+
{ executablePath },
|
|
183
|
+
);
|
|
184
|
+
if (!saved) return { kind: "rejected" };
|
|
185
|
+
notifyEffectiveSave(
|
|
186
|
+
ctx,
|
|
187
|
+
"browser executable",
|
|
188
|
+
saved.effectiveBrowser.executablePath ?? "automatic discovery",
|
|
189
|
+
saved.effectiveBrowser.executablePathSource,
|
|
190
|
+
);
|
|
191
|
+
return { kind: "back" };
|
|
192
|
+
},
|
|
193
|
+
"show-extensions": () => ({ kind: "to", screen: "extensions" }),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
const result = await runMenu(ctx, menu, {
|
|
197
|
+
getState: async ({ signal }) => {
|
|
198
|
+
signal.throwIfAborted();
|
|
199
|
+
const load = await loadSettings({ cwd: ctx.cwd, projectTrusted });
|
|
200
|
+
signal.throwIfAborted();
|
|
201
|
+
return { load };
|
|
202
|
+
},
|
|
203
|
+
signal: ownerSignal,
|
|
204
|
+
isCurrent,
|
|
205
|
+
onError: (currentCtx, error) =>
|
|
206
|
+
currentCtx.ui.notify(
|
|
207
|
+
sanitizeChromeDevtoolsDisplay(
|
|
208
|
+
`Chrome DevTools browser settings failed: ${formatError(error)}`,
|
|
209
|
+
),
|
|
210
|
+
"error",
|
|
211
|
+
),
|
|
212
|
+
});
|
|
213
|
+
return { closeParent: result.kind === "closed" && result.reason === "close" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function saveAndApplyBrowserPatch(
|
|
217
|
+
ctx: CommandContext,
|
|
218
|
+
generation: number,
|
|
219
|
+
ownerSignal: AbortSignal,
|
|
220
|
+
actionSignal: AbortSignal,
|
|
221
|
+
projectTrusted: boolean,
|
|
222
|
+
patch: BrowserSettingsPatch,
|
|
223
|
+
) {
|
|
224
|
+
try {
|
|
225
|
+
await ctx.waitForIdle();
|
|
226
|
+
if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
|
|
227
|
+
await saveBrowserSettings(patch);
|
|
228
|
+
if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
|
|
229
|
+
await shutdownManagedBrowser();
|
|
230
|
+
if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
|
|
231
|
+
const loaded = await loadSettings({ cwd: ctx.cwd, projectTrusted });
|
|
232
|
+
if (!isCurrent(generation, ownerSignal, actionSignal)) return false;
|
|
233
|
+
applyRuntimeBrowserSettings(loaded.effectiveBrowser, loaded.paths, projectTrusted);
|
|
234
|
+
state.settingsNotice = loaded.notice;
|
|
235
|
+
return loaded;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (isCurrent(generation, ownerSignal, actionSignal)) notifySaveFailure(ctx, error);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isCurrent(generation: number, ownerSignal: AbortSignal, actionSignal: AbortSignal) {
|
|
243
|
+
return generation === state.sessionGeneration && !ownerSignal.aborted && !actionSignal.aborted;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function invalidSettingsScreen(load: SettingsLoadResult) {
|
|
247
|
+
const reason =
|
|
248
|
+
load.userFile.kind === "invalid"
|
|
249
|
+
? load.userFile.reason
|
|
250
|
+
: "The user settings file is unavailable.";
|
|
251
|
+
return {
|
|
252
|
+
kind: "detail" as const,
|
|
253
|
+
title: "Chrome DevTools Browser settings · Read only",
|
|
254
|
+
lines: [
|
|
255
|
+
sanitizeChromeDevtoolsDisplay(
|
|
256
|
+
`Fix the active user settings before saving; the file will not be overwritten. ${reason}`,
|
|
257
|
+
),
|
|
258
|
+
],
|
|
259
|
+
hint: "back" as const,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function settingsContextLines(load: SettingsLoadResult) {
|
|
264
|
+
const browser = load.effectiveBrowser;
|
|
265
|
+
return [
|
|
266
|
+
`User settings · ${load.paths.user}`,
|
|
267
|
+
`Effective sources: endpoint ${browser.endpointSource} · auto-launch ${browser.autoLaunchSource} · executable ${browser.executablePathSource}`,
|
|
268
|
+
...(load.notice ? [`Warning: ${load.notice}`] : []),
|
|
269
|
+
].map((line) => sanitizeChromeDevtoolsDisplay(line));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function extensionDetailLines(load: SettingsLoadResult) {
|
|
273
|
+
const browser = load.effectiveBrowser;
|
|
274
|
+
return [
|
|
275
|
+
`Effective source: ${browser.extensionPathsSource}`,
|
|
276
|
+
...(browser.extensionPaths.length > 0
|
|
277
|
+
? browser.extensionPaths.map((extensionPath) => `- ${extensionPath}`)
|
|
278
|
+
: ["No unpacked extensions are configured."]),
|
|
279
|
+
"Edit browser.extensionPaths in the user JSON or a trusted project JSON, then run /reload.",
|
|
280
|
+
"Unpacked extensions execute privileged browser code; load only paths you trust.",
|
|
281
|
+
].map((line) => sanitizeChromeDevtoolsDisplay(line));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function notifyEffectiveSave(
|
|
285
|
+
ctx: CommandContext,
|
|
286
|
+
setting: string,
|
|
287
|
+
effectiveValue: string,
|
|
288
|
+
source: BrowserSettingsSource,
|
|
289
|
+
) {
|
|
290
|
+
const overrideNotice =
|
|
291
|
+
source === "environment" ? " The deprecated environment override remains effective." : "";
|
|
292
|
+
ctx.ui.notify(
|
|
293
|
+
sanitizeChromeDevtoolsDisplay(
|
|
294
|
+
`Chrome DevTools ${setting} saved. Effective ${setting}: ${effectiveValue} (${source}).${overrideNotice}`,
|
|
295
|
+
),
|
|
296
|
+
"info",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function notifySaveFailure(ctx: CommandContext, error: unknown) {
|
|
301
|
+
ctx.ui.notify(
|
|
302
|
+
sanitizeChromeDevtoolsDisplay(
|
|
303
|
+
`Chrome DevTools browser settings save failed; previous settings remain active: ${formatError(error)}`,
|
|
304
|
+
),
|
|
305
|
+
"warning",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function formatError(error: unknown) {
|
|
310
|
+
return error instanceof Error ? error.message : String(error);
|
|
311
|
+
}
|
package/src/chrome-devtools.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
initializeAvailableChromeDevtoolsTools,
|
|
8
8
|
} from "./lazy-tools.js";
|
|
9
9
|
import { applyRuntimeBrowserSettings, state } from "./runtime.js";
|
|
10
|
-
import { loadSettings } from "./settings.js";
|
|
10
|
+
import { loadSettings, waitForSettingsWrites } from "./settings.js";
|
|
11
11
|
import {
|
|
12
12
|
allChromeDevtoolsTools,
|
|
13
13
|
buildCommandGuide,
|
|
@@ -25,13 +25,22 @@ import {
|
|
|
25
25
|
selectPageTool,
|
|
26
26
|
} from "./tools.js";
|
|
27
27
|
|
|
28
|
-
type CommandAction =
|
|
28
|
+
type CommandAction =
|
|
29
|
+
| "menu"
|
|
30
|
+
| "help"
|
|
31
|
+
| "quickstart"
|
|
32
|
+
| "status"
|
|
33
|
+
| "settings"
|
|
34
|
+
| "tools"
|
|
35
|
+
| "enable"
|
|
36
|
+
| "disable";
|
|
29
37
|
type CommandContext = ExtensionCommandContext;
|
|
30
38
|
const STATUS_KEY = "chrome-devtools";
|
|
31
39
|
const COMMAND_COMPLETIONS = [
|
|
32
40
|
{ value: "help", label: "help", description: "Show command usage" },
|
|
33
41
|
{ value: "quickstart", label: "quickstart", description: "Show endpoint and launch help" },
|
|
34
42
|
{ value: "status", label: "status", description: "Show tool and settings status" },
|
|
43
|
+
{ value: "settings", label: "settings", description: "Edit browser connection settings" },
|
|
35
44
|
{ value: "tools", label: "tools", description: "Choose lazy-loadable Chrome DevTools tools" },
|
|
36
45
|
{ value: "toggle", label: "toggle", description: "Alias for tools" },
|
|
37
46
|
{ value: "select", label: "select", description: "Compatibility alias for tools" },
|
|
@@ -90,6 +99,7 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
90
99
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
91
100
|
const browserShutdown = shutdownManagedBrowser(undefined, { cancelLaunch: true });
|
|
92
101
|
await waitForChromeDevtoolsSettings();
|
|
102
|
+
await waitForSettingsWrites();
|
|
93
103
|
await browserShutdown;
|
|
94
104
|
});
|
|
95
105
|
}
|
|
@@ -120,6 +130,15 @@ async function handleChromeDevtoolsCommand(
|
|
|
120
130
|
ctx.ui.notify(status, "info");
|
|
121
131
|
return;
|
|
122
132
|
}
|
|
133
|
+
case "settings": {
|
|
134
|
+
if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
|
|
135
|
+
throw new Error("/chrome-devtools settings requires TUI or RPC mode");
|
|
136
|
+
}
|
|
137
|
+
const { showChromeDevtoolsBrowserSettings } = await import("./browser-settings-menu.js");
|
|
138
|
+
if (generation !== state.sessionGeneration) return;
|
|
139
|
+
await showChromeDevtoolsBrowserSettings(ctx, generation);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
123
142
|
case "tools": {
|
|
124
143
|
if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
|
|
125
144
|
throw new Error("/chrome-devtools tools requires TUI or RPC mode");
|
|
@@ -174,6 +193,7 @@ export function parseCommand(args: string): CommandAction | "unknown" {
|
|
|
174
193
|
if (command === "help") return "help";
|
|
175
194
|
if (command === "quickstart") return "quickstart";
|
|
176
195
|
if (command === "status") return "status";
|
|
196
|
+
if (command === "settings") return "settings";
|
|
177
197
|
if (command === "tools" || command === "select" || command === "toggle") return "tools";
|
|
178
198
|
if (command === "enable" || command === "on") return "enable";
|
|
179
199
|
if (command === "disable" || command === "off") return "disable";
|
package/src/menu.ts
CHANGED
|
@@ -8,14 +8,13 @@ import { CHROME_DEVTOOLS_TOOL_NAMES, type ChromeDevToolsToolName } from "./tool-
|
|
|
8
8
|
import {
|
|
9
9
|
buildBrowserStatusMessage,
|
|
10
10
|
buildCommandGuide,
|
|
11
|
-
buildSettingsSetupMessage,
|
|
12
11
|
sanitizeChromeDevtoolsDisplay,
|
|
13
12
|
setSelectedChromeDevtoolsTools,
|
|
14
13
|
} from "./tool-selector.js";
|
|
15
14
|
|
|
16
15
|
type CommandContext = ExtensionCommandContext;
|
|
17
|
-
type MainScreen = "main" | "browserStatus" | "
|
|
18
|
-
type MainAction = "tools" | "bulk";
|
|
16
|
+
type MainScreen = "main" | "browserStatus" | "help";
|
|
17
|
+
type MainAction = "tools" | "bulk" | "settings";
|
|
19
18
|
type ToolScreen = "tools" | "review";
|
|
20
19
|
type ToolAction = "toggle" | "selectAll" | "selectNone" | "review" | "apply" | "cancel";
|
|
21
20
|
|
|
@@ -104,9 +103,9 @@ export async function showChromeDevtoolsMenu(
|
|
|
104
103
|
},
|
|
105
104
|
{
|
|
106
105
|
id: "settings",
|
|
107
|
-
label: "
|
|
108
|
-
description: "
|
|
109
|
-
|
|
106
|
+
label: "Browser settings",
|
|
107
|
+
description: "Edit endpoint, auto-launch, and browser executable settings.",
|
|
108
|
+
action: "settings",
|
|
110
109
|
},
|
|
111
110
|
{
|
|
112
111
|
id: "help",
|
|
@@ -125,14 +124,6 @@ export async function showChromeDevtoolsMenu(
|
|
|
125
124
|
viewportSize: "adaptive",
|
|
126
125
|
hint: "back",
|
|
127
126
|
}),
|
|
128
|
-
settings: () => ({
|
|
129
|
-
kind: "review",
|
|
130
|
-
title: "Settings & setup",
|
|
131
|
-
content: buildSettingsSetupMessage(),
|
|
132
|
-
format: { kind: "text" },
|
|
133
|
-
viewportSize: "adaptive",
|
|
134
|
-
hint: "back",
|
|
135
|
-
}),
|
|
136
127
|
help: () => ({
|
|
137
128
|
kind: "review",
|
|
138
129
|
title: "Chrome DevTools help",
|
|
@@ -163,6 +154,13 @@ export async function showChromeDevtoolsMenu(
|
|
|
163
154
|
if (result?.applied && isCurrent()) updateSnapshotAfterApply(current, result.selectedTools);
|
|
164
155
|
return result?.closeParent ? { kind: "close" } : { kind: "stay" };
|
|
165
156
|
},
|
|
157
|
+
settings: async () => {
|
|
158
|
+
const { showChromeDevtoolsBrowserSettings } = await import("./browser-settings-menu.js");
|
|
159
|
+
if (!isCurrent()) return { kind: "stay" };
|
|
160
|
+
const result = await showChromeDevtoolsBrowserSettings(ctx, generation);
|
|
161
|
+
if (!isCurrent()) return { kind: "stay" };
|
|
162
|
+
return result.closeParent ? { kind: "close" } : { kind: "stay" };
|
|
163
|
+
},
|
|
166
164
|
},
|
|
167
165
|
});
|
|
168
166
|
await runMenu(ctx, menu, {
|
package/src/runtime.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_BROWSER_HOST,
|
|
4
|
+
DEFAULT_BROWSER_PORT,
|
|
5
|
+
type EffectiveBrowserSettings,
|
|
6
|
+
parseConfiguredPort,
|
|
7
|
+
} from "./settings.js";
|
|
3
8
|
|
|
4
|
-
export const DEFAULT_HOST =
|
|
5
|
-
export const DEFAULT_PORT =
|
|
9
|
+
export const DEFAULT_HOST = DEFAULT_BROWSER_HOST;
|
|
10
|
+
export const DEFAULT_PORT = DEFAULT_BROWSER_PORT;
|
|
6
11
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
7
12
|
export const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
|
|
8
13
|
export const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
|
|
@@ -26,6 +31,8 @@ export interface ChromeDevToolsState {
|
|
|
26
31
|
hostConfigured: boolean;
|
|
27
32
|
portConfigured: boolean;
|
|
28
33
|
autoLaunchEnabled: boolean;
|
|
34
|
+
endpointSource: EffectiveBrowserSettings["endpointSource"];
|
|
35
|
+
autoLaunchSource: EffectiveBrowserSettings["autoLaunchSource"];
|
|
29
36
|
browserExecutable?: string;
|
|
30
37
|
extensionPaths: string[];
|
|
31
38
|
browserExecutableSource: EffectiveBrowserSettings["executablePathSource"];
|
|
@@ -71,29 +78,19 @@ export interface BrowserCandidate extends BrowserCandidateDefinition {
|
|
|
71
78
|
resolvedExecutable: string;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
|
-
export
|
|
75
|
-
if (value === undefined) return undefined;
|
|
76
|
-
const trimmedValue = value.trim();
|
|
77
|
-
if (!/^\d+$/.test(trimmedValue)) return undefined;
|
|
78
|
-
const port = Number(trimmedValue);
|
|
79
|
-
if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
|
|
80
|
-
return port;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const configuredHost = process.env.PI_CHROME_DEVTOOLS_HOST ?? DEFAULT_HOST;
|
|
84
|
-
const configuredPortOverride = parseConfiguredPort(process.env.PI_CHROME_DEVTOOLS_PORT);
|
|
85
|
-
const configuredPort = configuredPortOverride ?? DEFAULT_PORT;
|
|
81
|
+
export { parseConfiguredPort };
|
|
86
82
|
|
|
87
83
|
export const state: ChromeDevToolsState = {
|
|
88
|
-
host:
|
|
89
|
-
port:
|
|
90
|
-
configuredPort,
|
|
91
|
-
hostConfigured:
|
|
92
|
-
portConfigured:
|
|
93
|
-
autoLaunchEnabled:
|
|
94
|
-
|
|
84
|
+
host: DEFAULT_HOST,
|
|
85
|
+
port: DEFAULT_PORT,
|
|
86
|
+
configuredPort: DEFAULT_PORT,
|
|
87
|
+
hostConfigured: false,
|
|
88
|
+
portConfigured: false,
|
|
89
|
+
autoLaunchEnabled: true,
|
|
90
|
+
endpointSource: "default",
|
|
91
|
+
autoLaunchSource: "default",
|
|
95
92
|
extensionPaths: [],
|
|
96
|
-
browserExecutableSource:
|
|
93
|
+
browserExecutableSource: "default",
|
|
97
94
|
extensionPathsSource: "default",
|
|
98
95
|
projectSettingsTrusted: false,
|
|
99
96
|
shuttingDown: false,
|
|
@@ -106,6 +103,14 @@ export function applyRuntimeBrowserSettings(
|
|
|
106
103
|
paths: { user: string; project?: string },
|
|
107
104
|
projectTrusted: boolean,
|
|
108
105
|
) {
|
|
106
|
+
state.host = browser.host;
|
|
107
|
+
state.port = browser.port;
|
|
108
|
+
state.configuredPort = browser.port;
|
|
109
|
+
state.hostConfigured = browser.hostConfigured;
|
|
110
|
+
state.portConfigured = browser.portConfigured;
|
|
111
|
+
state.autoLaunchEnabled = browser.autoLaunchEnabled;
|
|
112
|
+
state.endpointSource = browser.endpointSource;
|
|
113
|
+
state.autoLaunchSource = browser.autoLaunchSource;
|
|
109
114
|
state.browserExecutable = browser.executablePath;
|
|
110
115
|
state.extensionPaths = [...browser.extensionPaths];
|
|
111
116
|
state.browserExecutableSource = browser.executablePathSource;
|
package/src/settings.ts
CHANGED
|
@@ -23,11 +23,36 @@ export interface ChromeDevToolsSettings {
|
|
|
23
23
|
updatedAt: number;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export const DEFAULT_BROWSER_HOST = "127.0.0.1";
|
|
27
|
+
export const DEFAULT_BROWSER_PORT = 9222;
|
|
28
|
+
export const DEFAULT_BROWSER_ENDPOINT = `http://${DEFAULT_BROWSER_HOST}:${DEFAULT_BROWSER_PORT}`;
|
|
29
|
+
|
|
26
30
|
export type BrowserSettingsSource = "default" | "environment" | "project" | "user";
|
|
27
31
|
|
|
32
|
+
export interface UserBrowserSettings {
|
|
33
|
+
endpoint?: string;
|
|
34
|
+
autoLaunch?: boolean;
|
|
35
|
+
executablePath?: string;
|
|
36
|
+
extensionPaths?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BrowserSettingsPatch {
|
|
40
|
+
endpoint?: string | null;
|
|
41
|
+
autoLaunch?: boolean | null;
|
|
42
|
+
executablePath?: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
export interface EffectiveBrowserSettings {
|
|
46
|
+
endpoint: string;
|
|
47
|
+
host: string;
|
|
48
|
+
port: number;
|
|
49
|
+
hostConfigured: boolean;
|
|
50
|
+
portConfigured: boolean;
|
|
51
|
+
autoLaunchEnabled: boolean;
|
|
29
52
|
executablePath?: string;
|
|
30
53
|
extensionPaths: string[];
|
|
54
|
+
endpointSource: BrowserSettingsSource;
|
|
55
|
+
autoLaunchSource: BrowserSettingsSource;
|
|
31
56
|
executablePathSource: BrowserSettingsSource;
|
|
32
57
|
extensionPathsSource: BrowserSettingsSource;
|
|
33
58
|
}
|
|
@@ -43,9 +68,14 @@ export interface SettingsLoadOptions {
|
|
|
43
68
|
projectTrusted?: boolean;
|
|
44
69
|
}
|
|
45
70
|
|
|
71
|
+
export type UserSettingsFileStatus =
|
|
72
|
+
| { kind: "missing" | "valid" }
|
|
73
|
+
| { kind: "invalid"; reason: string };
|
|
74
|
+
|
|
46
75
|
interface SettingsLoadBase {
|
|
47
76
|
effectiveBrowser: EffectiveBrowserSettings;
|
|
48
77
|
paths: { user: string; project?: string };
|
|
78
|
+
userFile: UserSettingsFileStatus;
|
|
49
79
|
warnings: string[];
|
|
50
80
|
notice?: string;
|
|
51
81
|
}
|
|
@@ -60,15 +90,10 @@ export interface SettingsFileOperations {
|
|
|
60
90
|
rename(source: string, destination: string): Promise<void>;
|
|
61
91
|
}
|
|
62
92
|
|
|
63
|
-
interface NormalizedBrowserSection {
|
|
64
|
-
executablePath?: string;
|
|
65
|
-
extensionPaths?: string[];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
93
|
interface NormalizedSettingsDocument {
|
|
69
94
|
tools?: ChromeDevToolsToolName[];
|
|
70
95
|
updatedAt?: number;
|
|
71
|
-
browser?:
|
|
96
|
+
browser?: UserBrowserSettings;
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
interface SettingsDocumentResult {
|
|
@@ -86,6 +111,10 @@ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
|
|
|
86
111
|
|
|
87
112
|
let settingsSaveQueue = Promise.resolve();
|
|
88
113
|
|
|
114
|
+
export async function waitForSettingsWrites() {
|
|
115
|
+
await settingsSaveQueue;
|
|
116
|
+
}
|
|
117
|
+
|
|
89
118
|
export async function loadSettings(options: SettingsLoadOptions = {}): Promise<SettingsLoadResult> {
|
|
90
119
|
await settingsSaveQueue;
|
|
91
120
|
const userPath = settingsFilePath();
|
|
@@ -127,10 +156,11 @@ export async function loadSettings(options: SettingsLoadOptions = {}): Promise<S
|
|
|
127
156
|
warnings.push(...project.warnings);
|
|
128
157
|
}
|
|
129
158
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
const environmentWarning = deprecatedEnvironmentWarning();
|
|
160
|
+
if (environmentWarning) warnings.push(environmentWarning);
|
|
161
|
+
|
|
162
|
+
const userBrowser = user.normalized?.browser ?? {};
|
|
163
|
+
const effectiveBrowser = resolveEffectiveBrowser(userBrowser, project.normalized?.browser);
|
|
134
164
|
const settings = resolveSettings(user.normalized, project.normalized, effectiveBrowser);
|
|
135
165
|
const recognized =
|
|
136
166
|
settings.tools !== undefined ||
|
|
@@ -140,9 +170,17 @@ export async function loadSettings(options: SettingsLoadOptions = {}): Promise<S
|
|
|
140
170
|
.filter((result) => result.kind === "invalid")
|
|
141
171
|
.map((result) => result.reason)
|
|
142
172
|
.filter((reason): reason is string => Boolean(reason));
|
|
173
|
+
const userFile: UserSettingsFileStatus =
|
|
174
|
+
user.kind === "invalid"
|
|
175
|
+
? {
|
|
176
|
+
kind: "invalid",
|
|
177
|
+
reason: user.reason ?? `${userPath}: invalid settings`,
|
|
178
|
+
}
|
|
179
|
+
: { kind: user.kind };
|
|
143
180
|
const base = {
|
|
144
181
|
effectiveBrowser,
|
|
145
182
|
paths,
|
|
183
|
+
userFile,
|
|
146
184
|
warnings,
|
|
147
185
|
...(warnings.length > 0 ? { notice: warnings.join("\n") } : {}),
|
|
148
186
|
};
|
|
@@ -166,28 +204,69 @@ function resolveSettings(
|
|
|
166
204
|
}
|
|
167
205
|
|
|
168
206
|
function resolveEffectiveBrowser(
|
|
169
|
-
user:
|
|
170
|
-
project:
|
|
207
|
+
user: UserBrowserSettings,
|
|
208
|
+
project: UserBrowserSettings | undefined,
|
|
171
209
|
): EffectiveBrowserSettings {
|
|
210
|
+
const configuredEndpoint = parseBrowserEndpoint(user.endpoint ?? DEFAULT_BROWSER_ENDPOINT);
|
|
211
|
+
const environmentHost = process.env.PI_CHROME_DEVTOOLS_HOST;
|
|
212
|
+
const environmentPortValue = process.env.PI_CHROME_DEVTOOLS_PORT;
|
|
213
|
+
const environmentPort = parseConfiguredPort(environmentPortValue);
|
|
214
|
+
const environmentEndpointConfigured =
|
|
215
|
+
environmentHost !== undefined || environmentPort !== undefined;
|
|
216
|
+
const host = environmentHost ?? configuredEndpoint.host;
|
|
217
|
+
const port = environmentPort ?? configuredEndpoint.port;
|
|
218
|
+
const environmentAutoLaunch = process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH;
|
|
172
219
|
const environmentExecutable = process.env.PI_CHROME_DEVTOOLS_BROWSER;
|
|
173
|
-
const executablePath = environmentExecutable || user
|
|
174
|
-
const extensionPaths = project?.extensionPaths ?? user
|
|
220
|
+
const executablePath = environmentExecutable || user.executablePath;
|
|
221
|
+
const extensionPaths = project?.extensionPaths ?? user.extensionPaths ?? [];
|
|
222
|
+
const endpointSource: BrowserSettingsSource = environmentEndpointConfigured
|
|
223
|
+
? "environment"
|
|
224
|
+
: user.endpoint
|
|
225
|
+
? "user"
|
|
226
|
+
: "default";
|
|
175
227
|
return {
|
|
228
|
+
endpoint: formatBrowserEndpoint(host, port),
|
|
229
|
+
host,
|
|
230
|
+
port,
|
|
231
|
+
hostConfigured: environmentHost !== undefined || user.endpoint !== undefined,
|
|
232
|
+
portConfigured: environmentPort !== undefined || user.endpoint !== undefined,
|
|
233
|
+
autoLaunchEnabled:
|
|
234
|
+
environmentAutoLaunch === undefined
|
|
235
|
+
? (user.autoLaunch ?? true)
|
|
236
|
+
: environmentAutoLaunch !== "0",
|
|
176
237
|
...(executablePath ? { executablePath } : {}),
|
|
177
238
|
extensionPaths: [...extensionPaths],
|
|
239
|
+
endpointSource,
|
|
240
|
+
autoLaunchSource:
|
|
241
|
+
environmentAutoLaunch !== undefined
|
|
242
|
+
? "environment"
|
|
243
|
+
: user.autoLaunch !== undefined
|
|
244
|
+
? "user"
|
|
245
|
+
: "default",
|
|
178
246
|
executablePathSource: environmentExecutable
|
|
179
247
|
? "environment"
|
|
180
|
-
: user
|
|
248
|
+
: user.executablePath
|
|
181
249
|
? "user"
|
|
182
250
|
: "default",
|
|
183
251
|
extensionPathsSource: project?.extensionPaths
|
|
184
252
|
? "project"
|
|
185
|
-
: user
|
|
253
|
+
: user.extensionPaths
|
|
186
254
|
? "user"
|
|
187
255
|
: "default",
|
|
188
256
|
};
|
|
189
257
|
}
|
|
190
258
|
|
|
259
|
+
function deprecatedEnvironmentWarning() {
|
|
260
|
+
const configuredNames = [
|
|
261
|
+
"PI_CHROME_DEVTOOLS_HOST",
|
|
262
|
+
"PI_CHROME_DEVTOOLS_PORT",
|
|
263
|
+
"PI_CHROME_DEVTOOLS_AUTO_LAUNCH",
|
|
264
|
+
"PI_CHROME_DEVTOOLS_BROWSER",
|
|
265
|
+
].filter((name) => process.env[name] !== undefined);
|
|
266
|
+
if (configuredNames.length === 0) return undefined;
|
|
267
|
+
return `Chrome DevTools environment settings are deprecated and will be removed in a future version: ${configuredNames.join(", ")}. Move them to browser.endpoint, browser.autoLaunch, and browser.executablePath in ${settingsFilePath()}. Environment values remain active overrides during the deprecation period.`;
|
|
268
|
+
}
|
|
269
|
+
|
|
191
270
|
async function readSettingsDocument(
|
|
192
271
|
filePath: string,
|
|
193
272
|
scope: "project" | "user",
|
|
@@ -214,7 +293,7 @@ async function readSettingsDocument(
|
|
|
214
293
|
return { kind: "invalid", reason, warnings: [`Chrome DevTools settings ignored: ${reason}`] };
|
|
215
294
|
}
|
|
216
295
|
|
|
217
|
-
const scopeWarnings =
|
|
296
|
+
const scopeWarnings = projectMachineSettingsWarnings(parsed, scope, filePath);
|
|
218
297
|
try {
|
|
219
298
|
const normalized = await normalizeSettingsDocument(parsed, scope, filePath, cwd);
|
|
220
299
|
return { kind: "valid", document: { ...parsed }, normalized, warnings: scopeWarnings };
|
|
@@ -228,21 +307,19 @@ async function readSettingsDocument(
|
|
|
228
307
|
}
|
|
229
308
|
}
|
|
230
309
|
|
|
231
|
-
function
|
|
310
|
+
function projectMachineSettingsWarnings(
|
|
232
311
|
document: Record<string, unknown>,
|
|
233
312
|
scope: "project" | "user",
|
|
234
313
|
filePath: string,
|
|
235
314
|
) {
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
`Chrome DevTools project browser.executablePath ignored in ${filePath}; configure the machine-owned executable in ${settingsFilePath()}.`,
|
|
245
|
-
];
|
|
315
|
+
if (scope !== "project" || !isRecord(document.browser)) return [];
|
|
316
|
+
const browser = document.browser;
|
|
317
|
+
return ["endpoint", "autoLaunch", "executablePath"]
|
|
318
|
+
.filter((field) => browser[field] !== undefined)
|
|
319
|
+
.map(
|
|
320
|
+
(field) =>
|
|
321
|
+
`Chrome DevTools project browser.${field} ignored in ${filePath}; configure this machine-owned setting in ${settingsFilePath()}.`,
|
|
322
|
+
);
|
|
246
323
|
}
|
|
247
324
|
|
|
248
325
|
async function normalizeSettingsDocument(
|
|
@@ -277,8 +354,20 @@ async function normalizeBrowserSection(
|
|
|
277
354
|
scope: "project" | "user",
|
|
278
355
|
_filePath: string,
|
|
279
356
|
cwd?: string,
|
|
280
|
-
): Promise<
|
|
281
|
-
const normalized:
|
|
357
|
+
): Promise<UserBrowserSettings> {
|
|
358
|
+
const normalized: UserBrowserSettings = {};
|
|
359
|
+
if (scope === "user" && browser.endpoint !== undefined) {
|
|
360
|
+
if (typeof browser.endpoint !== "string") {
|
|
361
|
+
throw new Error("expected browser.endpoint to be an HTTP URL with an explicit port");
|
|
362
|
+
}
|
|
363
|
+
normalized.endpoint = parseBrowserEndpoint(browser.endpoint).endpoint;
|
|
364
|
+
}
|
|
365
|
+
if (scope === "user" && browser.autoLaunch !== undefined) {
|
|
366
|
+
if (typeof browser.autoLaunch !== "boolean") {
|
|
367
|
+
throw new Error("expected browser.autoLaunch to be a boolean");
|
|
368
|
+
}
|
|
369
|
+
normalized.autoLaunch = browser.autoLaunch;
|
|
370
|
+
}
|
|
282
371
|
if (scope === "user" && browser.executablePath !== undefined) {
|
|
283
372
|
if (typeof browser.executablePath !== "string" || browser.executablePath.length === 0) {
|
|
284
373
|
throw new Error("expected browser.executablePath to be a non-empty absolute path");
|
|
@@ -350,6 +439,52 @@ async function validateUnpackedExtensionPath(extensionPath: string) {
|
|
|
350
439
|
return canonicalPath;
|
|
351
440
|
}
|
|
352
441
|
|
|
442
|
+
export function parseBrowserEndpoint(value: string) {
|
|
443
|
+
const trimmedValue = value.trim();
|
|
444
|
+
const explicitPort = trimmedValue.match(/^http:\/\/(?:\[[^\]]+\]|[^/:?#]+):(\d+)\/?$/i)?.[1];
|
|
445
|
+
let endpoint: URL;
|
|
446
|
+
try {
|
|
447
|
+
endpoint = new URL(trimmedValue);
|
|
448
|
+
} catch {
|
|
449
|
+
throw new Error("browser.endpoint must be a valid HTTP URL with an explicit port");
|
|
450
|
+
}
|
|
451
|
+
if (
|
|
452
|
+
endpoint.protocol !== "http:" ||
|
|
453
|
+
endpoint.username ||
|
|
454
|
+
endpoint.password ||
|
|
455
|
+
!endpoint.hostname ||
|
|
456
|
+
!explicitPort ||
|
|
457
|
+
(endpoint.pathname !== "/" && endpoint.pathname !== "") ||
|
|
458
|
+
endpoint.search ||
|
|
459
|
+
endpoint.hash
|
|
460
|
+
) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
"browser.endpoint must be an HTTP origin with an explicit port and no credentials, path, query, or fragment",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const port = parseConfiguredPort(explicitPort);
|
|
466
|
+
if (port === undefined) {
|
|
467
|
+
throw new Error("browser.endpoint must use a port from 1 through 65535");
|
|
468
|
+
}
|
|
469
|
+
const host = endpoint.hostname;
|
|
470
|
+
return { endpoint: formatBrowserEndpoint(host, port), host, port };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function parseConfiguredPort(value: string | undefined) {
|
|
474
|
+
if (value === undefined) return undefined;
|
|
475
|
+
const trimmedValue = value.trim();
|
|
476
|
+
if (!/^\d+$/.test(trimmedValue)) return undefined;
|
|
477
|
+
const port = Number(trimmedValue);
|
|
478
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
|
|
479
|
+
return port;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function formatBrowserEndpoint(host: string, port: number) {
|
|
483
|
+
const formattedHost =
|
|
484
|
+
host.startsWith("[") && host.endsWith("]") ? host : host.includes(":") ? `[${host}]` : host;
|
|
485
|
+
return `http://${formattedHost}:${port}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
353
488
|
export function normalizeChromeDevtoolsSettings(
|
|
354
489
|
value: unknown,
|
|
355
490
|
): ChromeDevToolsSettings | undefined {
|
|
@@ -381,13 +516,48 @@ export function saveSettings(
|
|
|
381
516
|
settings: ChromeDevToolsSettings,
|
|
382
517
|
operations: Partial<SettingsFileOperations> = {},
|
|
383
518
|
): Promise<void> {
|
|
384
|
-
|
|
519
|
+
return queueSettingsMutation(
|
|
520
|
+
(current) => ({
|
|
521
|
+
...current,
|
|
522
|
+
tools: [...settings.tools],
|
|
523
|
+
updatedAt: settings.updatedAt,
|
|
524
|
+
}),
|
|
525
|
+
operations,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function saveBrowserSettings(
|
|
530
|
+
patch: BrowserSettingsPatch,
|
|
531
|
+
operations: Partial<SettingsFileOperations> = {},
|
|
532
|
+
): Promise<void> {
|
|
533
|
+
return queueSettingsMutation(async (current) => {
|
|
534
|
+
const browser = isRecord(current.browser) ? { ...current.browser } : {};
|
|
535
|
+
for (const field of ["endpoint", "autoLaunch", "executablePath"] as const) {
|
|
536
|
+
const value = patch[field];
|
|
537
|
+
if (value === undefined) continue;
|
|
538
|
+
if (value === null) delete browser[field];
|
|
539
|
+
else browser[field] = value;
|
|
540
|
+
}
|
|
541
|
+
await normalizeBrowserSection(browser, "user", settingsFilePath());
|
|
542
|
+
return { ...current, browser };
|
|
543
|
+
}, operations);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function queueSettingsMutation(
|
|
547
|
+
mutate: (
|
|
548
|
+
current: Record<string, unknown>,
|
|
549
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>,
|
|
550
|
+
operations: Partial<SettingsFileOperations>,
|
|
551
|
+
) {
|
|
552
|
+
const operation = settingsSaveQueue.then(() => saveSettingsMutationNow(mutate, operations));
|
|
385
553
|
settingsSaveQueue = operation.catch(() => undefined);
|
|
386
554
|
return operation;
|
|
387
555
|
}
|
|
388
556
|
|
|
389
|
-
async function
|
|
390
|
-
|
|
557
|
+
async function saveSettingsMutationNow(
|
|
558
|
+
mutate: (
|
|
559
|
+
current: Record<string, unknown>,
|
|
560
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>,
|
|
391
561
|
operations: Partial<SettingsFileOperations>,
|
|
392
562
|
): Promise<void> {
|
|
393
563
|
const filePath = settingsFilePath();
|
|
@@ -397,11 +567,7 @@ async function saveSettingsNow(
|
|
|
397
567
|
if (current.kind === "invalid") {
|
|
398
568
|
throw new Error(`Cannot save Chrome DevTools settings until you repair ${current.reason}`);
|
|
399
569
|
}
|
|
400
|
-
const nextDocument = {
|
|
401
|
-
...(current.document ?? {}),
|
|
402
|
-
tools: [...settings.tools],
|
|
403
|
-
updatedAt: settings.updatedAt,
|
|
404
|
-
};
|
|
570
|
+
const nextDocument = await mutate(current.document ?? {});
|
|
405
571
|
await mkdir(dirname(filePath), { recursive: true });
|
|
406
572
|
const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
407
573
|
try {
|
package/src/tool-selector.ts
CHANGED
|
@@ -268,10 +268,11 @@ function browserSettingsStatusLines() {
|
|
|
268
268
|
`Project settings: ${state.projectSettingsFilePath} (${state.projectSettingsTrusted ? "trusted" : "untrusted; ignored"})`,
|
|
269
269
|
]
|
|
270
270
|
: []),
|
|
271
|
+
`Auto-launch: ${state.autoLaunchEnabled ? "on" : "off"} (${state.autoLaunchSource})`,
|
|
271
272
|
`Browser executable: ${state.browserExecutable ?? "automatic discovery"} (${state.browserExecutableSource})`,
|
|
272
273
|
`Unpacked extensions (${state.extensionPathsSource}):`,
|
|
273
274
|
...extensionLines,
|
|
274
|
-
"
|
|
275
|
+
"Confirmed menu settings apply before the next browser connection; manual JSON edits require /reload or session replacement.",
|
|
275
276
|
...(state.extensionPaths.length > 0
|
|
276
277
|
? [
|
|
277
278
|
"Unpacked extensions require Chrome for Testing or Chromium and execute trusted browser code.",
|
|
@@ -287,6 +288,7 @@ export function buildCommandGuide() {
|
|
|
287
288
|
"/chrome-devtools help — show command usage",
|
|
288
289
|
"/chrome-devtools quickstart — show endpoint and launch help",
|
|
289
290
|
"/chrome-devtools status — show tool and settings status",
|
|
291
|
+
"/chrome-devtools settings — edit browser connection settings",
|
|
290
292
|
"/chrome-devtools tools — choose tools available to lazy-load",
|
|
291
293
|
"/chrome-devtools toggle|select — compatibility aliases for tools",
|
|
292
294
|
"/chrome-devtools enable|on — make all Chrome DevTools tools available to lazy-load",
|