chrome-cdp-manager 1.0.0 → 1.2.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/README.md +90 -32
- package/package.json +23 -4
- package/src/browser.js +50 -0
- package/src/browsers.js +175 -0
- package/src/cli.js +33 -9
- package/src/commands.js +74 -27
- package/src/config.js +64 -16
- package/src/index.js +39 -0
- package/src/launcher.js +20 -0
- package/src/{appBundle.js → launchers/macBundle.js} +59 -27
- package/src/launchers/winShortcut.js +135 -0
- package/src/platform.js +11 -8
- package/src/playwright.js +71 -0
- package/src/chrome.js +0 -74
package/README.md
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
# chrome-cdp-manager
|
|
2
2
|
|
|
3
|
-
Set up and drive a **Chrome DevTools Protocol (CDP)** instance on macOS
|
|
4
|
-
dedicated
|
|
5
|
-
the **Dock icon stays consistent**.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
Set up and drive a **Chrome DevTools Protocol (CDP)** instance on **macOS or
|
|
4
|
+
Windows** through a dedicated launcher, so launches always go through the same
|
|
5
|
+
entry point and the **Dock/taskbar icon stays consistent**. Works with any
|
|
6
|
+
**Chromium-based browser** — Chrome, Edge, Brave, Chromium, Vivaldi, Opera, Arc.
|
|
7
|
+
|
|
8
|
+
- **macOS**: builds a real `.app` bundle (`/Applications/ChromeCDP.app`) with a
|
|
9
|
+
proper `Info.plist` and the browser's own icon — launched via `open` so
|
|
10
|
+
LaunchServices attaches a consistent Dock icon.
|
|
11
|
+
- **Windows**: creates a Start Menu `.lnk` shortcut with the CDP flags baked in
|
|
12
|
+
and a custom icon — headed launches go through the shortcut for a consistent
|
|
13
|
+
taskbar entry.
|
|
11
14
|
- Connects over CDP with **zero heavy dependencies** — uses Node's built-in
|
|
12
15
|
`fetch` and `WebSocket` (no Playwright/Puppeteer, no browser download).
|
|
13
16
|
|
|
14
|
-
> Requires
|
|
17
|
+
> Requires Node.js ≥ 22 and a Chromium-based browser installed. On macOS,
|
|
18
|
+
> Apple Silicon (the launcher uses `arch -arm64`).
|
|
19
|
+
>
|
|
20
|
+
> Non-Chromium browsers (Firefox, Safari) are **not** supported — they don't
|
|
21
|
+
> speak the same CDP this tool drives.
|
|
15
22
|
|
|
16
23
|
## Usage
|
|
17
24
|
|
|
@@ -27,13 +34,19 @@ npm install -g chrome-cdp-manager
|
|
|
27
34
|
```
|
|
28
35
|
|
|
29
36
|
```bash
|
|
30
|
-
# Create / repair
|
|
37
|
+
# Create / repair the launcher and save defaults (port 9222, profile ~/.chrome_cdp_profile)
|
|
31
38
|
chrome-cdp setup
|
|
32
39
|
|
|
33
|
-
#
|
|
40
|
+
# Use a specific browser
|
|
41
|
+
chrome-cdp setup --browser edge
|
|
42
|
+
|
|
43
|
+
# See which browsers are installed
|
|
44
|
+
chrome-cdp browsers
|
|
45
|
+
|
|
46
|
+
# Launch ChromeCDP and open a page
|
|
34
47
|
chrome-cdp open https://example.com
|
|
35
48
|
|
|
36
|
-
# Same, headless (no window/Dock)
|
|
49
|
+
# Same, headless (no window/Dock/taskbar)
|
|
37
50
|
chrome-cdp open https://example.com --headless
|
|
38
51
|
|
|
39
52
|
# Fetch a page's rendered HTML over CDP (headless by default)
|
|
@@ -51,32 +64,71 @@ chrome-cdp stop
|
|
|
51
64
|
|
|
52
65
|
## Commands
|
|
53
66
|
|
|
54
|
-
| Command | Description
|
|
55
|
-
| ------------------ |
|
|
56
|
-
| `setup` | Create or repair
|
|
57
|
-
| `open [url]` | Launch via the
|
|
58
|
-
| `html <url>` | Navigate and print/save the page's serialized HTML
|
|
59
|
-
| `status` | Show
|
|
60
|
-
| `stop` | Ask the running ChromeCDP instance to quit
|
|
67
|
+
| Command | Description |
|
|
68
|
+
| ------------------ | ----------------------------------------------------------------- |
|
|
69
|
+
| `setup` | Create or repair the launcher (icon + CDP flags) |
|
|
70
|
+
| `open [url]` | Launch via the launcher, optionally open a URL; leaves it running |
|
|
71
|
+
| `html <url>` | Navigate and print/save the page's serialized HTML |
|
|
72
|
+
| `status` | Show launcher presence and CDP connection state |
|
|
73
|
+
| `stop` | Ask the running ChromeCDP instance to quit |
|
|
74
|
+
| `browsers` | List supported browsers and which are installed |
|
|
75
|
+
|
|
76
|
+
## Programmatic API
|
|
77
|
+
|
|
78
|
+
Beyond the CLI, the package exposes a small ES-module API (Node ≥ 22).
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { launch, CdpClient } from "chrome-cdp-manager";
|
|
82
|
+
|
|
83
|
+
// Ensure ChromeCDP is running (launches it if needed) and get its endpoint.
|
|
84
|
+
const { endpoint, config, launched } = await launch({ headless: false });
|
|
85
|
+
|
|
86
|
+
// Drive it with the built-in zero-dependency raw-CDP client...
|
|
87
|
+
const cdp = await CdpClient.connect(config.cdpPort);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Also exported: `loadConfig`, `getLauncher`, `ensureBrowserRunning`, `probeCdp`,
|
|
91
|
+
`waitForCdp`, `openUrl`, `getPageHtml`, `closeBrowser`.
|
|
92
|
+
|
|
93
|
+
### Playwright bridge (optional)
|
|
94
|
+
|
|
95
|
+
If you want Playwright's high-level page API, opt into the separate entry point.
|
|
96
|
+
`playwright` is an **optional peer dependency** — the core stays dependency-free.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { connect } from "chrome-cdp-manager/playwright";
|
|
100
|
+
|
|
101
|
+
await using session = await connect({ headless: false, match: u => u.includes("bing.com") });
|
|
102
|
+
await session.page.goto("https://www.bing.com");
|
|
103
|
+
// `await using` detaches the CDP channel on scope exit; the browser keeps running.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`connect()` resolves config, ensures the launcher + a running browser, connects
|
|
107
|
+
Playwright over CDP, and returns `{ browser, context, page, config }`. In
|
|
108
|
+
zero-install (npx) setups where Playwright can't be resolved automatically,
|
|
109
|
+
inject it: `connect({ chromium })`.
|
|
61
110
|
|
|
62
111
|
## Common options
|
|
63
112
|
|
|
64
|
-
| Option | Description
|
|
65
|
-
| ----------------------- |
|
|
66
|
-
| `--port <port>` | CDP port (default `9222`)
|
|
67
|
-
| `-p, --profile <dir>` |
|
|
68
|
-
| `-
|
|
69
|
-
| `--
|
|
70
|
-
|
|
|
113
|
+
| Option | Description |
|
|
114
|
+
| ----------------------- | ------------------------------------------------------------- |
|
|
115
|
+
| `--port <port>` | CDP port (default `9222`) |
|
|
116
|
+
| `-p, --profile <dir>` | Browser `user-data-dir` (default `~/.chrome_cdp_profile`) |
|
|
117
|
+
| `-b, --browser <name>` | Browser: `chrome`, `edge`, `brave`, `chromium`, `vivaldi`, `opera`, `arc` |
|
|
118
|
+
| `--path <path>` | Explicit browser executable (overrides `--browser`) |
|
|
119
|
+
| `--target <path>` | Launcher location (`.app` on macOS, `.lnk` on Windows) |
|
|
120
|
+
| `-t, --timeout <secs>` | Startup / load timeout (`open`, `html`) |
|
|
71
121
|
|
|
72
|
-
|
|
122
|
+
`-c, --chrome` and `--bundle` remain as aliases for `--path` and `--target`.
|
|
123
|
+
|
|
124
|
+
Browser, port, profile and launcher path are baked into the launcher and
|
|
73
125
|
persisted (`~/.config/chrome-cdp-manager/config.json`) at `setup` time so every
|
|
74
126
|
command agrees on the same environment. Re-run `setup --force` after changing
|
|
75
127
|
them.
|
|
76
128
|
|
|
77
|
-
## How the
|
|
129
|
+
## How the icon stays consistent
|
|
78
130
|
|
|
79
|
-
|
|
131
|
+
**macOS** — the bundle's executable is a small bash launcher:
|
|
80
132
|
|
|
81
133
|
```bash
|
|
82
134
|
exec arch -arm64 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
|
@@ -85,6 +137,12 @@ exec arch -arm64 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
|
85
137
|
"$@"
|
|
86
138
|
```
|
|
87
139
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
140
|
+
Headed launches go through `open /Applications/ChromeCDP.app`, so LaunchServices
|
|
141
|
+
runs the browser under the ChromeCDP bundle identity — a consistent Dock icon
|
|
142
|
+
every time instead of a generic/duplicated entry.
|
|
143
|
+
|
|
144
|
+
**Windows** — a Start Menu `.lnk` shortcut (`ChromeCDP.lnk`) is created with the
|
|
145
|
+
browser as its target, the CDP flags as its arguments, and the browser's icon.
|
|
146
|
+
Because the dedicated `--user-data-dir` gives the browser its own
|
|
147
|
+
AppUserModelID, the ChromeCDP window gets its own pinnable taskbar entry,
|
|
148
|
+
separate from your everyday browser windows.
|
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-cdp-manager",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Set up and drive a Chrome DevTools Protocol (CDP) instance on macOS through a dedicated
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Set up and drive a Chrome DevTools Protocol (CDP) instance on macOS or Windows through a dedicated launcher (consistent Dock/taskbar icon). Works with any Chromium-based browser — Chrome, Edge, Brave, Chromium, Vivaldi, Opera, Arc.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./playwright": "./src/playwright.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
6
12
|
"bin": {
|
|
7
13
|
"chrome-cdp": "bin/cli.js",
|
|
8
14
|
"chrome-cdp-manager": "bin/cli.js"
|
|
@@ -17,25 +23,38 @@
|
|
|
17
23
|
"node": ">=22"
|
|
18
24
|
},
|
|
19
25
|
"os": [
|
|
20
|
-
"darwin"
|
|
26
|
+
"darwin",
|
|
27
|
+
"win32"
|
|
21
28
|
],
|
|
22
29
|
"scripts": {
|
|
23
30
|
"start": "node bin/cli.js",
|
|
24
|
-
"check": "node --check bin/cli.js && node --check
|
|
31
|
+
"check": "node --check bin/cli.js && for f in src/*.js src/launchers/*.js; do node --check \"$f\" || exit 1; done",
|
|
25
32
|
"prepublishOnly": "npm run check",
|
|
26
33
|
"release": "bash scripts/publish.sh"
|
|
27
34
|
},
|
|
28
35
|
"keywords": [
|
|
29
36
|
"chrome",
|
|
37
|
+
"chromium",
|
|
38
|
+
"edge",
|
|
39
|
+
"brave",
|
|
30
40
|
"cdp",
|
|
31
41
|
"devtools-protocol",
|
|
32
42
|
"remote-debugging",
|
|
33
43
|
"headless",
|
|
34
44
|
"macos",
|
|
45
|
+
"windows",
|
|
35
46
|
"automation"
|
|
36
47
|
],
|
|
37
48
|
"license": "MIT",
|
|
38
49
|
"dependencies": {
|
|
39
50
|
"commander": "^12.1.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"playwright": "*"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"playwright": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
40
59
|
}
|
|
41
60
|
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getLauncher } from "./launcher.js";
|
|
2
|
+
|
|
3
|
+
/** CDP HTTP endpoint helper. */
|
|
4
|
+
function versionUrl(cdpPort) {
|
|
5
|
+
return `http://127.0.0.1:${cdpPort}/json/version`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Returns the CDP version payload if the browser is already listening, else null. */
|
|
9
|
+
export async function probeCdp(cdpPort) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(versionUrl(cdpPort), {
|
|
12
|
+
signal: AbortSignal.timeout(1000),
|
|
13
|
+
});
|
|
14
|
+
if (res.ok) return await res.json();
|
|
15
|
+
} catch {
|
|
16
|
+
// not up
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Poll the CDP endpoint until it responds or the timeout elapses. */
|
|
22
|
+
export async function waitForCdp(cdpPort, timeoutMs = 30_000) {
|
|
23
|
+
const deadline = Date.now() + timeoutMs;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
const version = await probeCdp(cdpPort);
|
|
26
|
+
if (version) return version;
|
|
27
|
+
await delay(250);
|
|
28
|
+
}
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for browser CDP ` +
|
|
31
|
+
`at ${versionUrl(cdpPort)}.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ensure the browser is running and CDP is reachable, launching it if needed.
|
|
37
|
+
* @returns {Promise<{ launched: boolean, version: object }>}
|
|
38
|
+
*/
|
|
39
|
+
export async function ensureBrowserRunning(config, { headless, timeoutMs } = {}) {
|
|
40
|
+
const existing = await probeCdp(config.cdpPort);
|
|
41
|
+
if (existing) return { launched: false, version: existing };
|
|
42
|
+
|
|
43
|
+
getLauncher().launch(config, { headless });
|
|
44
|
+
const version = await waitForCdp(config.cdpPort, timeoutMs);
|
|
45
|
+
return { launched: true, version };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function delay(ms) {
|
|
49
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
50
|
+
}
|
package/src/browsers.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registry of Chromium-family browsers that speak the DevTools Protocol via the
|
|
6
|
+
* shared `--remote-debugging-port` flag. Non-Chromium engines (Firefox's
|
|
7
|
+
* deprecated CDP, Safari's WebKit protocol) are intentionally excluded — they
|
|
8
|
+
* do not work with the {@link CdpClient} in this package.
|
|
9
|
+
*
|
|
10
|
+
* Each entry describes where the browser lives per platform:
|
|
11
|
+
* - macOS: the `.app` bundle path + the executable name inside it.
|
|
12
|
+
* - Windows: a list of candidate `.exe` paths (env vars like %ProgramFiles%
|
|
13
|
+
* are expanded at lookup time); the first that exists wins.
|
|
14
|
+
*/
|
|
15
|
+
const BROWSERS = Object.freeze({
|
|
16
|
+
chrome: {
|
|
17
|
+
label: "Google Chrome",
|
|
18
|
+
darwin: { app: "/Applications/Google Chrome.app", exec: "Google Chrome" },
|
|
19
|
+
win32: {
|
|
20
|
+
paths: [
|
|
21
|
+
"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe",
|
|
22
|
+
"%ProgramFiles(x86)%\\Google\\Chrome\\Application\\chrome.exe",
|
|
23
|
+
"%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
edge: {
|
|
28
|
+
label: "Microsoft Edge",
|
|
29
|
+
darwin: { app: "/Applications/Microsoft Edge.app", exec: "Microsoft Edge" },
|
|
30
|
+
win32: {
|
|
31
|
+
paths: [
|
|
32
|
+
"%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
33
|
+
"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe",
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
brave: {
|
|
38
|
+
label: "Brave",
|
|
39
|
+
darwin: { app: "/Applications/Brave Browser.app", exec: "Brave Browser" },
|
|
40
|
+
win32: {
|
|
41
|
+
paths: [
|
|
42
|
+
"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
43
|
+
"%ProgramFiles(x86)%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
44
|
+
"%LOCALAPPDATA%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
chromium: {
|
|
49
|
+
label: "Chromium",
|
|
50
|
+
darwin: { app: "/Applications/Chromium.app", exec: "Chromium" },
|
|
51
|
+
win32: {
|
|
52
|
+
paths: [
|
|
53
|
+
"%ProgramFiles%\\Chromium\\Application\\chrome.exe",
|
|
54
|
+
"%LOCALAPPDATA%\\Chromium\\Application\\chrome.exe",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
vivaldi: {
|
|
59
|
+
label: "Vivaldi",
|
|
60
|
+
darwin: { app: "/Applications/Vivaldi.app", exec: "Vivaldi" },
|
|
61
|
+
win32: {
|
|
62
|
+
paths: [
|
|
63
|
+
"%LOCALAPPDATA%\\Vivaldi\\Application\\vivaldi.exe",
|
|
64
|
+
"%ProgramFiles%\\Vivaldi\\Application\\vivaldi.exe",
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
opera: {
|
|
69
|
+
label: "Opera",
|
|
70
|
+
darwin: { app: "/Applications/Opera.app", exec: "Opera" },
|
|
71
|
+
win32: {
|
|
72
|
+
paths: [
|
|
73
|
+
"%LOCALAPPDATA%\\Programs\\Opera\\opera.exe",
|
|
74
|
+
"%ProgramFiles%\\Opera\\opera.exe",
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
arc: {
|
|
79
|
+
label: "Arc",
|
|
80
|
+
darwin: { app: "/Applications/Arc.app", exec: "Arc" },
|
|
81
|
+
win32: { paths: ["%LOCALAPPDATA%\\Arc\\app\\Arc.exe"] },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** The canonical default browser key. */
|
|
86
|
+
export const DEFAULT_BROWSER = "chrome";
|
|
87
|
+
|
|
88
|
+
/** All known browser keys, in display order. */
|
|
89
|
+
export function browserKeys() {
|
|
90
|
+
return Object.keys(BROWSERS);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Human label for a browser key (falls back to the key itself). */
|
|
94
|
+
export function browserLabel(key) {
|
|
95
|
+
return BROWSERS[key]?.label ?? key;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Expand Windows `%VAR%` placeholders from the environment. */
|
|
99
|
+
function expandWinPath(p) {
|
|
100
|
+
return p.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Pick the icon for a macOS `.app`. Prefers `app.icns` (Chrome's convention),
|
|
105
|
+
* otherwise the first `.icns` found in `Contents/Resources`.
|
|
106
|
+
*/
|
|
107
|
+
function resolveMacIcon(appPath) {
|
|
108
|
+
const resources = path.join(appPath, "Contents", "Resources");
|
|
109
|
+
const preferred = path.join(resources, "app.icns");
|
|
110
|
+
if (fs.existsSync(preferred)) return preferred;
|
|
111
|
+
try {
|
|
112
|
+
const icns = fs.readdirSync(resources).find((f) => f.endsWith(".icns"));
|
|
113
|
+
if (icns) return path.join(resources, icns);
|
|
114
|
+
} catch {
|
|
115
|
+
// Resources dir missing — no icon.
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a browser key to a concrete location on the current platform.
|
|
122
|
+
* @returns {{ key, label, path, icon: string|null, found: boolean }}
|
|
123
|
+
* `found` is whether the executable exists on disk. `path` is the best
|
|
124
|
+
* candidate even when not found, so callers can show a helpful message.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveBrowser(key, platform = process.platform) {
|
|
127
|
+
const def = BROWSERS[key];
|
|
128
|
+
if (!def) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Unknown browser "${key}". Known browsers: ${browserKeys().join(", ")}.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (platform === "darwin") {
|
|
135
|
+
const spec = def.darwin;
|
|
136
|
+
if (!spec) return notSupported(key, def, platform);
|
|
137
|
+
const exe = path.join(spec.app, "Contents", "MacOS", spec.exec);
|
|
138
|
+
const found = fs.existsSync(exe);
|
|
139
|
+
return {
|
|
140
|
+
key,
|
|
141
|
+
label: def.label,
|
|
142
|
+
path: exe,
|
|
143
|
+
icon: found ? resolveMacIcon(spec.app) : null,
|
|
144
|
+
found,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (platform === "win32") {
|
|
149
|
+
const spec = def.win32;
|
|
150
|
+
if (!spec) return notSupported(key, def, platform);
|
|
151
|
+
const candidates = spec.paths.map(expandWinPath).filter(Boolean);
|
|
152
|
+
const existing = candidates.find((p) => fs.existsSync(p));
|
|
153
|
+
return {
|
|
154
|
+
key,
|
|
155
|
+
label: def.label,
|
|
156
|
+
// On Windows the exe carries its own icon, so IconLocation = "<exe>,0".
|
|
157
|
+
path: existing ?? candidates[0] ?? "",
|
|
158
|
+
icon: existing ? `${existing},0` : null,
|
|
159
|
+
found: Boolean(existing),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return notSupported(key, def, platform);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function notSupported(key, def, platform) {
|
|
167
|
+
return { key, label: def.label, path: "", icon: null, found: false, platform };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** All browsers detected as installed on the current platform. */
|
|
171
|
+
export function detectInstalled(platform = process.platform) {
|
|
172
|
+
return browserKeys()
|
|
173
|
+
.map((key) => resolveBrowser(key, platform))
|
|
174
|
+
.filter((b) => b.found);
|
|
175
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -2,25 +2,30 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
|
|
4
4
|
import { DEFAULTS } from "./config.js";
|
|
5
|
+
import { browserKeys, DEFAULT_BROWSER } from "./browsers.js";
|
|
5
6
|
import {
|
|
6
7
|
setupCommand,
|
|
7
8
|
openCommand,
|
|
8
9
|
htmlCommand,
|
|
9
10
|
statusCommand,
|
|
10
11
|
stopCommand,
|
|
12
|
+
browsersCommand,
|
|
11
13
|
} from "./commands.js";
|
|
12
14
|
|
|
13
15
|
const require = createRequire(import.meta.url);
|
|
14
16
|
const { version } = require("../package.json");
|
|
15
17
|
|
|
18
|
+
const TARGET_LABEL = process.platform === "win32" ? "shortcut" : "app bundle";
|
|
19
|
+
|
|
16
20
|
export async function run(argv) {
|
|
17
21
|
const program = new Command();
|
|
18
22
|
|
|
19
23
|
program
|
|
20
24
|
.name("chrome-cdp")
|
|
21
25
|
.description(
|
|
22
|
-
"Set up and drive a Chrome DevTools Protocol instance on macOS
|
|
23
|
-
"dedicated
|
|
26
|
+
"Set up and drive a Chrome DevTools Protocol instance on macOS or Windows " +
|
|
27
|
+
"through a dedicated launcher (consistent Dock/taskbar icon). Works with " +
|
|
28
|
+
"any Chromium-based browser.",
|
|
24
29
|
)
|
|
25
30
|
.version(version, "-v, --version");
|
|
26
31
|
|
|
@@ -28,22 +33,36 @@ export async function run(argv) {
|
|
|
28
33
|
const withCommon = (cmd) =>
|
|
29
34
|
cmd
|
|
30
35
|
.option("--port <port>", `CDP port (default: ${DEFAULTS.cdpPort})`)
|
|
31
|
-
.option("-p, --profile <dir>", "
|
|
32
|
-
.option(
|
|
33
|
-
|
|
36
|
+
.option("-p, --profile <dir>", "Browser user-data-dir")
|
|
37
|
+
.option(
|
|
38
|
+
"-b, --browser <name>",
|
|
39
|
+
`Browser to use: ${browserKeys().join(", ")} (default: ${DEFAULT_BROWSER})`,
|
|
40
|
+
)
|
|
41
|
+
.option(
|
|
42
|
+
"--path <path>",
|
|
43
|
+
"Explicit browser executable path (overrides --browser)",
|
|
44
|
+
)
|
|
45
|
+
// Back-compat alias for --path.
|
|
46
|
+
.option("-c, --chrome <path>", "Alias for --path")
|
|
47
|
+
.option(
|
|
48
|
+
"--target <path>",
|
|
49
|
+
`Launcher ${TARGET_LABEL} path (default: ${DEFAULTS.launcherPath})`,
|
|
50
|
+
)
|
|
51
|
+
// Back-compat alias for --target.
|
|
52
|
+
.option("--bundle <path>", "Alias for --target");
|
|
34
53
|
|
|
35
54
|
withCommon(
|
|
36
55
|
program
|
|
37
56
|
.command("setup")
|
|
38
|
-
.description(
|
|
39
|
-
.option("-f, --force", "Rewrite the
|
|
57
|
+
.description(`Create or repair the ChromeCDP launcher (${TARGET_LABEL})`)
|
|
58
|
+
.option("-f, --force", "Rewrite the launcher even if it already exists"),
|
|
40
59
|
).action(setupCommand);
|
|
41
60
|
|
|
42
61
|
withCommon(
|
|
43
62
|
program
|
|
44
63
|
.command("open")
|
|
45
64
|
.argument("[url]", "URL to open after launch")
|
|
46
|
-
.description("Launch ChromeCDP via the
|
|
65
|
+
.description("Launch ChromeCDP via the launcher and optionally open a URL")
|
|
47
66
|
.option("--headless", "Run headless (no window/Dock)", false)
|
|
48
67
|
.option("-t, --timeout <seconds>", "Startup timeout in seconds", "30"),
|
|
49
68
|
).action(openCommand);
|
|
@@ -67,12 +86,17 @@ export async function run(argv) {
|
|
|
67
86
|
withCommon(
|
|
68
87
|
program
|
|
69
88
|
.command("status")
|
|
70
|
-
.description("Show
|
|
89
|
+
.description("Show launcher and CDP connection status"),
|
|
71
90
|
).action(statusCommand);
|
|
72
91
|
|
|
73
92
|
withCommon(
|
|
74
93
|
program.command("stop").description("Quit the running ChromeCDP instance"),
|
|
75
94
|
).action(stopCommand);
|
|
76
95
|
|
|
96
|
+
program
|
|
97
|
+
.command("browsers")
|
|
98
|
+
.description("List supported browsers and which are installed")
|
|
99
|
+
.action(browsersCommand);
|
|
100
|
+
|
|
77
101
|
await program.parseAsync(argv);
|
|
78
102
|
}
|
package/src/commands.js
CHANGED
|
@@ -3,17 +3,53 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { DEFAULTS, loadConfig, saveConfig, CONFIG_FILE } from "./config.js";
|
|
5
5
|
import { assertSupportedPlatform } from "./platform.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
resolveBrowser,
|
|
8
|
+
detectInstalled,
|
|
9
|
+
browserKeys,
|
|
10
|
+
browserLabel,
|
|
11
|
+
} from "./browsers.js";
|
|
12
|
+
import { getLauncher } from "./launcher.js";
|
|
13
|
+
import { ensureBrowserRunning, probeCdp } from "./browser.js";
|
|
8
14
|
import { openUrl, getPageHtml, closeBrowser } from "./cdp.js";
|
|
9
15
|
|
|
10
16
|
/** Merge persisted config with CLI overrides into a fully-resolved config. */
|
|
11
17
|
function resolveConfig(opts = {}) {
|
|
12
18
|
const stored = loadConfig();
|
|
13
19
|
const resolved = { ...stored };
|
|
14
|
-
|
|
20
|
+
|
|
21
|
+
const customPath = opts.path || opts.chrome;
|
|
22
|
+
|
|
23
|
+
// --browser switches the binary/icon to that browser's per-OS defaults.
|
|
24
|
+
if (opts.browser) {
|
|
25
|
+
const key = String(opts.browser).toLowerCase();
|
|
26
|
+
const r = resolveBrowser(key); // throws on unknown key
|
|
27
|
+
resolved.browser = key;
|
|
28
|
+
resolved.browserPath = r.path;
|
|
29
|
+
resolved.browserIcon = r.icon;
|
|
30
|
+
if (!r.found && !customPath) {
|
|
31
|
+
const installed = detectInstalled();
|
|
32
|
+
const hint = installed.length
|
|
33
|
+
? `Installed: ${installed.map((b) => b.key).join(", ")}.`
|
|
34
|
+
: "No supported browsers detected.";
|
|
35
|
+
throw new Error(
|
|
36
|
+
`${browserLabel(key)} not found for --browser ${key}. ${hint}\n` +
|
|
37
|
+
`Use --path to point at a custom binary.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// An explicit binary path always wins over browser resolution.
|
|
43
|
+
if (customPath) {
|
|
44
|
+
const abs = path.resolve(customPath);
|
|
45
|
+
resolved.browserPath = abs;
|
|
46
|
+
resolved.browserIcon =
|
|
47
|
+
process.platform === "win32" ? `${abs},0` : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
15
50
|
if (opts.profile) resolved.profileDir = path.resolve(opts.profile);
|
|
16
|
-
|
|
51
|
+
const target = opts.target || opts.bundle;
|
|
52
|
+
if (target) resolved.launcherPath = path.resolve(target);
|
|
17
53
|
if (opts.port !== undefined) resolved.cdpPort = parsePort(opts.port);
|
|
18
54
|
return resolved;
|
|
19
55
|
}
|
|
@@ -38,11 +74,15 @@ function normalizeUrl(url) {
|
|
|
38
74
|
return /^[a-z]+:\/\//i.test(url) ? url : `https://${url}`;
|
|
39
75
|
}
|
|
40
76
|
|
|
41
|
-
/** Create the
|
|
42
|
-
function
|
|
43
|
-
const
|
|
77
|
+
/** Create the launcher on demand (without forcing a rewrite of an existing one). */
|
|
78
|
+
function ensureLauncherReady(config) {
|
|
79
|
+
const launcher = getLauncher();
|
|
80
|
+
const { created } = launcher.ensure(config, { force: false });
|
|
44
81
|
if (created) {
|
|
45
|
-
console.error(
|
|
82
|
+
console.error(
|
|
83
|
+
`Created ${launcher.targetLabel.toLowerCase()} ${config.launcherPath} ` +
|
|
84
|
+
`(${browserLabel(config.browser)}).`,
|
|
85
|
+
);
|
|
46
86
|
}
|
|
47
87
|
}
|
|
48
88
|
|
|
@@ -50,11 +90,12 @@ function ensureBundleReady(config) {
|
|
|
50
90
|
export async function setupCommand(opts) {
|
|
51
91
|
assertSupportedPlatform();
|
|
52
92
|
const config = resolveConfig(opts);
|
|
53
|
-
const
|
|
93
|
+
const launcher = getLauncher();
|
|
94
|
+
const { created } = launcher.ensure(config, { force: true });
|
|
54
95
|
const file = saveConfig(config);
|
|
55
96
|
|
|
56
|
-
console.error(`${created ? "Created" : "Repaired"} ${config.
|
|
57
|
-
console.error(`
|
|
97
|
+
console.error(`${created ? "Created" : "Repaired"} ${config.launcherPath}`);
|
|
98
|
+
console.error(` Browser: ${browserLabel(config.browser)} (${config.browserPath})`);
|
|
58
99
|
console.error(` Profile: ${config.profileDir}`);
|
|
59
100
|
console.error(` CDP port: ${config.cdpPort}`);
|
|
60
101
|
console.error(`Config saved to ${file}`);
|
|
@@ -67,14 +108,9 @@ export async function openCommand(url, opts) {
|
|
|
67
108
|
const headless = Boolean(opts.headless);
|
|
68
109
|
const timeoutMs = parseTimeoutMs(opts.timeout);
|
|
69
110
|
|
|
70
|
-
|
|
111
|
+
ensureLauncherReady(config);
|
|
71
112
|
|
|
72
|
-
const { launched } = await
|
|
73
|
-
bundlePath: config.bundlePath,
|
|
74
|
-
cdpPort: config.cdpPort,
|
|
75
|
-
headless,
|
|
76
|
-
timeoutMs,
|
|
77
|
-
});
|
|
113
|
+
const { launched } = await ensureBrowserRunning(config, { headless, timeoutMs });
|
|
78
114
|
console.error(
|
|
79
115
|
launched
|
|
80
116
|
? `Launched ChromeCDP (${headless ? "headless" : "headed"}) on port ${config.cdpPort}.`
|
|
@@ -96,13 +132,8 @@ export async function htmlCommand(url, opts) {
|
|
|
96
132
|
const timeoutMs = parseTimeoutMs(opts.timeout);
|
|
97
133
|
const target = normalizeUrl(url);
|
|
98
134
|
|
|
99
|
-
|
|
100
|
-
await
|
|
101
|
-
bundlePath: config.bundlePath,
|
|
102
|
-
cdpPort: config.cdpPort,
|
|
103
|
-
headless,
|
|
104
|
-
timeoutMs,
|
|
105
|
-
});
|
|
135
|
+
ensureLauncherReady(config);
|
|
136
|
+
await ensureBrowserRunning(config, { headless, timeoutMs });
|
|
106
137
|
|
|
107
138
|
console.error(`Fetching HTML for ${target} ...`);
|
|
108
139
|
const html = await getPageHtml(config.cdpPort, target, {
|
|
@@ -123,11 +154,16 @@ export async function htmlCommand(url, opts) {
|
|
|
123
154
|
export async function statusCommand(opts) {
|
|
124
155
|
assertSupportedPlatform();
|
|
125
156
|
const config = resolveConfig(opts);
|
|
126
|
-
const
|
|
157
|
+
const launcher = getLauncher();
|
|
158
|
+
const exists = launcher.exists(config);
|
|
127
159
|
const version = await probeCdp(config.cdpPort);
|
|
128
160
|
|
|
129
161
|
console.log("ChromeCDP status");
|
|
130
|
-
console.log(`
|
|
162
|
+
console.log(` Browser: ${browserLabel(config.browser)} (${config.browserPath})`);
|
|
163
|
+
console.log(
|
|
164
|
+
` ${launcher.targetLabel}: ${exists ? "present" : "missing"} ` +
|
|
165
|
+
`(${launcher.targetPath(config)})`,
|
|
166
|
+
);
|
|
131
167
|
console.log(` Profile: ${config.profileDir}`);
|
|
132
168
|
console.log(` CDP port: ${config.cdpPort}`);
|
|
133
169
|
console.log(
|
|
@@ -151,4 +187,15 @@ export async function stopCommand(opts) {
|
|
|
151
187
|
console.error(`Asked ChromeCDP on port ${config.cdpPort} to quit.`);
|
|
152
188
|
}
|
|
153
189
|
|
|
190
|
+
// MARK: browsers
|
|
191
|
+
export async function browsersCommand() {
|
|
192
|
+
assertSupportedPlatform();
|
|
193
|
+
const installed = new Set(detectInstalled().map((b) => b.key));
|
|
194
|
+
console.log("Supported browsers (--browser <name>):");
|
|
195
|
+
for (const key of browserKeys()) {
|
|
196
|
+
const mark = installed.has(key) ? "✓ installed" : " not found";
|
|
197
|
+
console.log(` ${key.padEnd(10)} ${mark} ${browserLabel(key)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
154
201
|
export { DEFAULTS };
|
package/src/config.js
CHANGED
|
@@ -2,24 +2,71 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
import { DEFAULT_BROWSER, resolveBrowser } from "./browsers.js";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
8
|
+
* Canonical values for a ChromeCDP environment. The browser binary/icon and the
|
|
9
|
+
* launcher target (macOS `.app` bundle or Windows `.lnk`) depend on the OS and
|
|
10
|
+
* the chosen browser, so they're computed rather than hard-coded.
|
|
7
11
|
*
|
|
8
|
-
* These are baked into the
|
|
9
|
-
*
|
|
12
|
+
* These are baked into the launcher at setup time and persisted to a config
|
|
13
|
+
* file so every subcommand agrees on the same port/profile/binary.
|
|
10
14
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const BUNDLE_ID = "org.guocity.chrome-cdp";
|
|
16
|
+
const CDP_PORT = 9222;
|
|
17
|
+
|
|
18
|
+
/** Default launcher target path for the platform. */
|
|
19
|
+
function defaultLauncherPath(platform) {
|
|
20
|
+
if (platform === "win32") {
|
|
21
|
+
const appData =
|
|
22
|
+
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
23
|
+
return path.join(
|
|
24
|
+
appData,
|
|
25
|
+
"Microsoft",
|
|
26
|
+
"Windows",
|
|
27
|
+
"Start Menu",
|
|
28
|
+
"Programs",
|
|
29
|
+
"ChromeCDP.lnk",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return "/Applications/ChromeCDP.app";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Compute defaults for a given browser/platform (binary, icon, launcher). */
|
|
36
|
+
export function computeDefaults({
|
|
37
|
+
browser = DEFAULT_BROWSER,
|
|
38
|
+
platform = process.platform,
|
|
39
|
+
} = {}) {
|
|
40
|
+
const resolved = resolveBrowser(browser, platform);
|
|
41
|
+
return {
|
|
42
|
+
browser,
|
|
43
|
+
browserPath: resolved.path,
|
|
44
|
+
browserIcon: resolved.icon,
|
|
45
|
+
launcherPath: defaultLauncherPath(platform),
|
|
46
|
+
bundleId: BUNDLE_ID,
|
|
47
|
+
cdpPort: CDP_PORT,
|
|
48
|
+
profileDir: path.join(os.homedir(), ".chrome_cdp_profile"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Defaults for the current platform and default browser (for help text). */
|
|
53
|
+
export const DEFAULTS = Object.freeze(computeDefaults());
|
|
19
54
|
|
|
20
55
|
const CONFIG_DIR = path.join(os.homedir(), ".config", "chrome-cdp-manager");
|
|
21
56
|
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
22
57
|
|
|
58
|
+
/** Map legacy (pre-multi-browser) config keys onto the current schema. */
|
|
59
|
+
function migrateLegacy(stored) {
|
|
60
|
+
const out = { ...stored };
|
|
61
|
+
if (stored.chromePath && !stored.browserPath) out.browserPath = stored.chromePath;
|
|
62
|
+
if (stored.chromeIcon && !stored.browserIcon) out.browserIcon = stored.chromeIcon;
|
|
63
|
+
if (stored.bundlePath && !stored.launcherPath) out.launcherPath = stored.bundlePath;
|
|
64
|
+
delete out.chromePath;
|
|
65
|
+
delete out.chromeIcon;
|
|
66
|
+
delete out.bundlePath;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
23
70
|
/** Load persisted config, falling back to {@link DEFAULTS} for missing keys. */
|
|
24
71
|
export function loadConfig() {
|
|
25
72
|
let stored = {};
|
|
@@ -28,15 +75,16 @@ export function loadConfig() {
|
|
|
28
75
|
} catch {
|
|
29
76
|
// No config yet — first run.
|
|
30
77
|
}
|
|
31
|
-
return { ...DEFAULTS, ...stored };
|
|
78
|
+
return { ...DEFAULTS, ...migrateLegacy(stored) };
|
|
32
79
|
}
|
|
33
80
|
|
|
34
|
-
/** Persist the resolved config so other commands stay in sync with the
|
|
81
|
+
/** Persist the resolved config so other commands stay in sync with the launcher. */
|
|
35
82
|
export function saveConfig(config) {
|
|
36
83
|
const toStore = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
84
|
+
browser: config.browser,
|
|
85
|
+
browserPath: config.browserPath,
|
|
86
|
+
browserIcon: config.browserIcon,
|
|
87
|
+
launcherPath: config.launcherPath,
|
|
40
88
|
bundleId: config.bundleId,
|
|
41
89
|
cdpPort: config.cdpPort,
|
|
42
90
|
profileDir: config.profileDir,
|
package/src/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry point for chrome-cdp-manager.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the stable building blocks and adds {@link launch} — a one-call
|
|
5
|
+
* helper that resolves config, ensures the launcher exists, and starts the
|
|
6
|
+
* browser if it isn't already running. Stays dependency-free; the optional
|
|
7
|
+
* Playwright integration lives in the separate "chrome-cdp-manager/playwright"
|
|
8
|
+
* entry point so the core keeps its zero-heavy-dependency promise.
|
|
9
|
+
*/
|
|
10
|
+
import { loadConfig } from "./config.js";
|
|
11
|
+
import { getLauncher } from "./launcher.js";
|
|
12
|
+
import { ensureBrowserRunning } from "./browser.js";
|
|
13
|
+
|
|
14
|
+
export { CdpClient, openUrl, getPageHtml, closeBrowser } from "./cdp.js";
|
|
15
|
+
export { loadConfig, computeDefaults, DEFAULTS } from "./config.js";
|
|
16
|
+
export { getLauncher } from "./launcher.js";
|
|
17
|
+
export { ensureBrowserRunning, probeCdp, waitForCdp } from "./browser.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ensure a ChromeCDP instance is running and return its connection details.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {boolean} [opts.headless=false]
|
|
24
|
+
* @param {number} [opts.timeoutMs=30000] startup timeout in milliseconds.
|
|
25
|
+
* @param {object} [opts.config] overrides merged over the persisted config
|
|
26
|
+
* (e.g. `{ cdpPort, profileDir, browserPath }`).
|
|
27
|
+
* @returns {Promise<{ config: object, endpoint: string, launched: boolean, version: object }>}
|
|
28
|
+
*/
|
|
29
|
+
export async function launch({ headless = false, timeoutMs = 30_000, config: overrides } = {}) {
|
|
30
|
+
const config = { ...loadConfig(), ...overrides };
|
|
31
|
+
getLauncher().ensure(config, { force: false });
|
|
32
|
+
const { launched, version } = await ensureBrowserRunning(config, { headless, timeoutMs });
|
|
33
|
+
return {
|
|
34
|
+
config,
|
|
35
|
+
endpoint: `http://127.0.0.1:${config.cdpPort}`,
|
|
36
|
+
launched,
|
|
37
|
+
version,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/launcher.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as macBundle from "./launchers/macBundle.js";
|
|
2
|
+
import * as winShortcut from "./launchers/winShortcut.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Select the platform-specific launcher. Each implementation exposes the same
|
|
6
|
+
* surface: `ensure`, `exists`, `launch`, `targetPath`, `targetLabel`.
|
|
7
|
+
*
|
|
8
|
+
* Adding Linux later means dropping a `linuxDesktop.js` here that builds a
|
|
9
|
+
* `.desktop` entry — no other module needs to change.
|
|
10
|
+
*/
|
|
11
|
+
export function getLauncher(platform = process.platform) {
|
|
12
|
+
switch (platform) {
|
|
13
|
+
case "darwin":
|
|
14
|
+
return macBundle;
|
|
15
|
+
case "win32":
|
|
16
|
+
return winShortcut;
|
|
17
|
+
default:
|
|
18
|
+
throw new Error(`No launcher available for platform "${platform}".`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* macOS launcher: a real `.app` bundle whose executable is a small bash
|
|
7
|
+
* launcher. Headed launches go through `open <bundle>` so LaunchServices
|
|
8
|
+
* attaches the bundle's Dock icon; headless runs the launcher directly.
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The bash launcher baked into the bundle. It pins the browser path, CDP port
|
|
13
|
+
* and profile, and forwards extra args (`"$@"`) so callers can add flags such
|
|
14
|
+
* as `--headless=new`.
|
|
15
|
+
*/
|
|
16
|
+
function launcherScript({ browserPath, cdpPort, profileDir }) {
|
|
11
17
|
return `#!/usr/bin/env bash
|
|
12
18
|
# ChromeCDP launcher — generated by chrome-cdp-manager.
|
|
13
|
-
# Re-run \`
|
|
19
|
+
# Re-run \`chrome-cdp setup\` to regenerate this file.
|
|
14
20
|
set -euo pipefail
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
BROWSER=${shellQuote(browserPath)}
|
|
17
23
|
PORT=${shellQuote(String(cdpPort))}
|
|
18
24
|
PROFILE=${shellQuote(profileDir)}
|
|
19
25
|
|
|
20
|
-
exec arch -arm64 "$
|
|
26
|
+
exec arch -arm64 "$BROWSER" \\
|
|
21
27
|
--remote-debugging-port="$PORT" \\
|
|
22
28
|
--user-data-dir="$PROFILE" \\
|
|
23
29
|
"$@"
|
|
@@ -60,7 +66,7 @@ function shellQuote(value) {
|
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
/** Paths to the files that make up the bundle. */
|
|
63
|
-
|
|
69
|
+
function bundleLayout(bundlePath) {
|
|
64
70
|
const contents = path.join(bundlePath, "Contents");
|
|
65
71
|
return {
|
|
66
72
|
contents,
|
|
@@ -74,31 +80,37 @@ export function bundleLayout(bundlePath) {
|
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
/** Path shown in `status` for the launcher target. */
|
|
84
|
+
export function targetPath(config) {
|
|
85
|
+
return config.launcherPath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const targetLabel = "App bundle";
|
|
89
|
+
|
|
77
90
|
/** True when the bundle's executable launcher already exists. */
|
|
78
|
-
export function
|
|
79
|
-
return fs.existsSync(bundleLayout(
|
|
91
|
+
export function exists(config) {
|
|
92
|
+
return fs.existsSync(bundleLayout(config.launcherPath).executable);
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
/**
|
|
83
96
|
* Create or repair the ChromeCDP.app bundle so its Dock icon and launch
|
|
84
97
|
* behaviour are correct and consistent.
|
|
85
|
-
*
|
|
86
|
-
* @returns {{ created: boolean }} whether the bundle existed beforehand.
|
|
98
|
+
* @returns {{ created: boolean }} whether the bundle was newly created.
|
|
87
99
|
*/
|
|
88
|
-
export function
|
|
89
|
-
const {
|
|
100
|
+
export function ensure(config, { force = false } = {}) {
|
|
101
|
+
const { launcherPath, browserPath, browserIcon, cdpPort, profileDir, bundleId } =
|
|
90
102
|
config;
|
|
91
|
-
const layout = bundleLayout(
|
|
92
|
-
const existed =
|
|
103
|
+
const layout = bundleLayout(launcherPath);
|
|
104
|
+
const existed = exists(config);
|
|
93
105
|
|
|
94
106
|
if (existed && !force) {
|
|
95
107
|
return { created: false };
|
|
96
108
|
}
|
|
97
109
|
|
|
98
|
-
if (!fs.existsSync(
|
|
110
|
+
if (!fs.existsSync(browserPath)) {
|
|
99
111
|
throw new Error(
|
|
100
|
-
`
|
|
101
|
-
`Install
|
|
112
|
+
`Browser not found at:\n ${browserPath}\n` +
|
|
113
|
+
`Install it, pick another with --browser, or pass --path <path>.`,
|
|
102
114
|
);
|
|
103
115
|
}
|
|
104
116
|
|
|
@@ -108,14 +120,14 @@ export function ensureAppBundle(config, { force = false } = {}) {
|
|
|
108
120
|
|
|
109
121
|
fs.writeFileSync(
|
|
110
122
|
layout.executable,
|
|
111
|
-
launcherScript({
|
|
123
|
+
launcherScript({ browserPath, cdpPort, profileDir }),
|
|
112
124
|
{ mode: 0o755 },
|
|
113
125
|
);
|
|
114
126
|
fs.writeFileSync(layout.plist, infoPlist({ bundleId }));
|
|
115
127
|
|
|
116
|
-
// Use
|
|
117
|
-
if (fs.existsSync(
|
|
118
|
-
fs.copyFileSync(
|
|
128
|
+
// Use the browser's own icon so the Dock entry is recognisable.
|
|
129
|
+
if (browserIcon && fs.existsSync(browserIcon)) {
|
|
130
|
+
fs.copyFileSync(browserIcon, layout.icon);
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
// Remove the legacy classic-Mac "Icon\r" resource-fork file if present;
|
|
@@ -130,17 +142,37 @@ export function ensureAppBundle(config, { force = false } = {}) {
|
|
|
130
142
|
} catch (error) {
|
|
131
143
|
if (error.code === "EACCES" || error.code === "EPERM") {
|
|
132
144
|
throw new Error(
|
|
133
|
-
`Permission denied writing to ${
|
|
134
|
-
`Try: sudo
|
|
145
|
+
`Permission denied writing to ${launcherPath}.\n` +
|
|
146
|
+
`Try: sudo chrome-cdp setup`,
|
|
135
147
|
);
|
|
136
148
|
}
|
|
137
149
|
throw error;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
refreshLaunchServices(
|
|
152
|
+
refreshLaunchServices(launcherPath);
|
|
141
153
|
return { created: !existed };
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Launch the browser through the bundle. Headed goes via `open <bundle>` so
|
|
158
|
+
* LaunchServices attaches the Dock icon; headless runs the launcher directly.
|
|
159
|
+
*/
|
|
160
|
+
export function launch(config, { headless } = {}) {
|
|
161
|
+
if (headless) {
|
|
162
|
+
const { executable } = bundleLayout(config.launcherPath);
|
|
163
|
+
const child = spawn(executable, ["--headless=new"], {
|
|
164
|
+
detached: true,
|
|
165
|
+
stdio: "ignore",
|
|
166
|
+
});
|
|
167
|
+
child.unref();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// `open` returns immediately; readiness is awaited via waitForCdp().
|
|
172
|
+
const child = spawn("open", [config.launcherPath], { stdio: "ignore" });
|
|
173
|
+
child.unref();
|
|
174
|
+
}
|
|
175
|
+
|
|
144
176
|
/**
|
|
145
177
|
* Register the bundle with LaunchServices and bump its mtime so Finder/Dock
|
|
146
178
|
* pick up the (possibly new) icon without a logout.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Windows launcher: a Start Menu `.lnk` shortcut with a custom icon and the CDP
|
|
8
|
+
* flags baked into its arguments. Headed launches go through the shortcut so
|
|
9
|
+
* Windows uses its icon/identity; headless runs the browser exe directly.
|
|
10
|
+
*
|
|
11
|
+
* The shortcut is created with PowerShell's built-in `WScript.Shell` COM object
|
|
12
|
+
* — no extra npm dependency, no compiled addon.
|
|
13
|
+
*
|
|
14
|
+
* Taskbar note: Chromium derives its AppUserModelID (which controls taskbar
|
|
15
|
+
* grouping and pinning) from `--user-data-dir`, so the dedicated profile this
|
|
16
|
+
* tool uses already yields a separate, pinnable taskbar entry.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The browser arguments baked into the shortcut. */
|
|
20
|
+
function shortcutArgs({ cdpPort, profileDir }) {
|
|
21
|
+
return (
|
|
22
|
+
`--remote-debugging-port=${cdpPort} ` +
|
|
23
|
+
`--user-data-dir="${profileDir}"`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Quote a value as a PowerShell single-quoted literal. */
|
|
28
|
+
function psLiteral(value) {
|
|
29
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Path shown in `status` for the launcher target. */
|
|
33
|
+
export function targetPath(config) {
|
|
34
|
+
return config.launcherPath;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const targetLabel = "Shortcut";
|
|
38
|
+
|
|
39
|
+
/** True when the shortcut file already exists. */
|
|
40
|
+
export function exists(config) {
|
|
41
|
+
return fs.existsSync(config.launcherPath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create or repair the ChromeCDP shortcut.
|
|
46
|
+
* @returns {{ created: boolean }} whether the shortcut was newly created.
|
|
47
|
+
*/
|
|
48
|
+
export function ensure(config, { force = false } = {}) {
|
|
49
|
+
const { launcherPath, browserPath, browserIcon, cdpPort, profileDir } = config;
|
|
50
|
+
const existed = exists(config);
|
|
51
|
+
|
|
52
|
+
if (existed && !force) {
|
|
53
|
+
return { created: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!fs.existsSync(browserPath)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Browser not found at:\n ${browserPath}\n` +
|
|
59
|
+
`Install it, pick another with --browser, or pass --path <path>.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fs.mkdirSync(path.dirname(launcherPath), { recursive: true });
|
|
64
|
+
|
|
65
|
+
// Default the icon to the browser exe itself (it carries its own icon).
|
|
66
|
+
const iconLocation = browserIcon || `${browserPath},0`;
|
|
67
|
+
const script = [
|
|
68
|
+
"$ErrorActionPreference = 'Stop'",
|
|
69
|
+
"$WshShell = New-Object -ComObject WScript.Shell",
|
|
70
|
+
`$s = $WshShell.CreateShortcut(${psLiteral(launcherPath)})`,
|
|
71
|
+
`$s.TargetPath = ${psLiteral(browserPath)}`,
|
|
72
|
+
`$s.Arguments = ${psLiteral(shortcutArgs({ cdpPort, profileDir }))}`,
|
|
73
|
+
`$s.IconLocation = ${psLiteral(iconLocation)}`,
|
|
74
|
+
`$s.WorkingDirectory = ${psLiteral(path.dirname(browserPath))}`,
|
|
75
|
+
"$s.Description = 'ChromeCDP — CDP-enabled browser (chrome-cdp-manager)'",
|
|
76
|
+
"$s.Save()",
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
runPowerShell(script, launcherPath);
|
|
80
|
+
return { created: !existed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Launch the browser. Headed goes through the shortcut so the taskbar uses its
|
|
85
|
+
* icon; headless runs the exe directly with the same flags + `--headless=new`.
|
|
86
|
+
*/
|
|
87
|
+
export function launch(config, { headless } = {}) {
|
|
88
|
+
if (headless) {
|
|
89
|
+
const child = spawn(
|
|
90
|
+
config.browserPath,
|
|
91
|
+
[
|
|
92
|
+
`--remote-debugging-port=${config.cdpPort}`,
|
|
93
|
+
`--user-data-dir=${config.profileDir}`,
|
|
94
|
+
"--headless=new",
|
|
95
|
+
],
|
|
96
|
+
{ detached: true, stdio: "ignore" },
|
|
97
|
+
);
|
|
98
|
+
child.unref();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// `start "" "<lnk>"` resolves the shortcut so Windows uses its identity/icon.
|
|
103
|
+
const child = spawn("cmd", ["/c", "start", "", config.launcherPath], {
|
|
104
|
+
detached: true,
|
|
105
|
+
stdio: "ignore",
|
|
106
|
+
});
|
|
107
|
+
child.unref();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Run a PowerShell script via a temp `.ps1` file (avoids -Command quoting). */
|
|
111
|
+
function runPowerShell(script, launcherPath) {
|
|
112
|
+
const scriptPath = path.join(
|
|
113
|
+
os.tmpdir(),
|
|
114
|
+
`chrome-cdp-shortcut-${process.pid}.ps1`,
|
|
115
|
+
);
|
|
116
|
+
try {
|
|
117
|
+
fs.writeFileSync(scriptPath, script);
|
|
118
|
+
execFileSync(
|
|
119
|
+
"powershell.exe",
|
|
120
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath],
|
|
121
|
+
{ stdio: "ignore" },
|
|
122
|
+
);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Failed to create the shortcut at ${launcherPath} via PowerShell.\n` +
|
|
126
|
+
`${error.message}`,
|
|
127
|
+
);
|
|
128
|
+
} finally {
|
|
129
|
+
try {
|
|
130
|
+
fs.rmSync(scriptPath, { force: true });
|
|
131
|
+
} catch {
|
|
132
|
+
// best effort
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/platform.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
|
|
3
|
+
const SUPPORTED = new Set(["darwin", "win32"]);
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
|
-
* This tool builds a macOS `.app` bundle
|
|
5
|
-
* `
|
|
6
|
+
* This tool builds a platform-native launcher (a macOS `.app` bundle or a
|
|
7
|
+
* Windows `.lnk` shortcut). Only those two platforms are supported today.
|
|
6
8
|
*/
|
|
7
9
|
export function assertSupportedPlatform() {
|
|
8
|
-
if (process.platform
|
|
10
|
+
if (!SUPPORTED.has(process.platform)) {
|
|
9
11
|
throw new Error(
|
|
10
|
-
`chrome-cdp-manager
|
|
12
|
+
`chrome-cdp-manager supports macOS and Windows ` +
|
|
13
|
+
`(found "${process.platform}").`,
|
|
11
14
|
);
|
|
12
15
|
}
|
|
13
|
-
if (os.arch() !== "arm64") {
|
|
14
|
-
// Not fatal — the launcher uses `arch -arm64`, which is a no-op on
|
|
15
|
-
// and an error on Intel. Warn rather than block in case of Rosetta
|
|
16
|
+
if (process.platform === "darwin" && os.arch() !== "arm64") {
|
|
17
|
+
// Not fatal — the macOS launcher uses `arch -arm64`, which is a no-op on
|
|
18
|
+
// arm64 and an error on Intel. Warn rather than block in case of Rosetta.
|
|
16
19
|
process.emitWarning(
|
|
17
|
-
`Detected CPU arch "${os.arch()}". The launcher uses \`arch -arm64\`; ` +
|
|
20
|
+
`Detected CPU arch "${os.arch()}". The macOS launcher uses \`arch -arm64\`; ` +
|
|
18
21
|
`this is intended for Apple Silicon Macs.`,
|
|
19
22
|
);
|
|
20
23
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional Playwright bridge for chrome-cdp-manager.
|
|
3
|
+
*
|
|
4
|
+
* Loaded only when you opt in via "chrome-cdp-manager/playwright", so the core
|
|
5
|
+
* package stays free of heavy dependencies. `playwright` is an OPTIONAL peer
|
|
6
|
+
* dependency: install it yourself, or inject it through the `chromium` option
|
|
7
|
+
* (handy for zero-install / npx setups where this module can't resolve
|
|
8
|
+
* Playwright on its own).
|
|
9
|
+
*/
|
|
10
|
+
import { launch } from "./index.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Launch (or attach to) ChromeCDP and return a ready-to-drive Playwright page.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
* @param {boolean} [opts.headless=false]
|
|
17
|
+
* @param {(url: string) => boolean} [opts.match] pick an existing tab by URL;
|
|
18
|
+
* falls back to the first tab, then a fresh one.
|
|
19
|
+
* @param {number} [opts.timeoutMs=30000]
|
|
20
|
+
* @param {object} [opts.config] config overrides (see {@link launch}).
|
|
21
|
+
* @param {object} [opts.chromium] a Playwright `chromium` object to use
|
|
22
|
+
* instead of `import("playwright")` — for injected/zero-install
|
|
23
|
+
* setups.
|
|
24
|
+
* @returns {Promise<{ browser, context, page, config } & AsyncDisposable>}
|
|
25
|
+
* Dispose (or `await using`) detaches the CDP channel; the
|
|
26
|
+
* launcher-managed browser keeps running.
|
|
27
|
+
*/
|
|
28
|
+
export async function connect({ headless = false, match, timeoutMs = 30_000, config: overrides, chromium } = {}) {
|
|
29
|
+
const { config, endpoint } = await launch({ headless, timeoutMs, config: overrides });
|
|
30
|
+
|
|
31
|
+
const pwChromium = chromium ?? (await importPlaywright()).chromium;
|
|
32
|
+
const browser = await pwChromium.connectOverCDP(endpoint);
|
|
33
|
+
const context = browser.contexts()[0] ?? (await browser.newContext());
|
|
34
|
+
const page =
|
|
35
|
+
(match && context.pages().find((p) => match(p.url()))) ??
|
|
36
|
+
context.pages()[0] ??
|
|
37
|
+
(await context.newPage());
|
|
38
|
+
|
|
39
|
+
if (headless) await unmaskHeadlessUserAgent(context, page);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
browser,
|
|
43
|
+
context,
|
|
44
|
+
page,
|
|
45
|
+
config,
|
|
46
|
+
async [Symbol.asyncDispose]() {
|
|
47
|
+
await browser.close();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function importPlaywright() {
|
|
53
|
+
try {
|
|
54
|
+
return await import("playwright");
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"Playwright not found. Install it (`npm i playwright`) or pass `{ chromium }` to connect().",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Drop "HeadlessChrome" from the UA so sites don't treat the session as headless. */
|
|
63
|
+
async function unmaskHeadlessUserAgent(context, page) {
|
|
64
|
+
const userAgent = await page.evaluate(() => navigator.userAgent).catch(() => "");
|
|
65
|
+
const clean = userAgent.replace(/HeadlessChrome/g, "Chrome");
|
|
66
|
+
if (!clean || clean === userAgent) return;
|
|
67
|
+
|
|
68
|
+
await context.setExtraHTTPHeaders({ "user-agent": clean });
|
|
69
|
+
const session = await context.newCDPSession(page);
|
|
70
|
+
await session.send("Network.setUserAgentOverride", { userAgent: clean });
|
|
71
|
+
}
|
package/src/chrome.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { bundleLayout } from "./appBundle.js";
|
|
3
|
-
|
|
4
|
-
/** CDP HTTP endpoint helper. */
|
|
5
|
-
function versionUrl(cdpPort) {
|
|
6
|
-
return `http://127.0.0.1:${cdpPort}/json/version`;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Returns the CDP version payload if Chrome is already listening, else null. */
|
|
10
|
-
export async function probeCdp(cdpPort) {
|
|
11
|
-
try {
|
|
12
|
-
const res = await fetch(versionUrl(cdpPort), {
|
|
13
|
-
signal: AbortSignal.timeout(1000),
|
|
14
|
-
});
|
|
15
|
-
if (res.ok) return await res.json();
|
|
16
|
-
} catch {
|
|
17
|
-
// not up
|
|
18
|
-
}
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Launch Chrome through the ChromeCDP.app bundle.
|
|
24
|
-
*
|
|
25
|
-
* Headed mode goes through `open <bundle>` so LaunchServices attaches the
|
|
26
|
-
* ChromeCDP Dock icon. Headless mode runs the bundle's launcher directly
|
|
27
|
-
* (there is no Dock presence to keep consistent) and forwards `--headless=new`.
|
|
28
|
-
*/
|
|
29
|
-
export function launchChrome({ bundlePath, headless }) {
|
|
30
|
-
if (headless) {
|
|
31
|
-
const { executable } = bundleLayout(bundlePath);
|
|
32
|
-
const child = spawn(executable, ["--headless=new"], {
|
|
33
|
-
detached: true,
|
|
34
|
-
stdio: "ignore",
|
|
35
|
-
});
|
|
36
|
-
child.unref();
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// `open` returns immediately; readiness is awaited via waitForCdp().
|
|
41
|
-
const child = spawn("open", [bundlePath], { stdio: "ignore" });
|
|
42
|
-
child.unref();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Poll the CDP endpoint until it responds or the timeout elapses. */
|
|
46
|
-
export async function waitForCdp(cdpPort, timeoutMs = 30_000) {
|
|
47
|
-
const deadline = Date.now() + timeoutMs;
|
|
48
|
-
while (Date.now() < deadline) {
|
|
49
|
-
const version = await probeCdp(cdpPort);
|
|
50
|
-
if (version) return version;
|
|
51
|
-
await delay(250);
|
|
52
|
-
}
|
|
53
|
-
throw new Error(
|
|
54
|
-
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for Chrome CDP ` +
|
|
55
|
-
`at ${versionUrl(cdpPort)}.`,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Ensure Chrome is running and CDP is reachable, launching it if needed.
|
|
61
|
-
* @returns {Promise<{ launched: boolean, version: object }>}
|
|
62
|
-
*/
|
|
63
|
-
export async function ensureChromeRunning({ bundlePath, cdpPort, headless, timeoutMs }) {
|
|
64
|
-
const existing = await probeCdp(cdpPort);
|
|
65
|
-
if (existing) return { launched: false, version: existing };
|
|
66
|
-
|
|
67
|
-
launchChrome({ bundlePath, headless });
|
|
68
|
-
const version = await waitForCdp(cdpPort, timeoutMs);
|
|
69
|
-
return { launched: true, version };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function delay(ms) {
|
|
73
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
74
|
-
}
|