chrome-cdp-manager 1.0.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/LICENSE +21 -0
- package/README.md +90 -0
- package/bin/cli.js +7 -0
- package/package.json +41 -0
- package/src/appBundle.js +160 -0
- package/src/cdp.js +168 -0
- package/src/chrome.js +74 -0
- package/src/cli.js +78 -0
- package/src/commands.js +154 -0
- package/src/config.js +49 -0
- package/src/platform.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# chrome-cdp-manager
|
|
2
|
+
|
|
3
|
+
Set up and drive a **Chrome DevTools Protocol (CDP)** instance on macOS through a
|
|
4
|
+
dedicated `ChromeCDP.app` bundle, so launches always go through the same app and
|
|
5
|
+
the **Dock icon stays consistent**.
|
|
6
|
+
|
|
7
|
+
- Builds a real macOS `.app` bundle (`/Applications/ChromeCDP.app`) with a proper
|
|
8
|
+
`Info.plist` and Chrome's own icon — created automatically on first use.
|
|
9
|
+
- Launches Chrome headed via `open <bundle>` (so LaunchServices attaches the
|
|
10
|
+
ChromeCDP Dock icon) or headless via `--headless=new`.
|
|
11
|
+
- Connects over CDP with **zero heavy dependencies** — uses Node's built-in
|
|
12
|
+
`fetch` and `WebSocket` (no Playwright/Puppeteer, no browser download).
|
|
13
|
+
|
|
14
|
+
> Requires macOS on Apple Silicon, Node.js ≥ 22, and Google Chrome installed.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
The package is published as `chrome-cdp-manager`; the command it installs is
|
|
19
|
+
**`chrome-cdp`**.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Run without installing — invoke via the package name
|
|
23
|
+
npx chrome-cdp-manager setup
|
|
24
|
+
|
|
25
|
+
# Or install once, then use the `chrome-cdp` command
|
|
26
|
+
npm install -g chrome-cdp-manager
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Create / repair ChromeCDP.app and save defaults (port 9222, profile ~/.chrome_cdp_profile)
|
|
31
|
+
chrome-cdp setup
|
|
32
|
+
|
|
33
|
+
# Launch ChromeCDP (Dock icon = ChromeCDP) and open a page
|
|
34
|
+
chrome-cdp open https://example.com
|
|
35
|
+
|
|
36
|
+
# Same, headless (no window/Dock)
|
|
37
|
+
chrome-cdp open https://example.com --headless
|
|
38
|
+
|
|
39
|
+
# Fetch a page's rendered HTML over CDP (headless by default)
|
|
40
|
+
chrome-cdp html example.com -o page.html
|
|
41
|
+
|
|
42
|
+
# Inspect state
|
|
43
|
+
chrome-cdp status
|
|
44
|
+
|
|
45
|
+
# Quit the running instance
|
|
46
|
+
chrome-cdp stop
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> Tip: with `npx`, run it by package name — `npx chrome-cdp-manager <command>` —
|
|
50
|
+
> since `npx chrome-cdp` would try to fetch an unrelated `chrome-cdp` package.
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
| Command | Description |
|
|
55
|
+
| ------------------ | --------------------------------------------------------------- |
|
|
56
|
+
| `setup` | Create or repair `ChromeCDP.app` (icon, `Info.plist`, launcher) |
|
|
57
|
+
| `open [url]` | Launch via the bundle, optionally open a URL; leaves it running |
|
|
58
|
+
| `html <url>` | Navigate and print/save the page's serialized HTML |
|
|
59
|
+
| `status` | Show bundle presence and CDP connection state |
|
|
60
|
+
| `stop` | Ask the running ChromeCDP instance to quit |
|
|
61
|
+
|
|
62
|
+
## Common options
|
|
63
|
+
|
|
64
|
+
| Option | Description |
|
|
65
|
+
| ----------------------- | ------------------------------------------------------ |
|
|
66
|
+
| `--port <port>` | CDP port (default `9222`) |
|
|
67
|
+
| `-p, --profile <dir>` | Chrome `user-data-dir` (default `~/.chrome_cdp_profile`)|
|
|
68
|
+
| `-c, --chrome <path>` | Google Chrome executable |
|
|
69
|
+
| `--bundle <path>` | App bundle location (default `/Applications/ChromeCDP.app`) |
|
|
70
|
+
| `-t, --timeout <secs>` | Startup / load timeout (`open`, `html`) |
|
|
71
|
+
|
|
72
|
+
Port, profile, Chrome path and bundle path are baked into the app bundle and
|
|
73
|
+
persisted (`~/.config/chrome-cdp-manager/config.json`) at `setup` time so every
|
|
74
|
+
command agrees on the same environment. Re-run `setup --force` after changing
|
|
75
|
+
them.
|
|
76
|
+
|
|
77
|
+
## How the Dock icon stays consistent
|
|
78
|
+
|
|
79
|
+
The bundle's executable is a small bash launcher:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
exec arch -arm64 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
|
83
|
+
--remote-debugging-port=9222 \
|
|
84
|
+
--user-data-dir="$HOME/.chrome_cdp_profile" \
|
|
85
|
+
"$@"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Because headed launches go through `open /Applications/ChromeCDP.app`,
|
|
89
|
+
LaunchServices runs Chrome under the ChromeCDP bundle identity — so you get the
|
|
90
|
+
ChromeCDP Dock icon every time instead of a generic/duplicated Chrome entry.
|
package/bin/cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chrome-cdp-manager",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Set up and drive a Chrome DevTools Protocol (CDP) instance on macOS through a dedicated ChromeCDP.app bundle, so the Dock icon stays consistent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"chrome-cdp": "bin/cli.js",
|
|
8
|
+
"chrome-cdp-manager": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"os": [
|
|
20
|
+
"darwin"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "node bin/cli.js",
|
|
24
|
+
"check": "node --check bin/cli.js && node --check src/cli.js",
|
|
25
|
+
"prepublishOnly": "npm run check",
|
|
26
|
+
"release": "bash scripts/publish.sh"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"chrome",
|
|
30
|
+
"cdp",
|
|
31
|
+
"devtools-protocol",
|
|
32
|
+
"remote-debugging",
|
|
33
|
+
"headless",
|
|
34
|
+
"macos",
|
|
35
|
+
"automation"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"commander": "^12.1.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/appBundle.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the bash launcher that the bundle executes. It bakes in the canonical
|
|
7
|
+
* Chrome path, CDP port and profile, and forwards any extra args (`"$@"`) so
|
|
8
|
+
* callers can append flags such as `--headless=new`.
|
|
9
|
+
*/
|
|
10
|
+
function launcherScript({ chromePath, cdpPort, profileDir }) {
|
|
11
|
+
return `#!/usr/bin/env bash
|
|
12
|
+
# ChromeCDP launcher — generated by chrome-cdp-manager.
|
|
13
|
+
# Re-run \`npx chrome-cdp-manager setup\` to regenerate this file.
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
CHROME=${shellQuote(chromePath)}
|
|
17
|
+
PORT=${shellQuote(String(cdpPort))}
|
|
18
|
+
PROFILE=${shellQuote(profileDir)}
|
|
19
|
+
|
|
20
|
+
exec arch -arm64 "$CHROME" \\
|
|
21
|
+
--remote-debugging-port="$PORT" \\
|
|
22
|
+
--user-data-dir="$PROFILE" \\
|
|
23
|
+
"$@"
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function infoPlist({ bundleId, version = "1.0.0" }) {
|
|
28
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
29
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
30
|
+
<plist version="1.0">
|
|
31
|
+
<dict>
|
|
32
|
+
\t<key>CFBundleName</key>
|
|
33
|
+
\t<string>ChromeCDP</string>
|
|
34
|
+
\t<key>CFBundleDisplayName</key>
|
|
35
|
+
\t<string>ChromeCDP</string>
|
|
36
|
+
\t<key>CFBundleIdentifier</key>
|
|
37
|
+
\t<string>${bundleId}</string>
|
|
38
|
+
\t<key>CFBundleVersion</key>
|
|
39
|
+
\t<string>${version}</string>
|
|
40
|
+
\t<key>CFBundleShortVersionString</key>
|
|
41
|
+
\t<string>${version}</string>
|
|
42
|
+
\t<key>CFBundlePackageType</key>
|
|
43
|
+
\t<string>APPL</string>
|
|
44
|
+
\t<key>CFBundleExecutable</key>
|
|
45
|
+
\t<string>ChromeCDP</string>
|
|
46
|
+
\t<key>CFBundleIconFile</key>
|
|
47
|
+
\t<string>AppIcon</string>
|
|
48
|
+
\t<key>LSMinimumSystemVersion</key>
|
|
49
|
+
\t<string>11.0</string>
|
|
50
|
+
\t<key>NSHighResolutionCapable</key>
|
|
51
|
+
\t<true/>
|
|
52
|
+
</dict>
|
|
53
|
+
</plist>
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Quote a value for safe single-quoted embedding in bash. */
|
|
58
|
+
function shellQuote(value) {
|
|
59
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Paths to the files that make up the bundle. */
|
|
63
|
+
export function bundleLayout(bundlePath) {
|
|
64
|
+
const contents = path.join(bundlePath, "Contents");
|
|
65
|
+
return {
|
|
66
|
+
contents,
|
|
67
|
+
macOS: path.join(contents, "MacOS"),
|
|
68
|
+
resources: path.join(contents, "Resources"),
|
|
69
|
+
executable: path.join(contents, "MacOS", "ChromeCDP"),
|
|
70
|
+
plist: path.join(contents, "Info.plist"),
|
|
71
|
+
icon: path.join(contents, "Resources", "AppIcon.icns"),
|
|
72
|
+
legacyIcon: path.join(bundlePath, "Icon\r"),
|
|
73
|
+
legacyIconNoCR: path.join(bundlePath, "Icon"),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when the bundle's executable launcher already exists. */
|
|
78
|
+
export function bundleExists(bundlePath) {
|
|
79
|
+
return fs.existsSync(bundleLayout(bundlePath).executable);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create or repair the ChromeCDP.app bundle so its Dock icon and launch
|
|
84
|
+
* behaviour are correct and consistent.
|
|
85
|
+
*
|
|
86
|
+
* @returns {{ created: boolean }} whether the bundle existed beforehand.
|
|
87
|
+
*/
|
|
88
|
+
export function ensureAppBundle(config, { force = false } = {}) {
|
|
89
|
+
const { bundlePath, chromePath, chromeIcon, cdpPort, profileDir, bundleId } =
|
|
90
|
+
config;
|
|
91
|
+
const layout = bundleLayout(bundlePath);
|
|
92
|
+
const existed = bundleExists(bundlePath);
|
|
93
|
+
|
|
94
|
+
if (existed && !force) {
|
|
95
|
+
return { created: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!fs.existsSync(chromePath)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Google Chrome not found at:\n ${chromePath}\n` +
|
|
101
|
+
`Install Chrome or pass --chrome <path>.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
fs.mkdirSync(layout.macOS, { recursive: true });
|
|
107
|
+
fs.mkdirSync(layout.resources, { recursive: true });
|
|
108
|
+
|
|
109
|
+
fs.writeFileSync(
|
|
110
|
+
layout.executable,
|
|
111
|
+
launcherScript({ chromePath, cdpPort, profileDir }),
|
|
112
|
+
{ mode: 0o755 },
|
|
113
|
+
);
|
|
114
|
+
fs.writeFileSync(layout.plist, infoPlist({ bundleId }));
|
|
115
|
+
|
|
116
|
+
// Use Chrome's own icon so the Dock entry is recognisable.
|
|
117
|
+
if (fs.existsSync(chromeIcon)) {
|
|
118
|
+
fs.copyFileSync(chromeIcon, layout.icon);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Remove the legacy classic-Mac "Icon\r" resource-fork file if present;
|
|
122
|
+
// modern bundles render their icon from Info.plist + Resources/*.icns.
|
|
123
|
+
for (const stale of [layout.legacyIcon, layout.legacyIconNoCR]) {
|
|
124
|
+
try {
|
|
125
|
+
fs.rmSync(stale, { force: true });
|
|
126
|
+
} catch {
|
|
127
|
+
// best effort
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error.code === "EACCES" || error.code === "EPERM") {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Permission denied writing to ${bundlePath}.\n` +
|
|
134
|
+
`Try: sudo npx chrome-cdp-manager setup`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
refreshLaunchServices(bundlePath);
|
|
141
|
+
return { created: !existed };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Register the bundle with LaunchServices and bump its mtime so Finder/Dock
|
|
146
|
+
* pick up the (possibly new) icon without a logout.
|
|
147
|
+
*/
|
|
148
|
+
function refreshLaunchServices(bundlePath) {
|
|
149
|
+
const lsregister =
|
|
150
|
+
"/System/Library/Frameworks/CoreServices.framework/Frameworks/" +
|
|
151
|
+
"LaunchServices.framework/Support/lsregister";
|
|
152
|
+
try {
|
|
153
|
+
if (fs.existsSync(lsregister)) {
|
|
154
|
+
execFileSync(lsregister, ["-f", bundlePath], { stdio: "ignore" });
|
|
155
|
+
}
|
|
156
|
+
execFileSync("touch", [bundlePath]);
|
|
157
|
+
} catch {
|
|
158
|
+
// Non-fatal: the bundle still works, the icon cache may just lag.
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/cdp.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Chrome DevTools Protocol client built on Node's global `fetch` and
|
|
3
|
+
* `WebSocket` (Node >= 22). Avoids a heavyweight Playwright/Puppeteer
|
|
4
|
+
* dependency so `npx` stays fast and browser-download free.
|
|
5
|
+
*/
|
|
6
|
+
export class CdpClient {
|
|
7
|
+
#ws;
|
|
8
|
+
#nextId = 0;
|
|
9
|
+
#pending = new Map();
|
|
10
|
+
#eventWaiters = new Map();
|
|
11
|
+
|
|
12
|
+
constructor(webSocketDebuggerUrl) {
|
|
13
|
+
this.url = webSocketDebuggerUrl;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Resolve the browser-level WebSocket endpoint and connect to it. */
|
|
17
|
+
static async connect(cdpPort) {
|
|
18
|
+
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error(`CDP not reachable on port ${cdpPort} (HTTP ${res.status}).`);
|
|
21
|
+
}
|
|
22
|
+
const { webSocketDebuggerUrl } = await res.json();
|
|
23
|
+
if (!webSocketDebuggerUrl) {
|
|
24
|
+
throw new Error("CDP did not advertise a webSocketDebuggerUrl.");
|
|
25
|
+
}
|
|
26
|
+
const client = new CdpClient(webSocketDebuggerUrl);
|
|
27
|
+
await client.#open();
|
|
28
|
+
return client;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#open() {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
this.#ws = new WebSocket(this.url);
|
|
34
|
+
this.#ws.addEventListener("open", () => resolve(), { once: true });
|
|
35
|
+
this.#ws.addEventListener(
|
|
36
|
+
"error",
|
|
37
|
+
() => reject(new Error("Failed to open CDP WebSocket.")),
|
|
38
|
+
{ once: true },
|
|
39
|
+
);
|
|
40
|
+
this.#ws.addEventListener("message", (ev) => this.#onMessage(ev));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#onMessage(ev) {
|
|
45
|
+
let data;
|
|
46
|
+
try {
|
|
47
|
+
data = JSON.parse(ev.data);
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (data.id !== undefined && this.#pending.has(data.id)) {
|
|
53
|
+
const { resolve, reject } = this.#pending.get(data.id);
|
|
54
|
+
this.#pending.delete(data.id);
|
|
55
|
+
if (data.error) reject(new Error(data.error.message));
|
|
56
|
+
else resolve(data.result);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (data.method) {
|
|
61
|
+
const key = eventKey(data.method, data.sessionId);
|
|
62
|
+
const waiter = this.#eventWaiters.get(key);
|
|
63
|
+
if (waiter) {
|
|
64
|
+
this.#eventWaiters.delete(key);
|
|
65
|
+
waiter(data.params);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Send a CDP command, optionally scoped to an attached session. */
|
|
71
|
+
send(method, params = {}, sessionId) {
|
|
72
|
+
const id = ++this.#nextId;
|
|
73
|
+
const message = { id, method, params };
|
|
74
|
+
if (sessionId) message.sessionId = sessionId;
|
|
75
|
+
this.#ws.send(JSON.stringify(message));
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
this.#pending.set(id, { resolve, reject });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve the next time `method` fires (for the given session, if any). */
|
|
82
|
+
waitForEvent(method, sessionId) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
this.#eventWaiters.set(eventKey(method, sessionId), resolve);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
close() {
|
|
89
|
+
try {
|
|
90
|
+
this.#ws?.close();
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function eventKey(method, sessionId) {
|
|
98
|
+
return sessionId ? `${sessionId}:${method}` : method;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Open a URL in a new tab and leave it running. Returns the new targetId. */
|
|
102
|
+
export async function openUrl(cdpPort, url) {
|
|
103
|
+
const cdp = await CdpClient.connect(cdpPort);
|
|
104
|
+
try {
|
|
105
|
+
const { targetId } = await cdp.send("Target.createTarget", { url });
|
|
106
|
+
return targetId;
|
|
107
|
+
} finally {
|
|
108
|
+
cdp.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Navigate to a URL and return the page's serialized HTML.
|
|
114
|
+
* @param {object} opts
|
|
115
|
+
* @param {boolean} [opts.close] close the tab after capturing.
|
|
116
|
+
* @param {number} [opts.timeoutMs] navigation timeout.
|
|
117
|
+
*/
|
|
118
|
+
export async function getPageHtml(cdpPort, url, { close = false, timeoutMs = 30_000 } = {}) {
|
|
119
|
+
const cdp = await CdpClient.connect(cdpPort);
|
|
120
|
+
let targetId;
|
|
121
|
+
try {
|
|
122
|
+
({ targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }));
|
|
123
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", {
|
|
124
|
+
targetId,
|
|
125
|
+
flatten: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await cdp.send("Page.enable", {}, sessionId);
|
|
129
|
+
const loaded = cdp.waitForEvent("Page.loadEventFired", sessionId);
|
|
130
|
+
await cdp.send("Page.navigate", { url }, sessionId);
|
|
131
|
+
|
|
132
|
+
await Promise.race([
|
|
133
|
+
loaded,
|
|
134
|
+
delay(timeoutMs).then(() => {
|
|
135
|
+
throw new Error(`Timed out loading ${url} after ${timeoutMs}ms.`);
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const { result } = await cdp.send(
|
|
140
|
+
"Runtime.evaluate",
|
|
141
|
+
{
|
|
142
|
+
expression: "document.documentElement.outerHTML",
|
|
143
|
+
returnByValue: true,
|
|
144
|
+
},
|
|
145
|
+
sessionId,
|
|
146
|
+
);
|
|
147
|
+
return result.value;
|
|
148
|
+
} finally {
|
|
149
|
+
if (close && targetId) {
|
|
150
|
+
await cdp.send("Target.closeTarget", { targetId }).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
cdp.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Close the entire browser via CDP (graceful quit). */
|
|
157
|
+
export async function closeBrowser(cdpPort) {
|
|
158
|
+
const cdp = await CdpClient.connect(cdpPort);
|
|
159
|
+
try {
|
|
160
|
+
await cdp.send("Browser.close").catch(() => {});
|
|
161
|
+
} finally {
|
|
162
|
+
cdp.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function delay(ms) {
|
|
167
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
168
|
+
}
|
package/src/chrome.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
import { DEFAULTS } from "./config.js";
|
|
5
|
+
import {
|
|
6
|
+
setupCommand,
|
|
7
|
+
openCommand,
|
|
8
|
+
htmlCommand,
|
|
9
|
+
statusCommand,
|
|
10
|
+
stopCommand,
|
|
11
|
+
} from "./commands.js";
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { version } = require("../package.json");
|
|
15
|
+
|
|
16
|
+
export async function run(argv) {
|
|
17
|
+
const program = new Command();
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.name("chrome-cdp")
|
|
21
|
+
.description(
|
|
22
|
+
"Set up and drive a Chrome DevTools Protocol instance on macOS through a " +
|
|
23
|
+
"dedicated ChromeCDP.app bundle (consistent Dock icon).",
|
|
24
|
+
)
|
|
25
|
+
.version(version, "-v, --version");
|
|
26
|
+
|
|
27
|
+
// Options shared across commands.
|
|
28
|
+
const withCommon = (cmd) =>
|
|
29
|
+
cmd
|
|
30
|
+
.option("--port <port>", `CDP port (default: ${DEFAULTS.cdpPort})`)
|
|
31
|
+
.option("-p, --profile <dir>", "Chrome user-data-dir")
|
|
32
|
+
.option("-c, --chrome <path>", "Google Chrome executable path")
|
|
33
|
+
.option("--bundle <path>", `App bundle path (default: ${DEFAULTS.bundlePath})`);
|
|
34
|
+
|
|
35
|
+
withCommon(
|
|
36
|
+
program
|
|
37
|
+
.command("setup")
|
|
38
|
+
.description("Create or repair ChromeCDP.app (icon, Info.plist, launcher)")
|
|
39
|
+
.option("-f, --force", "Rewrite the bundle even if it already exists"),
|
|
40
|
+
).action(setupCommand);
|
|
41
|
+
|
|
42
|
+
withCommon(
|
|
43
|
+
program
|
|
44
|
+
.command("open")
|
|
45
|
+
.argument("[url]", "URL to open after launch")
|
|
46
|
+
.description("Launch ChromeCDP via the app bundle and optionally open a URL")
|
|
47
|
+
.option("--headless", "Run headless (no window/Dock)", false)
|
|
48
|
+
.option("-t, --timeout <seconds>", "Startup timeout in seconds", "30"),
|
|
49
|
+
).action(openCommand);
|
|
50
|
+
|
|
51
|
+
withCommon(
|
|
52
|
+
program
|
|
53
|
+
.command("html")
|
|
54
|
+
.argument("<url>", "URL to load")
|
|
55
|
+
.description("Print or save a page's HTML over CDP")
|
|
56
|
+
.option("--headless", "Run headless (default for html)")
|
|
57
|
+
.option("--headed", "Force a visible window")
|
|
58
|
+
.option("--close", "Close the tab when done", false)
|
|
59
|
+
.option("-o, --output <file>", "Write HTML to a file instead of stdout")
|
|
60
|
+
.option("-t, --timeout <seconds>", "Load timeout in seconds", "30"),
|
|
61
|
+
).action((url, opts) => {
|
|
62
|
+
// --headed wins over --headless when both are passed.
|
|
63
|
+
const headless = opts.headed ? false : opts.headless;
|
|
64
|
+
return htmlCommand(url, { ...opts, headless });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
withCommon(
|
|
68
|
+
program
|
|
69
|
+
.command("status")
|
|
70
|
+
.description("Show bundle and CDP connection status"),
|
|
71
|
+
).action(statusCommand);
|
|
72
|
+
|
|
73
|
+
withCommon(
|
|
74
|
+
program.command("stop").description("Quit the running ChromeCDP instance"),
|
|
75
|
+
).action(stopCommand);
|
|
76
|
+
|
|
77
|
+
await program.parseAsync(argv);
|
|
78
|
+
}
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { DEFAULTS, loadConfig, saveConfig, CONFIG_FILE } from "./config.js";
|
|
5
|
+
import { assertSupportedPlatform } from "./platform.js";
|
|
6
|
+
import { ensureAppBundle, bundleExists } from "./appBundle.js";
|
|
7
|
+
import { ensureChromeRunning, probeCdp } from "./chrome.js";
|
|
8
|
+
import { openUrl, getPageHtml, closeBrowser } from "./cdp.js";
|
|
9
|
+
|
|
10
|
+
/** Merge persisted config with CLI overrides into a fully-resolved config. */
|
|
11
|
+
function resolveConfig(opts = {}) {
|
|
12
|
+
const stored = loadConfig();
|
|
13
|
+
const resolved = { ...stored };
|
|
14
|
+
if (opts.chrome) resolved.chromePath = opts.chrome;
|
|
15
|
+
if (opts.profile) resolved.profileDir = path.resolve(opts.profile);
|
|
16
|
+
if (opts.bundle) resolved.bundlePath = path.resolve(opts.bundle);
|
|
17
|
+
if (opts.port !== undefined) resolved.cdpPort = parsePort(opts.port);
|
|
18
|
+
return resolved;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parsePort(value) {
|
|
22
|
+
const port = Number(value);
|
|
23
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
24
|
+
throw new Error(`Invalid --port "${value}". Use an integer 1-65535.`);
|
|
25
|
+
}
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseTimeoutMs(value, fallbackSeconds = 30) {
|
|
30
|
+
const seconds = value === undefined ? fallbackSeconds : Number(value);
|
|
31
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
32
|
+
throw new Error(`Invalid --timeout "${value}". Use seconds > 0.`);
|
|
33
|
+
}
|
|
34
|
+
return seconds * 1000;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeUrl(url) {
|
|
38
|
+
return /^[a-z]+:\/\//i.test(url) ? url : `https://${url}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create the bundle on demand (without forcing a rewrite of an existing one). */
|
|
42
|
+
function ensureBundleReady(config) {
|
|
43
|
+
const { created } = ensureAppBundle(config, { force: false });
|
|
44
|
+
if (created) {
|
|
45
|
+
console.error(`Created ${config.bundlePath} (Dock icon: Chrome).`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: setup
|
|
50
|
+
export async function setupCommand(opts) {
|
|
51
|
+
assertSupportedPlatform();
|
|
52
|
+
const config = resolveConfig(opts);
|
|
53
|
+
const { created } = ensureAppBundle(config, { force: true });
|
|
54
|
+
const file = saveConfig(config);
|
|
55
|
+
|
|
56
|
+
console.error(`${created ? "Created" : "Repaired"} ${config.bundlePath}`);
|
|
57
|
+
console.error(` Chrome: ${config.chromePath}`);
|
|
58
|
+
console.error(` Profile: ${config.profileDir}`);
|
|
59
|
+
console.error(` CDP port: ${config.cdpPort}`);
|
|
60
|
+
console.error(`Config saved to ${file}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// MARK: open
|
|
64
|
+
export async function openCommand(url, opts) {
|
|
65
|
+
assertSupportedPlatform();
|
|
66
|
+
const config = resolveConfig(opts);
|
|
67
|
+
const headless = Boolean(opts.headless);
|
|
68
|
+
const timeoutMs = parseTimeoutMs(opts.timeout);
|
|
69
|
+
|
|
70
|
+
ensureBundleReady(config);
|
|
71
|
+
|
|
72
|
+
const { launched } = await ensureChromeRunning({
|
|
73
|
+
bundlePath: config.bundlePath,
|
|
74
|
+
cdpPort: config.cdpPort,
|
|
75
|
+
headless,
|
|
76
|
+
timeoutMs,
|
|
77
|
+
});
|
|
78
|
+
console.error(
|
|
79
|
+
launched
|
|
80
|
+
? `Launched ChromeCDP (${headless ? "headless" : "headed"}) on port ${config.cdpPort}.`
|
|
81
|
+
: `ChromeCDP already running on port ${config.cdpPort}.`,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (url) {
|
|
85
|
+
const target = await openUrl(config.cdpPort, normalizeUrl(url));
|
|
86
|
+
console.error(`Opened ${normalizeUrl(url)} (target ${target}).`);
|
|
87
|
+
}
|
|
88
|
+
console.error(`DevTools: http://127.0.0.1:${config.cdpPort}/json`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MARK: html
|
|
92
|
+
export async function htmlCommand(url, opts) {
|
|
93
|
+
assertSupportedPlatform();
|
|
94
|
+
const config = resolveConfig(opts);
|
|
95
|
+
const headless = opts.headless === undefined ? true : Boolean(opts.headless);
|
|
96
|
+
const timeoutMs = parseTimeoutMs(opts.timeout);
|
|
97
|
+
const target = normalizeUrl(url);
|
|
98
|
+
|
|
99
|
+
ensureBundleReady(config);
|
|
100
|
+
await ensureChromeRunning({
|
|
101
|
+
bundlePath: config.bundlePath,
|
|
102
|
+
cdpPort: config.cdpPort,
|
|
103
|
+
headless,
|
|
104
|
+
timeoutMs,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
console.error(`Fetching HTML for ${target} ...`);
|
|
108
|
+
const html = await getPageHtml(config.cdpPort, target, {
|
|
109
|
+
close: Boolean(opts.close),
|
|
110
|
+
timeoutMs,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (opts.output) {
|
|
114
|
+
const outPath = path.resolve(opts.output);
|
|
115
|
+
fs.writeFileSync(outPath, html);
|
|
116
|
+
console.error(`Saved HTML to ${outPath}`);
|
|
117
|
+
} else {
|
|
118
|
+
process.stdout.write(html.endsWith("\n") ? html : `${html}\n`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// MARK: status
|
|
123
|
+
export async function statusCommand(opts) {
|
|
124
|
+
assertSupportedPlatform();
|
|
125
|
+
const config = resolveConfig(opts);
|
|
126
|
+
const exists = bundleExists(config.bundlePath);
|
|
127
|
+
const version = await probeCdp(config.cdpPort);
|
|
128
|
+
|
|
129
|
+
console.log("ChromeCDP status");
|
|
130
|
+
console.log(` Bundle: ${exists ? "present" : "missing"} (${config.bundlePath})`);
|
|
131
|
+
console.log(` Profile: ${config.profileDir}`);
|
|
132
|
+
console.log(` CDP port: ${config.cdpPort}`);
|
|
133
|
+
console.log(
|
|
134
|
+
version
|
|
135
|
+
? ` Running: yes — ${version.Browser} (${version["Protocol-Version"] ?? "?"})`
|
|
136
|
+
: " Running: no",
|
|
137
|
+
);
|
|
138
|
+
console.log(` Config: ${CONFIG_FILE}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// MARK: stop
|
|
142
|
+
export async function stopCommand(opts) {
|
|
143
|
+
assertSupportedPlatform();
|
|
144
|
+
const config = resolveConfig(opts);
|
|
145
|
+
const version = await probeCdp(config.cdpPort);
|
|
146
|
+
if (!version) {
|
|
147
|
+
console.error(`No CDP instance reachable on port ${config.cdpPort}.`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await closeBrowser(config.cdpPort);
|
|
151
|
+
console.error(`Asked ChromeCDP on port ${config.cdpPort} to quit.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { DEFAULTS };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default, canonical values for a ChromeCDP environment.
|
|
7
|
+
*
|
|
8
|
+
* These are baked into the ChromeCDP.app bundle at setup time and persisted to
|
|
9
|
+
* a config file so every subcommand agrees on the same port/profile/binary.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULTS = Object.freeze({
|
|
12
|
+
chromePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
13
|
+
chromeIcon: "/Applications/Google Chrome.app/Contents/Resources/app.icns",
|
|
14
|
+
bundlePath: "/Applications/ChromeCDP.app",
|
|
15
|
+
bundleId: "org.guocity.chrome-cdp",
|
|
16
|
+
cdpPort: 9222,
|
|
17
|
+
profileDir: path.join(os.homedir(), ".chrome_cdp_profile"),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "chrome-cdp-manager");
|
|
21
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
22
|
+
|
|
23
|
+
/** Load persisted config, falling back to {@link DEFAULTS} for missing keys. */
|
|
24
|
+
export function loadConfig() {
|
|
25
|
+
let stored = {};
|
|
26
|
+
try {
|
|
27
|
+
stored = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
// No config yet — first run.
|
|
30
|
+
}
|
|
31
|
+
return { ...DEFAULTS, ...stored };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Persist the resolved config so other commands stay in sync with the bundle. */
|
|
35
|
+
export function saveConfig(config) {
|
|
36
|
+
const toStore = {
|
|
37
|
+
chromePath: config.chromePath,
|
|
38
|
+
chromeIcon: config.chromeIcon,
|
|
39
|
+
bundlePath: config.bundlePath,
|
|
40
|
+
bundleId: config.bundleId,
|
|
41
|
+
cdpPort: config.cdpPort,
|
|
42
|
+
profileDir: config.profileDir,
|
|
43
|
+
};
|
|
44
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
45
|
+
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(toStore, null, 2)}\n`);
|
|
46
|
+
return CONFIG_FILE;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { CONFIG_FILE };
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This tool builds a macOS `.app` bundle and relies on `arch -arm64` plus the
|
|
5
|
+
* `open` LaunchServices command, so it only runs on Apple Silicon macOS.
|
|
6
|
+
*/
|
|
7
|
+
export function assertSupportedPlatform() {
|
|
8
|
+
if (process.platform !== "darwin") {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`chrome-cdp-manager only supports macOS (found "${process.platform}").`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
if (os.arch() !== "arm64") {
|
|
14
|
+
// Not fatal — the launcher uses `arch -arm64`, which is a no-op on arm64
|
|
15
|
+
// and an error on Intel. Warn rather than block in case of Rosetta setups.
|
|
16
|
+
process.emitWarning(
|
|
17
|
+
`Detected CPU arch "${os.arch()}". The launcher uses \`arch -arm64\`; ` +
|
|
18
|
+
`this is intended for Apple Silicon Macs.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|