chrome-cdp-manager 1.2.2 → 1.2.6
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 +48 -0
- package/bin/playwright-cli.js +76 -0
- package/package.json +6 -3
- package/src/browser.js +17 -1
- package/src/cli.js +8 -1
- package/src/commands.js +56 -12
- package/src/config.js +4 -0
- package/src/launchers/macBundle.js +48 -2
- package/src/launchers/winShortcut.js +19 -6
- package/src/preferences.js +29 -0
- package/src/proxy.js +93 -0
- package/types/config.d.ts +2 -0
- package/types/launchers/winShortcut.d.ts +4 -0
- package/types/preferences.d.ts +10 -0
- package/types/proxy.d.ts +34 -0
package/README.md
CHANGED
|
@@ -49,6 +49,11 @@ chrome-cdp open https://example.com
|
|
|
49
49
|
# Same, headless (no window/Dock/taskbar)
|
|
50
50
|
chrome-cdp open https://example.com --headless
|
|
51
51
|
|
|
52
|
+
# Route all traffic through a proxy (defaults to socks5://127.0.0.1:1080)
|
|
53
|
+
chrome-cdp open https://example.com --proxy
|
|
54
|
+
chrome-cdp open https://example.com --proxy socks5://127.0.0.1:9050
|
|
55
|
+
chrome-cdp open https://example.com --proxy http://10.0.0.1:8080
|
|
56
|
+
|
|
52
57
|
# Fetch a page's rendered HTML over CDP (headless by default)
|
|
53
58
|
chrome-cdp html example.com -o page.html
|
|
54
59
|
|
|
@@ -108,6 +113,36 @@ Playwright over CDP, and returns `{ browser, context, page, config }`. In
|
|
|
108
113
|
zero-install (npx) setups where Playwright can't be resolved automatically,
|
|
109
114
|
inject it: `connect({ chromium })`.
|
|
110
115
|
|
|
116
|
+
## Playwright CLI (`playwright-cli`)
|
|
117
|
+
|
|
118
|
+
The package also installs a **`playwright-cli`** command that runs
|
|
119
|
+
[Playwright's MCP CLI client](https://playwright.dev) against the ChromeCDP
|
|
120
|
+
instance managed here. It's a thin wrapper that, before handing off, guarantees:
|
|
121
|
+
|
|
122
|
+
1. A **headed** ChromeCDP browser is reachable over CDP. If one is **already
|
|
123
|
+
running on the port, it just attaches to it** (no relaunch); otherwise it
|
|
124
|
+
launches a fresh headed instance.
|
|
125
|
+
2. The environment points Playwright at that browser instead of spawning its own:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
PLAYWRIGHT_MCP_CDP_ENDPOINT=http://localhost:9222 # from config (default 9222)
|
|
129
|
+
PLAYWRIGHT_MCP_ISOLATED=false
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Everything after `playwright-cli` is forwarded verbatim to the Playwright CLI:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Drive the attached browser
|
|
136
|
+
playwright-cli goto https://example.com
|
|
137
|
+
playwright-cli click "Sign in"
|
|
138
|
+
|
|
139
|
+
# Help / version forward straight through (no browser launch)
|
|
140
|
+
playwright-cli --help
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Requires the optional `playwright` peer dependency (`npm i playwright`). The
|
|
144
|
+
endpoint port follows your saved config (`~/.config/chrome-cdp-manager/config.json`).
|
|
145
|
+
|
|
111
146
|
## Common options
|
|
112
147
|
|
|
113
148
|
| Option | Description |
|
|
@@ -117,6 +152,8 @@ inject it: `connect({ chromium })`.
|
|
|
117
152
|
| `-b, --browser <name>` | Browser: `chrome`, `edge`, `brave`, `chromium`, `vivaldi`, `opera`, `arc` |
|
|
118
153
|
| `--path <path>` | Explicit browser executable (overrides `--browser`) |
|
|
119
154
|
| `--target <path>` | Launcher location (`.app` on macOS, `.lnk` on Windows) |
|
|
155
|
+
| `--proxy [server]` | Route traffic through a proxy; omit the value for `socks5://127.0.0.1:1080`. Accepts a port (`1080`), `host:port`, or `scheme://host:port` (`socks5`, `socks4`, `http`, `https`) |
|
|
156
|
+
| `--no-proxy` | Disable any configured proxy for this run |
|
|
120
157
|
| `-t, --timeout <secs>` | Startup / load timeout (`open`, `html`) |
|
|
121
158
|
|
|
122
159
|
`-c, --chrome` and `--bundle` remain as aliases for `--path` and `--target`.
|
|
@@ -126,6 +163,17 @@ persisted (`~/.config/chrome-cdp-manager/config.json`) at `setup` time so every
|
|
|
126
163
|
command agrees on the same environment. Re-run `setup --force` after changing
|
|
127
164
|
them.
|
|
128
165
|
|
|
166
|
+
### Proxy
|
|
167
|
+
|
|
168
|
+
`--proxy` passes Chrome's `--proxy-server` flag at launch so traffic routes
|
|
169
|
+
through it. The default is a local SOCKS5 proxy on port `1080` (the common
|
|
170
|
+
`ssh -D` / shadowsocks default). The proxy is a **per-launch** flag — it is not
|
|
171
|
+
baked into the launcher, so a normal (non-proxied) launch is never affected by
|
|
172
|
+
it. Before launching, the proxy port is probed and a warning is printed if
|
|
173
|
+
nothing is listening; `status` reports the configured proxy and whether it's
|
|
174
|
+
reachable. Persist a default proxy with `chrome-cdp setup --proxy …`, or opt out
|
|
175
|
+
of a persisted proxy for a single run with `--no-proxy`.
|
|
176
|
+
|
|
129
177
|
## How the icon stays consistent
|
|
130
178
|
|
|
131
179
|
**macOS** — the bundle's executable is a small bash launcher:
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `playwright-cli` — run Playwright's MCP CLI client against the ChromeCDP
|
|
4
|
+
* instance managed by this package.
|
|
5
|
+
*
|
|
6
|
+
* The Playwright CLI itself has nothing to do with this repo; this wrapper only
|
|
7
|
+
* guarantees the two things it needs before handing off:
|
|
8
|
+
*
|
|
9
|
+
* 1. A *headed* ChromeCDP browser is running and reachable over CDP.
|
|
10
|
+
* 2. The env points Playwright at that browser instead of spawning its own:
|
|
11
|
+
* PLAYWRIGHT_MCP_CDP_ENDPOINT=http://localhost:<cdpPort> (default 9222)
|
|
12
|
+
* PLAYWRIGHT_MCP_ISOLATED=false
|
|
13
|
+
*
|
|
14
|
+
* Everything after `playwright-cli` is forwarded verbatim — the cli-client reads
|
|
15
|
+
* process.argv.slice(2) directly, which already holds those args here.
|
|
16
|
+
*/
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
|
|
19
|
+
import { assertSupportedPlatform } from "../src/platform.js";
|
|
20
|
+
import { loadConfig } from "../src/config.js";
|
|
21
|
+
import { getLauncher } from "../src/launcher.js";
|
|
22
|
+
import { ensureBrowserRunning, probeCdp } from "../src/browser.js";
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
|
|
26
|
+
/** Plain help/version requests don't need a browser — just forward them. */
|
|
27
|
+
function isHelpOrVersionOnly(args) {
|
|
28
|
+
if (args.length === 0) return true;
|
|
29
|
+
const hasCommand = args.some((a) => !a.startsWith("-"));
|
|
30
|
+
if (!hasCommand) return true;
|
|
31
|
+
return args.some((a) => ["-h", "--help", "-v", "--version"].includes(a));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
|
|
37
|
+
if (!isHelpOrVersionOnly(args)) {
|
|
38
|
+
assertSupportedPlatform();
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
const endpoint = `http://localhost:${config.cdpPort}`;
|
|
41
|
+
|
|
42
|
+
// If a CDP browser is already up on this port, attach to it as-is — never
|
|
43
|
+
// relaunch (even if it's headless). Only launch a fresh headed instance when
|
|
44
|
+
// nothing is listening.
|
|
45
|
+
const existing = await probeCdp(config.cdpPort);
|
|
46
|
+
if (existing) {
|
|
47
|
+
console.error(`ChromeCDP already running on port ${config.cdpPort}.`);
|
|
48
|
+
} else {
|
|
49
|
+
getLauncher().ensure(config, { force: false });
|
|
50
|
+
await ensureBrowserRunning(config, { headless: false, timeoutMs: 30_000 });
|
|
51
|
+
console.error(`Launched ChromeCDP (headed) on port ${config.cdpPort}.`);
|
|
52
|
+
}
|
|
53
|
+
console.error(`playwright-cli attaching to ${endpoint} ...`);
|
|
54
|
+
|
|
55
|
+
process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT = endpoint;
|
|
56
|
+
process.env.PLAYWRIGHT_MCP_ISOLATED = "false";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let program;
|
|
60
|
+
try {
|
|
61
|
+
({ program } = require("playwright-core/lib/tools/cli-client/program"));
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"Playwright not found. The `playwright-cli` command needs Playwright " +
|
|
65
|
+
"installed:\n npm i playwright",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const packageJson = require("../package.json");
|
|
70
|
+
await program({ embedderVersion: packageJson.version });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
main().catch((error) => {
|
|
74
|
+
console.error(`\nError: ${error.message}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-cdp-manager",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.6",
|
|
4
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
6
|
"main": "./src/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
"bin": {
|
|
20
20
|
"chrome-cdp": "bin/cli.js",
|
|
21
|
-
"chrome-cdp-manager": "bin/cli.js"
|
|
21
|
+
"chrome-cdp-manager": "bin/cli.js",
|
|
22
|
+
"playwright-cli": "bin/playwright-cli.js"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
25
|
"bin",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
],
|
|
58
59
|
"license": "MIT",
|
|
59
60
|
"dependencies": {
|
|
61
|
+
"cheerio": "^1.2.0",
|
|
60
62
|
"commander": "^12.1.0"
|
|
61
63
|
},
|
|
62
64
|
"peerDependencies": {
|
|
@@ -68,6 +70,7 @@
|
|
|
68
70
|
}
|
|
69
71
|
},
|
|
70
72
|
"devDependencies": {
|
|
71
|
-
"
|
|
73
|
+
"playwright": "^1.61.0",
|
|
74
|
+
"typescript": "^5.9.3"
|
|
72
75
|
}
|
|
73
76
|
}
|
package/src/browser.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getLauncher } from "./launcher.js";
|
|
2
|
+
import { closeBrowser } from "./cdp.js";
|
|
3
|
+
|
|
2
4
|
|
|
3
5
|
/** CDP HTTP endpoint helper. */
|
|
4
6
|
function versionUrl(cdpPort) {
|
|
@@ -38,7 +40,21 @@ export async function waitForCdp(cdpPort, timeoutMs = 30_000) {
|
|
|
38
40
|
*/
|
|
39
41
|
export async function ensureBrowserRunning(config, { headless, timeoutMs } = {}) {
|
|
40
42
|
const existing = await probeCdp(config.cdpPort);
|
|
41
|
-
if (existing)
|
|
43
|
+
if (existing) {
|
|
44
|
+
const isHeadless =
|
|
45
|
+
/HeadlessChrome/i.test(existing.Browser) ||
|
|
46
|
+
/HeadlessChrome/i.test(existing["User-Agent"] || "");
|
|
47
|
+
if (isHeadless && !headless) {
|
|
48
|
+
try {
|
|
49
|
+
await closeBrowser(config.cdpPort);
|
|
50
|
+
await delay(500);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// ignore
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
return { launched: false, version: existing };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
42
58
|
|
|
43
59
|
getLauncher().launch(config, { headless });
|
|
44
60
|
const version = await waitForCdp(config.cdpPort, timeoutMs);
|
package/src/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
|
|
4
4
|
import { DEFAULTS } from "./config.js";
|
|
5
5
|
import { browserKeys, DEFAULT_BROWSER } from "./browsers.js";
|
|
6
|
+
import { DEFAULT_PROXY } from "./proxy.js";
|
|
6
7
|
import {
|
|
7
8
|
setupCommand,
|
|
8
9
|
openCommand,
|
|
@@ -61,7 +62,13 @@ Run 'chrome-cdp <command> --help' to see all options for a specific command.`,
|
|
|
61
62
|
`Launcher ${TARGET_LABEL} path (default: ${DEFAULTS.launcherPath})`,
|
|
62
63
|
)
|
|
63
64
|
// Back-compat alias for --target.
|
|
64
|
-
.option("--bundle <path>", "Alias for --target")
|
|
65
|
+
.option("--bundle <path>", "Alias for --target")
|
|
66
|
+
.option(
|
|
67
|
+
"--proxy [server]",
|
|
68
|
+
`Route traffic through a proxy; value omitted uses ${DEFAULT_PROXY}. ` +
|
|
69
|
+
`Accepts a port (1080), host:port, or scheme://host:port`,
|
|
70
|
+
)
|
|
71
|
+
.option("--no-proxy", "Disable any configured proxy for this run");
|
|
65
72
|
|
|
66
73
|
withCommon(
|
|
67
74
|
program
|
package/src/commands.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { getLauncher } from "./launcher.js";
|
|
13
13
|
import { ensureBrowserRunning, probeCdp } from "./browser.js";
|
|
14
14
|
import { openUrl, getPageHtml, closeBrowser } from "./cdp.js";
|
|
15
|
+
import { parseProxy, detectProxy } from "./proxy.js";
|
|
15
16
|
|
|
16
17
|
/** Merge persisted config with CLI overrides into a fully-resolved config. */
|
|
17
18
|
function resolveConfig(opts = {}) {
|
|
@@ -51,6 +52,14 @@ function resolveConfig(opts = {}) {
|
|
|
51
52
|
const target = opts.target || opts.bundle;
|
|
52
53
|
if (target) resolved.launcherPath = path.resolve(target);
|
|
53
54
|
if (opts.port !== undefined) resolved.cdpPort = parsePort(opts.port);
|
|
55
|
+
|
|
56
|
+
// --no-proxy => opts.proxy === false; --proxy [server] => true or a string;
|
|
57
|
+
// absent => undefined (no proxy — it is never persisted).
|
|
58
|
+
if (opts.proxy === false) {
|
|
59
|
+
resolved.proxy = null;
|
|
60
|
+
} else if (opts.proxy !== undefined) {
|
|
61
|
+
resolved.proxy = parseProxy(opts.proxy).server;
|
|
62
|
+
}
|
|
54
63
|
return resolved;
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -86,6 +95,21 @@ function ensureLauncherReady(config) {
|
|
|
86
95
|
}
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
/** Probe the configured proxy and report whether it's reachable. */
|
|
99
|
+
async function reportProxy(config) {
|
|
100
|
+
if (!config.proxy) return;
|
|
101
|
+
const { server, host, port } = parseProxy(config.proxy);
|
|
102
|
+
const reachable = await detectProxy({ host, port });
|
|
103
|
+
if (reachable) {
|
|
104
|
+
console.error(`Proxy: routing traffic through ${server} (detected listening).`);
|
|
105
|
+
} else {
|
|
106
|
+
console.error(
|
|
107
|
+
`Proxy: ${server} configured, but nothing is listening on ${host}:${port}. ` +
|
|
108
|
+
`Pages won't load until the proxy is up — or pass --no-proxy.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
// MARK: setup
|
|
90
114
|
export async function setupCommand(opts) {
|
|
91
115
|
assertSupportedPlatform();
|
|
@@ -109,6 +133,7 @@ export async function openCommand(url, opts) {
|
|
|
109
133
|
const timeoutMs = parseTimeoutMs(opts.timeout);
|
|
110
134
|
|
|
111
135
|
ensureLauncherReady(config);
|
|
136
|
+
await reportProxy(config);
|
|
112
137
|
|
|
113
138
|
const { launched, version } = await ensureBrowserRunning(config, { headless, timeoutMs });
|
|
114
139
|
if (launched) {
|
|
@@ -136,20 +161,31 @@ export async function htmlCommand(url, opts) {
|
|
|
136
161
|
const target = normalizeUrl(url);
|
|
137
162
|
|
|
138
163
|
ensureLauncherReady(config);
|
|
139
|
-
await
|
|
164
|
+
await reportProxy(config);
|
|
165
|
+
const { launched } = await ensureBrowserRunning(config, { headless, timeoutMs });
|
|
140
166
|
|
|
141
167
|
console.error(`Fetching HTML for ${target} ...`);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
try {
|
|
169
|
+
const html = await getPageHtml(config.cdpPort, target, {
|
|
170
|
+
close: !launched || Boolean(opts.close),
|
|
171
|
+
timeoutMs,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (opts.output) {
|
|
175
|
+
const outPath = path.resolve(opts.output);
|
|
176
|
+
fs.writeFileSync(outPath, html);
|
|
177
|
+
console.error(`Saved HTML to ${outPath}`);
|
|
178
|
+
} else {
|
|
179
|
+
process.stdout.write(html.endsWith("\n") ? html : `${html}\n`);
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
if (launched && headless) {
|
|
183
|
+
try {
|
|
184
|
+
await closeBrowser(config.cdpPort);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// ignore errors during shutdown
|
|
187
|
+
}
|
|
188
|
+
}
|
|
153
189
|
}
|
|
154
190
|
}
|
|
155
191
|
|
|
@@ -176,6 +212,14 @@ export async function statusCommand(opts) {
|
|
|
176
212
|
console.log(` Profile: ${config.profileDir}`);
|
|
177
213
|
console.log(` CDP port: ${config.cdpPort}`);
|
|
178
214
|
|
|
215
|
+
if (config.proxy) {
|
|
216
|
+
const { server, host, port } = parseProxy(config.proxy);
|
|
217
|
+
const reachable = await detectProxy({ host, port });
|
|
218
|
+
console.log(` Proxy: ${server} (${reachable ? "reachable" : "NOT reachable"})`);
|
|
219
|
+
} else {
|
|
220
|
+
console.log(` Proxy: none (direct connection)`);
|
|
221
|
+
}
|
|
222
|
+
|
|
179
223
|
const isHeadless =
|
|
180
224
|
/HeadlessChrome/i.test(version.Browser) ||
|
|
181
225
|
/HeadlessChrome/i.test(version["User-Agent"] || "");
|
package/src/config.js
CHANGED
|
@@ -46,6 +46,8 @@ export function computeDefaults({
|
|
|
46
46
|
bundleId: BUNDLE_ID,
|
|
47
47
|
cdpPort: CDP_PORT,
|
|
48
48
|
profileDir: path.join(os.homedir(), ".chrome_cdp_profile"),
|
|
49
|
+
// Proxy is opt-in: null means launch with a direct connection.
|
|
50
|
+
proxy: null,
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -88,6 +90,8 @@ export function saveConfig(config) {
|
|
|
88
90
|
bundleId: config.bundleId,
|
|
89
91
|
cdpPort: config.cdpPort,
|
|
90
92
|
profileDir: config.profileDir,
|
|
93
|
+
// Proxy is intentionally not persisted: it's a per-invocation flag, so it
|
|
94
|
+
// never leaks into other launches.
|
|
91
95
|
};
|
|
92
96
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
93
97
|
fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(toStore, null, 2)}\n`);
|
|
@@ -3,6 +3,8 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn, execFileSync } from "node:child_process";
|
|
5
5
|
|
|
6
|
+
import { getWindowSizeFromProfile } from "../preferences.js";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* macOS launcher: a real `.app` bundle whose executable is a small bash
|
|
8
10
|
* launcher. Headed launches go through `open <bundle>` so LaunchServices
|
|
@@ -27,6 +29,36 @@ BROWSER=${shellQuote(browserPath)}
|
|
|
27
29
|
PORT=${shellQuote(String(cdpPort))}
|
|
28
30
|
PROFILE=${shellQuote(profileDir)}
|
|
29
31
|
|
|
32
|
+
# Gracefully close any background headless Chrome instance using the same port.
|
|
33
|
+
NODE_BIN="node"
|
|
34
|
+
if ! command -v node &> /dev/null; then
|
|
35
|
+
for path in "/opt/homebrew/bin/node" "/usr/local/bin/node" "$HOME/.nvm/versions/node"/*/bin/node "$HOME/.nvs"/*/bin/node; do
|
|
36
|
+
if [ -x "$path" ]; then
|
|
37
|
+
NODE_BIN="$path"
|
|
38
|
+
break
|
|
39
|
+
fi
|
|
40
|
+
done
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
"$NODE_BIN" --no-warnings -e "(async () => {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch('http://127.0.0.1:${cdpPort}/json/version');
|
|
46
|
+
if (res.ok) {
|
|
47
|
+
const info = await res.json();
|
|
48
|
+
const isHeadless = /HeadlessChrome/i.test(info.Browser) || /HeadlessChrome/i.test(info['User-Agent'] || '');
|
|
49
|
+
if (isHeadless) {
|
|
50
|
+
const ws = new WebSocket(info.webSocketDebuggerUrl);
|
|
51
|
+
ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Browser.close' }));
|
|
52
|
+
ws.onmessage = () => {
|
|
53
|
+
ws.close();
|
|
54
|
+
process.exit(0);
|
|
55
|
+
};
|
|
56
|
+
await new Promise(r => setTimeout(r, 800));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
})()" || true
|
|
61
|
+
|
|
30
62
|
exec ${archPrefix}"$BROWSER" \\
|
|
31
63
|
--remote-debugging-port="$PORT" \\
|
|
32
64
|
--user-data-dir="$PROFILE" \\
|
|
@@ -162,9 +194,20 @@ export function ensure(config, { force = false } = {}) {
|
|
|
162
194
|
* LaunchServices attaches the Dock icon; headless runs the launcher directly.
|
|
163
195
|
*/
|
|
164
196
|
export function launch(config, { headless } = {}) {
|
|
197
|
+
// The proxy is a per-launch flag, not baked into the (fixed) bundle, so a
|
|
198
|
+
// normal launch is never affected by it.
|
|
199
|
+
const proxyArg = config.proxy ? [`--proxy-server=${config.proxy}`] : [];
|
|
200
|
+
|
|
165
201
|
if (headless) {
|
|
166
202
|
const { executable } = bundleLayout(config.launcherPath);
|
|
167
|
-
const
|
|
203
|
+
const args = ["--headless=new"];
|
|
204
|
+
const size = getWindowSizeFromProfile(config.profileDir);
|
|
205
|
+
if (size) {
|
|
206
|
+
args.push(`--window-size=${size.width},${size.height}`);
|
|
207
|
+
args.push(`--screen-info={0,0 ${size.width}x${size.height}}`);
|
|
208
|
+
}
|
|
209
|
+
args.push(...proxyArg);
|
|
210
|
+
const child = spawn(executable, args, {
|
|
168
211
|
detached: true,
|
|
169
212
|
stdio: "ignore",
|
|
170
213
|
});
|
|
@@ -173,7 +216,10 @@ export function launch(config, { headless } = {}) {
|
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
// `open` returns immediately; readiness is awaited via waitForCdp().
|
|
176
|
-
|
|
219
|
+
// `--args` forwards the proxy flag to the bundle's launcher ("$@").
|
|
220
|
+
const openArgs = [config.launcherPath];
|
|
221
|
+
if (proxyArg.length) openArgs.push("--args", ...proxyArg);
|
|
222
|
+
const child = spawn("open", openArgs, { stdio: "ignore" });
|
|
177
223
|
child.unref();
|
|
178
224
|
}
|
|
179
225
|
|
|
@@ -3,6 +3,8 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawn, execFileSync } from "node:child_process";
|
|
5
5
|
|
|
6
|
+
import { getWindowSizeFromProfile } from "../preferences.js";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Windows launcher: a Start Menu `.lnk` shortcut with a custom icon and the CDP
|
|
8
10
|
* flags baked into its arguments. Headed launches go through the shortcut so
|
|
@@ -83,16 +85,27 @@ export function ensure(config, { force = false } = {}) {
|
|
|
83
85
|
/**
|
|
84
86
|
* Launch the browser. Headed goes through the shortcut so the taskbar uses its
|
|
85
87
|
* icon; headless runs the exe directly with the same flags + `--headless=new`.
|
|
88
|
+
*
|
|
89
|
+
* The proxy is a per-launch flag, never baked into the (fixed) shortcut. A
|
|
90
|
+
* `.lnk` can't take extra args at launch time, so a proxied headed launch runs
|
|
91
|
+
* the exe directly (the dedicated profile still gives a separate taskbar entry).
|
|
86
92
|
*/
|
|
87
93
|
export function launch(config, { headless } = {}) {
|
|
88
|
-
if (headless) {
|
|
94
|
+
if (headless || config.proxy) {
|
|
95
|
+
const args = [
|
|
96
|
+
`--remote-debugging-port=${config.cdpPort}`,
|
|
97
|
+
`--user-data-dir=${config.profileDir}`,
|
|
98
|
+
];
|
|
99
|
+
if (headless) args.push("--headless=new");
|
|
100
|
+
if (config.proxy) args.push(`--proxy-server=${config.proxy}`);
|
|
101
|
+
const size = getWindowSizeFromProfile(config.profileDir);
|
|
102
|
+
if (size) {
|
|
103
|
+
args.push(`--window-size=${size.width},${size.height}`);
|
|
104
|
+
args.push(`--screen-info={0,0 ${size.width}x${size.height}}`);
|
|
105
|
+
}
|
|
89
106
|
const child = spawn(
|
|
90
107
|
config.browserPath,
|
|
91
|
-
|
|
92
|
-
`--remote-debugging-port=${config.cdpPort}`,
|
|
93
|
-
`--user-data-dir=${config.profileDir}`,
|
|
94
|
-
"--headless=new",
|
|
95
|
-
],
|
|
108
|
+
args,
|
|
96
109
|
{ detached: true, stdio: "ignore" },
|
|
97
110
|
);
|
|
98
111
|
child.unref();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reads the last closed window dimensions from the Chrome profile Preferences file.
|
|
6
|
+
* @param {string} profileDir
|
|
7
|
+
* @returns {{ width: number, height: number, maximized: boolean } | null}
|
|
8
|
+
*/
|
|
9
|
+
export function getWindowSizeFromProfile(profileDir) {
|
|
10
|
+
try {
|
|
11
|
+
const preferencesPath = path.join(profileDir, "Default", "Preferences");
|
|
12
|
+
if (fs.existsSync(preferencesPath)) {
|
|
13
|
+
const content = fs.readFileSync(preferencesPath, "utf8");
|
|
14
|
+
const prefs = JSON.parse(content);
|
|
15
|
+
const placement = prefs?.browser?.window_placement;
|
|
16
|
+
if (placement) {
|
|
17
|
+
const { left, top, right, bottom, maximized } = placement;
|
|
18
|
+
const width = right - left;
|
|
19
|
+
const height = bottom - top;
|
|
20
|
+
if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
|
|
21
|
+
return { width, height, maximized: Boolean(maximized) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Ignore and fallback
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proxy support: normalise a user-supplied proxy value into a Chrome
|
|
5
|
+
* `--proxy-server` string and probe whether something is actually listening on
|
|
6
|
+
* it. The default is a local SOCKS5 proxy on port 1080 (the common SSH/`ssh -D`
|
|
7
|
+
* and shadowsocks default).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_PROXY_HOST = "127.0.0.1";
|
|
11
|
+
export const DEFAULT_PROXY_PORT = 1080;
|
|
12
|
+
export const DEFAULT_PROXY_SCHEME = "socks5";
|
|
13
|
+
export const DEFAULT_PROXY = `${DEFAULT_PROXY_SCHEME}://${DEFAULT_PROXY_HOST}:${DEFAULT_PROXY_PORT}`;
|
|
14
|
+
|
|
15
|
+
/** Chrome accepts these proxy schemes for `--proxy-server`. */
|
|
16
|
+
const KNOWN_SCHEMES = new Set(["socks5", "socks4", "http", "https"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a proxy value into its parts. Accepts, in order of convenience:
|
|
20
|
+
* - `true` / "" / "default" → the default SOCKS5 proxy
|
|
21
|
+
* - "1080" → socks5://127.0.0.1:1080
|
|
22
|
+
* - "host:1080" → socks5://host:1080
|
|
23
|
+
* - "socks5://host:1080" → as given
|
|
24
|
+
* - "http://host:8080" → an HTTP proxy
|
|
25
|
+
* @returns {{ server: string, scheme: string, host: string, port: number }}
|
|
26
|
+
*/
|
|
27
|
+
export function parseProxy(value) {
|
|
28
|
+
if (value === true || value === undefined || value === null) {
|
|
29
|
+
value = DEFAULT_PROXY;
|
|
30
|
+
}
|
|
31
|
+
let raw = String(value).trim();
|
|
32
|
+
if (raw === "" || raw.toLowerCase() === "default") raw = DEFAULT_PROXY;
|
|
33
|
+
|
|
34
|
+
// Port-only shorthand: "1080".
|
|
35
|
+
if (/^\d+$/.test(raw)) {
|
|
36
|
+
raw = `${DEFAULT_PROXY_SCHEME}://${DEFAULT_PROXY_HOST}:${raw}`;
|
|
37
|
+
} else if (!raw.includes("://")) {
|
|
38
|
+
// "host:port" shorthand — default the scheme to socks5.
|
|
39
|
+
raw = `${DEFAULT_PROXY_SCHEME}://${raw}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Invalid --proxy "${value}". Use a port (1080), host:port, or scheme://host:port.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const scheme = url.protocol.replace(/:$/, "").toLowerCase();
|
|
52
|
+
if (!KNOWN_SCHEMES.has(scheme)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Unsupported proxy scheme "${scheme}". Use one of: ${[...KNOWN_SCHEMES].join(", ")}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const host = url.hostname || DEFAULT_PROXY_HOST;
|
|
59
|
+
const port = url.port
|
|
60
|
+
? Number(url.port)
|
|
61
|
+
: scheme.startsWith("socks")
|
|
62
|
+
? DEFAULT_PROXY_PORT
|
|
63
|
+
: 8080;
|
|
64
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
65
|
+
throw new Error(`Invalid proxy port in "${value}". Use an integer 1-65535.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { server: `${scheme}://${host}:${port}`, scheme, host, port };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Probe a TCP port to see whether a proxy is actually listening there. This is
|
|
73
|
+
* a connectivity check only — it can't verify the protocol — but it catches the
|
|
74
|
+
* common case of routing traffic through a proxy that isn't running.
|
|
75
|
+
* @returns {Promise<boolean>}
|
|
76
|
+
*/
|
|
77
|
+
export function detectProxy({ host, port, timeoutMs = 1500 } = {}) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const socket = new net.Socket();
|
|
80
|
+
let settled = false;
|
|
81
|
+
const finish = (reachable) => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
settled = true;
|
|
84
|
+
socket.destroy();
|
|
85
|
+
resolve(reachable);
|
|
86
|
+
};
|
|
87
|
+
socket.setTimeout(timeoutMs);
|
|
88
|
+
socket.once("connect", () => finish(true));
|
|
89
|
+
socket.once("timeout", () => finish(false));
|
|
90
|
+
socket.once("error", () => finish(false));
|
|
91
|
+
socket.connect(port, host);
|
|
92
|
+
});
|
|
93
|
+
}
|
package/types/config.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export function computeDefaults({ browser, platform, }?: {
|
|
|
10
10
|
bundleId: string;
|
|
11
11
|
cdpPort: number;
|
|
12
12
|
profileDir: any;
|
|
13
|
+
proxy: any;
|
|
13
14
|
};
|
|
14
15
|
/** Load persisted config, falling back to {@link DEFAULTS} for missing keys. */
|
|
15
16
|
export function loadConfig(): any;
|
|
@@ -24,5 +25,6 @@ export const DEFAULTS: Readonly<{
|
|
|
24
25
|
bundleId: string;
|
|
25
26
|
cdpPort: number;
|
|
26
27
|
profileDir: any;
|
|
28
|
+
proxy: any;
|
|
27
29
|
}>;
|
|
28
30
|
export const CONFIG_FILE: any;
|
|
@@ -14,6 +14,10 @@ export function ensure(config: any, { force }?: {
|
|
|
14
14
|
/**
|
|
15
15
|
* Launch the browser. Headed goes through the shortcut so the taskbar uses its
|
|
16
16
|
* icon; headless runs the exe directly with the same flags + `--headless=new`.
|
|
17
|
+
*
|
|
18
|
+
* The proxy is a per-launch flag, never baked into the (fixed) shortcut. A
|
|
19
|
+
* `.lnk` can't take extra args at launch time, so a proxied headed launch runs
|
|
20
|
+
* the exe directly (the dedicated profile still gives a separate taskbar entry).
|
|
17
21
|
*/
|
|
18
22
|
export function launch(config: any, { headless }?: {}): void;
|
|
19
23
|
export const targetLabel: "Shortcut";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the last closed window dimensions from the Chrome profile Preferences file.
|
|
3
|
+
* @param {string} profileDir
|
|
4
|
+
* @returns {{ width: number, height: number, maximized: boolean } | null}
|
|
5
|
+
*/
|
|
6
|
+
export function getWindowSizeFromProfile(profileDir: string): {
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
maximized: boolean;
|
|
10
|
+
} | null;
|
package/types/proxy.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a proxy value into its parts. Accepts, in order of convenience:
|
|
3
|
+
* - `true` / "" / "default" → the default SOCKS5 proxy
|
|
4
|
+
* - "1080" → socks5://127.0.0.1:1080
|
|
5
|
+
* - "host:1080" → socks5://host:1080
|
|
6
|
+
* - "socks5://host:1080" → as given
|
|
7
|
+
* - "http://host:8080" → an HTTP proxy
|
|
8
|
+
* @returns {{ server: string, scheme: string, host: string, port: number }}
|
|
9
|
+
*/
|
|
10
|
+
export function parseProxy(value: any): {
|
|
11
|
+
server: string;
|
|
12
|
+
scheme: string;
|
|
13
|
+
host: string;
|
|
14
|
+
port: number;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Probe a TCP port to see whether a proxy is actually listening there. This is
|
|
18
|
+
* a connectivity check only — it can't verify the protocol — but it catches the
|
|
19
|
+
* common case of routing traffic through a proxy that isn't running.
|
|
20
|
+
* @returns {Promise<boolean>}
|
|
21
|
+
*/
|
|
22
|
+
export function detectProxy({ host, port, timeoutMs }?: {
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Proxy support: normalise a user-supplied proxy value into a Chrome
|
|
27
|
+
* `--proxy-server` string and probe whether something is actually listening on
|
|
28
|
+
* it. The default is a local SOCKS5 proxy on port 1080 (the common SSH/`ssh -D`
|
|
29
|
+
* and shadowsocks default).
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_PROXY_HOST: "127.0.0.1";
|
|
32
|
+
export const DEFAULT_PROXY_PORT: 1080;
|
|
33
|
+
export const DEFAULT_PROXY_SCHEME: "socks5";
|
|
34
|
+
export const DEFAULT_PROXY: "socks5://127.0.0.1:1080";
|