camoufox-playwright-cli 0.1.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 +63 -0
- package/bin/cli.js +172 -0
- package/package.json +39 -0
- package/src/host.js +86 -0
- package/src/paths.js +19 -0
- package/src/profiles.js +145 -0
- package/src/webgl.js +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shun
|
|
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,63 @@
|
|
|
1
|
+
# camoufox-playwright-cli
|
|
2
|
+
|
|
3
|
+
Official [`@playwright/cli`](https://github.com/microsoft/playwright-cli) (Microsoft's Playwright CLI — open, click, fill, snapshot, tabs, network, tracing, video, ...), driven by [Camoufox](https://camoufox.com) instead of stock Chromium/Firefox, so the browser doesn't look automated.
|
|
4
|
+
|
|
5
|
+
No fork of playwright-cli, no monkey-patching. Camoufox's stealth is just three standard Playwright launch options — `executablePath`, `firefoxUserPrefs`, and a `CAMOU_CONFIG_*` env blob — and playwright-cli already passes those through from a config file. This package generates that config for you and manages it as named **profiles**.
|
|
6
|
+
|
|
7
|
+
Short alias: `cpw` (same binary as `camoufox-playwright-cli`).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g camoufox-playwright-cli
|
|
13
|
+
cpw install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`install` downloads the Camoufox browser binary (shared cache, only happens once even across other Camoufox-based tools) and generates a `default` profile.
|
|
17
|
+
|
|
18
|
+
## Profiles
|
|
19
|
+
|
|
20
|
+
A profile is a fingerprint identity + its own isolated browser data (cookies, localStorage, login sessions) — think of it as "one persona". The `default` profile is created automatically and its fingerprint is generated from **your actual machine** where possible:
|
|
21
|
+
|
|
22
|
+
- Real GPU vendor (via `Get-CimInstance Win32_VideoController` on Windows) — matched to the closest vendor bucket in Camoufox's curated WebGL database. Camoufox only ships a fixed, vetted set of older reference GPU models per vendor (no RTX 20/30/40-series entries at all, for example), so the *vendor* will match your real hardware but the exact renderer string won't be your literal card model. That vendor-match is what fingerprinting scripts actually cross-check; matching the exact model isn't possible through Camoufox's public API without maintaining a separate spoofing database, which is out of scope here.
|
|
23
|
+
- Real screen resolution, locale, and timezone (via `Intl` + WMI).
|
|
24
|
+
|
|
25
|
+
If any of these can't be detected (non-Windows, sandboxed environment, etc.) that field silently falls back to Camoufox's own randomized default — it never errors out.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cpw profile list
|
|
29
|
+
cpw profile create work
|
|
30
|
+
cpw profile rotate work # wipes cookies + regenerates fingerprint
|
|
31
|
+
cpw profile delete work
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Rotating or deleting a profile always wipes its browser session data first — keeping old cookies with a new fingerprint is itself an inconsistency signal, so we don't allow that combination.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
Everything besides `install` and `profile` is forwarded verbatim to the real `playwright-cli`, with `--profile` mapped to its `-s=` session flag and (for `open`) `--config=` pointed at that profile's generated config:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cpw open https://example.com
|
|
42
|
+
cpw snapshot
|
|
43
|
+
cpw click e3
|
|
44
|
+
cpw fill e5 "hello"
|
|
45
|
+
cpw screenshot --filename out.png
|
|
46
|
+
cpw close
|
|
47
|
+
|
|
48
|
+
cpw --profile=work open https://example.com # separate identity + separate cookies
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
See the [playwright-cli command reference](https://github.com/microsoft/playwright-cli) for the full list — tabs, network routing, storage state, tracing, video recording, etc. all work unmodified.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
- [`camoufox-js`](https://github.com/apify/camoufox-js)'s `launchOptions()` computes `{executablePath, env, firefoxUserPrefs}` for a given fingerprint.
|
|
56
|
+
- We write that into `~/.camoufox-playwright-cli/profiles/<name>/cli.config.json`, in the shape `@playwright/cli` expects (`browser.launchOptions`).
|
|
57
|
+
- `cpw <command>` is a thin wrapper: it ensures that config exists, then spawns the real `playwright-cli` binary with `-s=<profile>` (and `--config=` on `open`) injected.
|
|
58
|
+
|
|
59
|
+
Dependency versions (`@playwright/cli`, `camoufox-js`) are pinned exactly, not range-matched — both are young/prerelease packages and an unpinned `npm install` could silently pick up a breaking protocol change between Playwright's internal Firefox/Juggler driver and Camoufox's patched binary. Upgrades are deliberate, one version bump at a time, tested against the checks in this README before being released.
|
|
60
|
+
|
|
61
|
+
## Security note
|
|
62
|
+
|
|
63
|
+
The `env` passed to the Camoufox process is an explicit allowlist (`PATH`, temp dirs, etc.) — never your full shell environment. Don't widen it without checking what secrets might be sitting in `process.env`.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_PROFILE,
|
|
5
|
+
ensureProfile,
|
|
6
|
+
profileConfigPath,
|
|
7
|
+
profileExists,
|
|
8
|
+
listProfiles,
|
|
9
|
+
deleteProfileFiles,
|
|
10
|
+
} from "../src/profiles.js";
|
|
11
|
+
import { playwrightCliBin, camoufoxJsBin } from "../src/paths.js";
|
|
12
|
+
|
|
13
|
+
function extractProfileFlag(argv) {
|
|
14
|
+
let profile = DEFAULT_PROFILE;
|
|
15
|
+
const rest = [];
|
|
16
|
+
for (const arg of argv) {
|
|
17
|
+
if (arg.startsWith("--profile=")) {
|
|
18
|
+
profile = arg.slice("--profile=".length);
|
|
19
|
+
} else {
|
|
20
|
+
rest.push(arg);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { profile, rest };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
const { profile, rest } = extractProfileFlag(process.argv.slice(2));
|
|
28
|
+
const [command, ...args] = rest;
|
|
29
|
+
|
|
30
|
+
switch (command) {
|
|
31
|
+
case "install":
|
|
32
|
+
return runInstall();
|
|
33
|
+
case "profile":
|
|
34
|
+
return runProfile(args);
|
|
35
|
+
case undefined:
|
|
36
|
+
case "-h":
|
|
37
|
+
case "--help":
|
|
38
|
+
return printHelp();
|
|
39
|
+
default:
|
|
40
|
+
return runPlaywrightCli(profile, rest);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runCamoufoxFetch() {
|
|
45
|
+
const result = spawnSync(process.execPath, [camoufoxJsBin(), "fetch"], { stdio: "inherit" });
|
|
46
|
+
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Stops the daemon (if running) and wipes its browser profile dir (cookies,
|
|
50
|
+
// storage). Used before rotate/delete so a new fingerprint never keeps the
|
|
51
|
+
// old identity's login state — a fingerprint change with stale cookies is
|
|
52
|
+
// itself an inconsistency signal.
|
|
53
|
+
function resetSessionData(profileName) {
|
|
54
|
+
spawnSync(process.execPath, [playwrightCliBin(), `-s=${profileName}`, "close"], { stdio: "ignore" });
|
|
55
|
+
spawnSync(process.execPath, [playwrightCliBin(), `-s=${profileName}`, "delete-data"], { stdio: "ignore" });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runInstall() {
|
|
59
|
+
console.log("Downloading Camoufox browser binary (this only needs to run once)...");
|
|
60
|
+
runCamoufoxFetch();
|
|
61
|
+
|
|
62
|
+
console.log("\nGenerating default profile from your machine's real parameters...");
|
|
63
|
+
const file = await ensureProfile(DEFAULT_PROFILE);
|
|
64
|
+
console.log(`Profile written to ${file}`);
|
|
65
|
+
console.log("\nDone. Try: camoufox-playwright-cli open https://example.com");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function runProfile(args) {
|
|
69
|
+
const [sub, name] = args;
|
|
70
|
+
switch (sub) {
|
|
71
|
+
case "list": {
|
|
72
|
+
const profiles = await listProfiles();
|
|
73
|
+
if (profiles.length === 0) {
|
|
74
|
+
console.log("No profiles yet. Run `camoufox-playwright-cli install` first.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const p of profiles) {
|
|
78
|
+
console.log(`${p.name}${p.name === DEFAULT_PROFILE ? " (default)" : ""}`);
|
|
79
|
+
console.log(` created: ${p.createdAt}`);
|
|
80
|
+
console.log(` os: ${p.os} locale: ${p.locale} timezone: ${p.timezone}`);
|
|
81
|
+
console.log(` screen: ${JSON.stringify(p.screen)}`);
|
|
82
|
+
console.log(` gpu: ${JSON.stringify(p.gpu)}`);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
case "create": {
|
|
87
|
+
if (!name) return console.error("Usage: camoufox-playwright-cli profile create <name>");
|
|
88
|
+
if (profileExists(name)) return console.error(`Profile "${name}" already exists. Use \`profile rotate ${name}\` to regenerate it.`);
|
|
89
|
+
const file = await ensureProfile(name);
|
|
90
|
+
console.log(`Created profile "${name}" -> ${file}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
case "rotate": {
|
|
94
|
+
const target = name || DEFAULT_PROFILE;
|
|
95
|
+
if (!profileExists(target)) return console.error(`Profile "${target}" does not exist. Use \`profile create ${target}\` first.`);
|
|
96
|
+
resetSessionData(target);
|
|
97
|
+
const file = await ensureProfile(target, { rotate: true });
|
|
98
|
+
console.log(`Rotated profile "${target}" (browser data wiped, new fingerprint generated) -> ${file}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
case "delete": {
|
|
102
|
+
if (!name) return console.error("Usage: camoufox-playwright-cli profile delete <name>");
|
|
103
|
+
if (name === DEFAULT_PROFILE) return console.error(`Refusing to delete the "${DEFAULT_PROFILE}" profile. Use \`profile rotate\` instead.`);
|
|
104
|
+
resetSessionData(name);
|
|
105
|
+
const existed = await deleteProfileFiles(name);
|
|
106
|
+
console.log(existed ? `Deleted profile "${name}"` : `Profile "${name}" did not exist`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
default:
|
|
110
|
+
console.log(`Usage:
|
|
111
|
+
camoufox-playwright-cli profile list
|
|
112
|
+
camoufox-playwright-cli profile create <name>
|
|
113
|
+
camoufox-playwright-cli profile rotate [name] (defaults to "default")
|
|
114
|
+
camoufox-playwright-cli profile delete <name>`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function runPlaywrightCli(profile, argv) {
|
|
119
|
+
if (!profileExists(profile)) {
|
|
120
|
+
if (profile === DEFAULT_PROFILE) {
|
|
121
|
+
console.log("No default profile yet - running setup first...");
|
|
122
|
+
runCamoufoxFetch();
|
|
123
|
+
await ensureProfile(DEFAULT_PROFILE);
|
|
124
|
+
} else {
|
|
125
|
+
console.error(`Profile "${profile}" does not exist. Create it with: camoufox-playwright-cli profile create ${profile}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const configFile = profileConfigPath(profile);
|
|
131
|
+
const finalArgs = [...argv];
|
|
132
|
+
|
|
133
|
+
if (!finalArgs.some((a) => a === "-s" || a.startsWith("-s="))) {
|
|
134
|
+
finalArgs.push(`-s=${profile}`);
|
|
135
|
+
}
|
|
136
|
+
if (finalArgs[0] === "open" && !finalArgs.some((a) => a.startsWith("--config"))) {
|
|
137
|
+
finalArgs.push(`--config=${configFile}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const result = spawnSync(process.execPath, [playwrightCliBin(), ...finalArgs], { stdio: "inherit" });
|
|
141
|
+
process.exit(result.status ?? 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function printHelp() {
|
|
145
|
+
console.log(`camoufox-playwright-cli (alias: cpw) - Playwright CLI (@playwright/cli), driven by Camoufox (stealth Firefox)
|
|
146
|
+
|
|
147
|
+
Setup (run once):
|
|
148
|
+
cpw install Download Camoufox + generate the "default" profile
|
|
149
|
+
|
|
150
|
+
Profiles (fingerprint + isolated browser data, one per identity):
|
|
151
|
+
cpw profile list
|
|
152
|
+
cpw profile create <name>
|
|
153
|
+
cpw profile rotate [name] Wipe cookies + regenerate fingerprint (defaults to "default")
|
|
154
|
+
cpw profile delete <name>
|
|
155
|
+
|
|
156
|
+
Everything else is forwarded to the real playwright-cli, pointed at Camoufox.
|
|
157
|
+
Use --profile=<name> to pick which identity to drive (defaults to "default"):
|
|
158
|
+
cpw open <url>
|
|
159
|
+
cpw --profile=work open <url>
|
|
160
|
+
cpw snapshot
|
|
161
|
+
cpw click <ref>
|
|
162
|
+
cpw fill <ref> <text>
|
|
163
|
+
cpw screenshot
|
|
164
|
+
cpw close
|
|
165
|
+
|
|
166
|
+
("camoufox-playwright-cli" also works everywhere "cpw" does — same binary, two names.)
|
|
167
|
+
|
|
168
|
+
See https://github.com/microsoft/playwright-cli for the full command reference.
|
|
169
|
+
`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "camoufox-playwright-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Playwright CLI (@playwright/cli), driven by Camoufox for stealth browsing. Profile-based: auto-generated default fingerprint on install, create/rotate/delete named profiles.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"camoufox-playwright-cli": "bin/cli.js",
|
|
8
|
+
"cpw": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Shun",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/ShunL12324/camoufox-playwright-cli.git"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"camoufox",
|
|
27
|
+
"playwright",
|
|
28
|
+
"stealth",
|
|
29
|
+
"anti-detect",
|
|
30
|
+
"browser-automation",
|
|
31
|
+
"cli",
|
|
32
|
+
"profiles"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@playwright/cli": "0.1.18",
|
|
36
|
+
"better-sqlite3": "13.0.1",
|
|
37
|
+
"camoufox-js": "0.12.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/host.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Best-effort detection of real host parameters so the generated fingerprint
|
|
4
|
+
// matches the machine it's actually running on (real GPU vendor, real
|
|
5
|
+
// screen size, real locale/timezone) instead of a fully random combo that
|
|
6
|
+
// can look inconsistent (e.g. a 4K screen paired with a decade-old GPU).
|
|
7
|
+
// Every probe is independently wrapped in try/catch: if a probe fails
|
|
8
|
+
// (unsupported OS, no PowerShell, sandboxed env, ...) that field is simply
|
|
9
|
+
// omitted and camoufox-js falls back to its own randomized default for it.
|
|
10
|
+
|
|
11
|
+
function detectOS() {
|
|
12
|
+
switch (process.platform) {
|
|
13
|
+
case "win32":
|
|
14
|
+
return "windows";
|
|
15
|
+
case "darwin":
|
|
16
|
+
return "macos";
|
|
17
|
+
case "linux":
|
|
18
|
+
return "linux";
|
|
19
|
+
default:
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function detectLocaleAndTimezone() {
|
|
25
|
+
try {
|
|
26
|
+
const opts = Intl.DateTimeFormat().resolvedOptions();
|
|
27
|
+
return { locale: opts.locale, timezone: opts.timeZone };
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function detectScreen() {
|
|
34
|
+
if (process.platform !== "win32") return undefined;
|
|
35
|
+
try {
|
|
36
|
+
const out = execFileSync(
|
|
37
|
+
"powershell",
|
|
38
|
+
[
|
|
39
|
+
"-NoProfile",
|
|
40
|
+
"-Command",
|
|
41
|
+
"Get-CimInstance Win32_VideoController | Select-Object -First 1 CurrentHorizontalResolution,CurrentVerticalResolution | ConvertTo-Json",
|
|
42
|
+
],
|
|
43
|
+
{ encoding: "utf-8", timeout: 10_000 },
|
|
44
|
+
);
|
|
45
|
+
const data = JSON.parse(out);
|
|
46
|
+
const width = data.CurrentHorizontalResolution;
|
|
47
|
+
const height = data.CurrentVerticalResolution;
|
|
48
|
+
if (!width || !height) return undefined;
|
|
49
|
+
return { width, height };
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Maps a real GPU name string to one of the vendor buckets Camoufox ships
|
|
56
|
+
// curated (vendor, renderer) WebGL fingerprints for. Camoufox's database
|
|
57
|
+
// only covers a fixed, vetted set of older GPU models per vendor (see
|
|
58
|
+
// README for why) - we can match the real vendor, not the exact model.
|
|
59
|
+
function detectGpuVendor() {
|
|
60
|
+
if (process.platform !== "win32") return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const name = execFileSync(
|
|
63
|
+
"powershell",
|
|
64
|
+
["-NoProfile", "-Command", "(Get-CimInstance Win32_VideoController | Select-Object -First 1).Name"],
|
|
65
|
+
{ encoding: "utf-8", timeout: 10_000 },
|
|
66
|
+
).trim();
|
|
67
|
+
if (!name) return undefined;
|
|
68
|
+
if (/nvidia|geforce|quadro|rtx|gtx/i.test(name)) return { raw: name, vendor: "nvidia" };
|
|
69
|
+
if (/amd|radeon/i.test(name)) return { raw: name, vendor: "amd" };
|
|
70
|
+
if (/intel/i.test(name)) return { raw: name, vendor: "intel" };
|
|
71
|
+
return { raw: name, vendor: undefined };
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function detectHost() {
|
|
78
|
+
const { locale, timezone } = detectLocaleAndTimezone();
|
|
79
|
+
return {
|
|
80
|
+
os: detectOS(),
|
|
81
|
+
locale,
|
|
82
|
+
timezone,
|
|
83
|
+
screen: detectScreen(),
|
|
84
|
+
gpu: detectGpuVendor(),
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
export function playwrightCliBin() {
|
|
7
|
+
const pkgJsonPath = require.resolve("@playwright/cli/package.json");
|
|
8
|
+
const pkgDir = path.dirname(pkgJsonPath);
|
|
9
|
+
const pkg = require(pkgJsonPath);
|
|
10
|
+
return path.join(pkgDir, pkg.bin["playwright-cli"]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function camoufoxJsBin() {
|
|
14
|
+
const pkgJsonPath = require.resolve("camoufox-js/package.json");
|
|
15
|
+
const pkgDir = path.dirname(pkgJsonPath);
|
|
16
|
+
const pkg = require(pkgJsonPath);
|
|
17
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin)[0];
|
|
18
|
+
return path.join(pkgDir, bin);
|
|
19
|
+
}
|
package/src/profiles.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { launchOptions } from "camoufox-js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, writeFile, readFile, rm, readdir } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { detectHost } from "./host.js";
|
|
7
|
+
import { pickVendorMatchedWebgl } from "./webgl.js";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_PROFILE = "default";
|
|
10
|
+
|
|
11
|
+
// Only pass through what the Camoufox binary actually needs to start.
|
|
12
|
+
// launchOptions() defaults env to process.env, which would leak every
|
|
13
|
+
// secret in the caller's shell into the CAMOU_CONFIG env blob and into
|
|
14
|
+
// the config.json file on disk. Never widen this without a reason.
|
|
15
|
+
const SAFE_ENV_KEYS = [
|
|
16
|
+
"PATH",
|
|
17
|
+
"SYSTEMROOT",
|
|
18
|
+
"WINDIR",
|
|
19
|
+
"TEMP",
|
|
20
|
+
"TMP",
|
|
21
|
+
"USERPROFILE",
|
|
22
|
+
"HOME",
|
|
23
|
+
"DISPLAY", // Linux/X11 headed mode
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function safeEnv() {
|
|
27
|
+
const env = {};
|
|
28
|
+
for (const key of SAFE_ENV_KEYS) {
|
|
29
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
30
|
+
}
|
|
31
|
+
return env;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ROOT_DIR = path.join(os.homedir(), ".camoufox-playwright-cli");
|
|
35
|
+
const PROFILES_DIR = path.join(ROOT_DIR, "profiles");
|
|
36
|
+
|
|
37
|
+
function isValidName(name) {
|
|
38
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function profileDir(name) {
|
|
42
|
+
if (!isValidName(name)) {
|
|
43
|
+
throw new Error(`Invalid profile name "${name}" (use letters, numbers, - and _ only)`);
|
|
44
|
+
}
|
|
45
|
+
return path.join(PROFILES_DIR, name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function profileConfigPath(name) {
|
|
49
|
+
return path.join(profileDir(name), "cli.config.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function profileMetaPath(name) {
|
|
53
|
+
return path.join(profileDir(name), "meta.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function profileExists(name) {
|
|
57
|
+
return existsSync(profileConfigPath(name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function listProfiles() {
|
|
61
|
+
if (!existsSync(PROFILES_DIR)) return [];
|
|
62
|
+
const names = await readdir(PROFILES_DIR);
|
|
63
|
+
const profiles = [];
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
if (!profileExists(name)) continue;
|
|
66
|
+
const meta = JSON.parse(await readFile(profileMetaPath(name), "utf-8").catch(() => "{}"));
|
|
67
|
+
profiles.push({ name, ...meta });
|
|
68
|
+
}
|
|
69
|
+
return profiles;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readProfileConfig(name) {
|
|
73
|
+
if (!profileExists(name)) return null;
|
|
74
|
+
return JSON.parse(await readFile(profileConfigPath(name), "utf-8"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Creates a profile if it doesn't exist, or regenerates it in place when
|
|
78
|
+
// `rotate` is true. The generated fingerprint prefers real host parameters
|
|
79
|
+
// (GPU vendor, screen size, locale/timezone) over random ones — see
|
|
80
|
+
// host.js and webgl.js for what's detected and why exact GPU model spoofing
|
|
81
|
+
// isn't possible. Any detection failure just falls back to camoufox-js's
|
|
82
|
+
// own randomized default for that field; it never throws.
|
|
83
|
+
export async function ensureProfile(name, { rotate = false } = {}) {
|
|
84
|
+
if (!rotate && profileExists(name)) {
|
|
85
|
+
return profileConfigPath(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await mkdir(profileDir(name), { recursive: true });
|
|
89
|
+
|
|
90
|
+
const host = detectHost();
|
|
91
|
+
const targetOs = host.os || "windows";
|
|
92
|
+
const webglConfig = host.gpu?.vendor
|
|
93
|
+
? pickVendorMatchedWebgl(targetOs, host.gpu.vendor)
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
const opts = await launchOptions({
|
|
97
|
+
os: targetOs,
|
|
98
|
+
humanize: true,
|
|
99
|
+
locale: host.locale ? [host.locale] : undefined,
|
|
100
|
+
screen: host.screen
|
|
101
|
+
? {
|
|
102
|
+
minWidth: host.screen.width,
|
|
103
|
+
maxWidth: host.screen.width,
|
|
104
|
+
minHeight: host.screen.height,
|
|
105
|
+
maxHeight: host.screen.height,
|
|
106
|
+
}
|
|
107
|
+
: undefined,
|
|
108
|
+
webgl_config: webglConfig,
|
|
109
|
+
env: safeEnv(),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const config = {
|
|
113
|
+
browser: {
|
|
114
|
+
browserName: "firefox",
|
|
115
|
+
launchOptions: {
|
|
116
|
+
executablePath: opts.executablePath,
|
|
117
|
+
args: opts.args,
|
|
118
|
+
env: opts.env,
|
|
119
|
+
firefoxUserPrefs: opts.firefoxUserPrefs,
|
|
120
|
+
headless: opts.headless,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const meta = {
|
|
126
|
+
createdAt: new Date().toISOString(),
|
|
127
|
+
os: targetOs,
|
|
128
|
+
locale: host.locale ?? "randomized (host detection failed)",
|
|
129
|
+
timezone: host.timezone ?? "randomized (host detection failed)",
|
|
130
|
+
screen: host.screen ?? "randomized (host detection failed)",
|
|
131
|
+
gpu: host.gpu
|
|
132
|
+
? { real: host.gpu.raw, spoofedAs: webglConfig ? webglConfig[1] : "no vendor match in Camoufox DB, randomized" }
|
|
133
|
+
: "randomized (host detection failed or non-Windows)",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
await writeFile(profileConfigPath(name), JSON.stringify(config, null, 2));
|
|
137
|
+
await writeFile(profileMetaPath(name), JSON.stringify(meta, null, 2));
|
|
138
|
+
return profileConfigPath(name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function deleteProfileFiles(name) {
|
|
142
|
+
if (!existsSync(profileDir(name))) return false;
|
|
143
|
+
await rm(profileDir(name), { recursive: true, force: true });
|
|
144
|
+
return true;
|
|
145
|
+
}
|
package/src/webgl.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
// Camoufox ships a curated SQLite DB of (vendor, renderer) WebGL pairs that
|
|
8
|
+
// have been vetted to render a consistent, believable fingerprint. It does
|
|
9
|
+
// NOT cover every real GPU (no RTX 20/30/40-series entries, for example) -
|
|
10
|
+
// only a fixed set of older reference models per vendor bucket. We can't
|
|
11
|
+
// spoof the exact real GPU model through this API, but we CAN pick a combo
|
|
12
|
+
// whose *vendor* matches the real GPU, which is the part fingerprinting
|
|
13
|
+
// scripts actually cross-check against other signals (CPU core count,
|
|
14
|
+
// platform string, etc). Exact-model spoofing would require building and
|
|
15
|
+
// maintaining our own WebGL fingerprint database - out of scope here.
|
|
16
|
+
|
|
17
|
+
const VENDOR_BUCKET_TO_CAMOUFOX = {
|
|
18
|
+
nvidia: "Google Inc. (NVIDIA)",
|
|
19
|
+
amd: "Google Inc. (AMD)",
|
|
20
|
+
intel: "Google Inc. (Intel)",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const OS_COLUMN = { windows: "win", macos: "mac", linux: "lin" };
|
|
24
|
+
|
|
25
|
+
function dbPath() {
|
|
26
|
+
const camoufoxJsPkg = require.resolve("camoufox-js/package.json");
|
|
27
|
+
return path.join(path.dirname(camoufoxJsPkg), "dist", "data-files", "webgl_data.db");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Returns a [vendor, renderer] tuple for camoufox-js's `webgl_config` option,
|
|
31
|
+
// or undefined if the real vendor couldn't be detected / has no bucket.
|
|
32
|
+
export function pickVendorMatchedWebgl(os, gpuVendorBucket) {
|
|
33
|
+
const camoufoxVendor = VENDOR_BUCKET_TO_CAMOUFOX[gpuVendorBucket];
|
|
34
|
+
const osColumn = OS_COLUMN[os];
|
|
35
|
+
if (!camoufoxVendor || !osColumn) return undefined;
|
|
36
|
+
|
|
37
|
+
const db = new Database(dbPath(), { readonly: true });
|
|
38
|
+
try {
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare(
|
|
41
|
+
`SELECT vendor, renderer FROM webgl_fingerprints WHERE vendor = ? AND ${osColumn} > 0`,
|
|
42
|
+
)
|
|
43
|
+
.all(camoufoxVendor);
|
|
44
|
+
if (rows.length === 0) return undefined;
|
|
45
|
+
return [rows[0].vendor, rows[0].renderer];
|
|
46
|
+
} finally {
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
}
|