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 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 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`.
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 macOS on Apple Silicon, Node.js ≥ 22, and Google Chrome installed.
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 ChromeCDP.app and save defaults (port 9222, profile ~/.chrome_cdp_profile)
37
+ # Create / repair the launcher and save defaults (port 9222, profile ~/.chrome_cdp_profile)
31
38
  chrome-cdp setup
32
39
 
33
- # Launch ChromeCDP (Dock icon = ChromeCDP) and open a page
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 `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 |
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>` | 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`) |
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
- Port, profile, Chrome path and bundle path are baked into the app bundle and
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 Dock icon stays consistent
129
+ ## How the icon stays consistent
78
130
 
79
- The bundle's executable is a small bash launcher:
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
- 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.
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.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.",
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 src/cli.js",
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
+ }
@@ -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 through a " +
23
- "dedicated ChromeCDP.app bundle (consistent Dock icon).",
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>", "Chrome user-data-dir")
32
- .option("-c, --chrome <path>", "Google Chrome executable path")
33
- .option("--bundle <path>", `App bundle path (default: ${DEFAULTS.bundlePath})`);
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("Create or repair ChromeCDP.app (icon, Info.plist, launcher)")
39
- .option("-f, --force", "Rewrite the bundle even if it already exists"),
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 app bundle and optionally open a URL")
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 bundle and CDP connection status"),
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 { ensureAppBundle, bundleExists } from "./appBundle.js";
7
- import { ensureChromeRunning, probeCdp } from "./chrome.js";
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
- if (opts.chrome) resolved.chromePath = opts.chrome;
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
- if (opts.bundle) resolved.bundlePath = path.resolve(opts.bundle);
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 bundle on demand (without forcing a rewrite of an existing one). */
42
- function ensureBundleReady(config) {
43
- const { created } = ensureAppBundle(config, { force: false });
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(`Created ${config.bundlePath} (Dock icon: Chrome).`);
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 { created } = ensureAppBundle(config, { force: true });
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.bundlePath}`);
57
- console.error(` Chrome: ${config.chromePath}`);
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
- ensureBundleReady(config);
111
+ ensureLauncherReady(config);
71
112
 
72
- const { launched } = await ensureChromeRunning({
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
- ensureBundleReady(config);
100
- await ensureChromeRunning({
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 exists = bundleExists(config.bundlePath);
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(` Bundle: ${exists ? "present" : "missing"} (${config.bundlePath})`);
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
- * Default, canonical values for a ChromeCDP environment.
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 ChromeCDP.app bundle at setup time and persisted to
9
- * a config file so every subcommand agrees on the same port/profile/binary.
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
- 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
- });
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 bundle. */
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
- chromePath: config.chromePath,
38
- chromeIcon: config.chromeIcon,
39
- bundlePath: config.bundlePath,
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
+ }
@@ -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
- * 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`.
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
- function launcherScript({ chromePath, cdpPort, profileDir }) {
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 \`npx chrome-cdp-manager setup\` to regenerate this file.
19
+ # Re-run \`chrome-cdp setup\` to regenerate this file.
14
20
  set -euo pipefail
15
21
 
16
- CHROME=${shellQuote(chromePath)}
22
+ BROWSER=${shellQuote(browserPath)}
17
23
  PORT=${shellQuote(String(cdpPort))}
18
24
  PROFILE=${shellQuote(profileDir)}
19
25
 
20
- exec arch -arm64 "$CHROME" \\
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
- export function bundleLayout(bundlePath) {
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 bundleExists(bundlePath) {
79
- return fs.existsSync(bundleLayout(bundlePath).executable);
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 ensureAppBundle(config, { force = false } = {}) {
89
- const { bundlePath, chromePath, chromeIcon, cdpPort, profileDir, bundleId } =
100
+ export function ensure(config, { force = false } = {}) {
101
+ const { launcherPath, browserPath, browserIcon, cdpPort, profileDir, bundleId } =
90
102
  config;
91
- const layout = bundleLayout(bundlePath);
92
- const existed = bundleExists(bundlePath);
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(chromePath)) {
110
+ if (!fs.existsSync(browserPath)) {
99
111
  throw new Error(
100
- `Google Chrome not found at:\n ${chromePath}\n` +
101
- `Install Chrome or pass --chrome <path>.`,
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({ chromePath, cdpPort, profileDir }),
123
+ launcherScript({ browserPath, cdpPort, profileDir }),
112
124
  { mode: 0o755 },
113
125
  );
114
126
  fs.writeFileSync(layout.plist, infoPlist({ bundleId }));
115
127
 
116
- // Use Chrome's own icon so the Dock entry is recognisable.
117
- if (fs.existsSync(chromeIcon)) {
118
- fs.copyFileSync(chromeIcon, layout.icon);
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 ${bundlePath}.\n` +
134
- `Try: sudo npx chrome-cdp-manager setup`,
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(bundlePath);
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 and relies on `arch -arm64` plus the
5
- * `open` LaunchServices command, so it only runs on Apple Silicon macOS.
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 !== "darwin") {
10
+ if (!SUPPORTED.has(process.platform)) {
9
11
  throw new Error(
10
- `chrome-cdp-manager only supports macOS (found "${process.platform}").`,
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 arm64
15
- // and an error on Intel. Warn rather than block in case of Rosetta setups.
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
- }