mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 08:00:19 +00:00
#798 left RDM on for isMobile, as Playwright's Juggler does. RDM matches no real browser: Firefox for Android never runs it, and under touch emulation it drops a mouse click's pointer events, which no device does. Camoufox only has desktop identities, so an is_mobile context was a desktop UA, platform, fonts and GPU with devtools' mobile mode on top. Juggler now keeps inRDMPane off for every page, is_mobile included, and the isMobile plumbing #798 added is gone again. viewport-no-rdm requires is_mobile=True to keep the platform's scrollbars too. Both launchers warn instead (warnings.yml is_mobile): on new_page() / new_context(is_mobile=True) of a Camoufox browser, and on is_mobile passed to launch_options() for a persistent context. has_touch, device_scale_factor and viewport keep working without RDM. Upstream playwright-python skips its isMobile tests on Firefox in 1.61-1.63, so no skiplist entries are needed. On beta.31 with this Juggler in omni.ja, viewport-no-rdm passes: 12/12 px of scrollbar with no viewport, with a viewport, and with is_mobile, and a has_touch click fires pointerdown and pointerup. pythonlib 411 passed; typescript 584 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1837 lines
59 KiB
TypeScript
1837 lines
59 KiB
TypeScript
/**
|
|
* Launch-option assembly: turns Camoufox's high-level options into the
|
|
* Playwright Firefox launch options plus the CAMOU_CONFIG environment.
|
|
*
|
|
* TypeScript twin of pythonlib/camoufox/utils.py. `launchOptions()` must
|
|
* produce what Python's `launch_options()` produces for the same inputs; the
|
|
* goldens in tests/fixtures/launch (scripts/golden/launch_golden.py) hold it
|
|
* to that.
|
|
*
|
|
* Every collaborator is reached through `utilsDeps`, the counterpart of the
|
|
* module globals the Python tests monkeypatch (`utils.generate_fingerprint`,
|
|
* `utils._stock_profile_disk_capacity_kb`, ...). Production code never
|
|
* touches it.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { inspect } from "node:util";
|
|
import { UAParser } from "ua-parser-js";
|
|
import { addDefaultAddons, confirmPaths, type DefaultAddon } from "./addons.js";
|
|
import * as coherence from "./coherence.js";
|
|
import * as cpuAffinity from "./cpu_affinity.js";
|
|
import { hasDisplay, largestDisplay } from "./display.js";
|
|
import {
|
|
InvalidOS,
|
|
InvalidPropertyType,
|
|
NonFirefoxFingerprint,
|
|
} from "./exceptions.js";
|
|
import {
|
|
audioSeedFromIdentity,
|
|
clampScreenToDisplay,
|
|
clampWindowDimensions,
|
|
clampWindowPosition,
|
|
fixHardwareConcurrency,
|
|
fixNavigatorArch,
|
|
fixScreenNoTaskbar,
|
|
fromFpgen,
|
|
fromPreset,
|
|
generateFingerprint,
|
|
generateRandomFontSubset,
|
|
generateRandomVoiceSubset,
|
|
getRandomPreset,
|
|
identitySalt,
|
|
identitySeed,
|
|
raiseScreenToModernFloor,
|
|
Screen,
|
|
setMediaDevicesDefaults,
|
|
WINDOWS_11_MARKER_FONTS,
|
|
} from "./fingerprints.js";
|
|
import { ensureModel } from "./fpgen/index.js";
|
|
import { geoipAllowed, getGeolocation } from "./geolocation.js";
|
|
import {
|
|
type ProxyConfig,
|
|
ProxyHelper,
|
|
publicIP,
|
|
validIPv4,
|
|
validIPv6,
|
|
} from "./ip.js";
|
|
import { handleLocales } from "./locales.js";
|
|
import {
|
|
effectiveVersionMin,
|
|
ensureBrowserProfileDir,
|
|
ensureCamoufoxInstalled,
|
|
getPath,
|
|
INSTALL_DIR,
|
|
installedVerStr,
|
|
LOCAL_DATA,
|
|
launchPath,
|
|
OS_NAME,
|
|
resolvedPlaywrightVersionStr,
|
|
Version,
|
|
} from "./pkgman.js";
|
|
import {
|
|
formatPyFloatRepr,
|
|
isPyError,
|
|
orjsonDumps,
|
|
PyFloat,
|
|
pyRepr,
|
|
pyStr,
|
|
ValueError,
|
|
} from "./pycompat.js";
|
|
import type { VirtualDisplay } from "./virtdisplay.js";
|
|
import { FallbackWarning, LeakWarning, warn } from "./warnings.js";
|
|
import { sampleWebglForScreen, webglForGpu } from "./webgl.js";
|
|
|
|
export type ListOrString = string | string[];
|
|
export type TargetOS = "mac" | "win" | "lin";
|
|
export type EnvVars = Record<string, string | number | boolean>;
|
|
|
|
// Camoufox preferences to cache previous pages and requests
|
|
export const CACHE_PREFS: Record<string, any> = {
|
|
"browser.sessionhistory.max_entries": 10,
|
|
"browser.sessionhistory.max_total_viewers": -1,
|
|
"browser.cache.memory.enable": true,
|
|
"browser.cache.disk_cache_ssl": true,
|
|
"browser.cache.disk.smart_size.enabled": true,
|
|
};
|
|
|
|
/** The host OS in fonts.json / target_os terms ('mac', 'win', 'lin'). */
|
|
function hostOsKey(): TargetOS | null {
|
|
return (
|
|
({ darwin: "mac", win32: "win", linux: "lin" } as Record<string, TargetOS>)[
|
|
process.platform
|
|
] ?? null
|
|
);
|
|
}
|
|
|
|
// navigator.storage.estimate().quota is not a constant: Gecko derives it from
|
|
// the disk. GetTemporaryStorageLimit() (dom/quota/ActorsParent.cpp) takes
|
|
// nsIFile::GetDiskCapacity() of the storage directory and halves it, then
|
|
// QuotaManager::GetGroupLimitForLimit() reports min(that / 5, 10 GiB) to the
|
|
// page -- so any disk of 100 GB or more reads back as exactly 10 GiB, and a
|
|
// smaller one as its own capacity / 10.
|
|
export const QUOTA_FIXED_LIMIT_PREF =
|
|
"dom.quotaManager.temporaryStorage.fixedLimit";
|
|
// The pref is a signed 32-bit int in KB. Any value above 50 GiB already reports
|
|
// the 10 GiB group cap, so clamping a multi-terabyte disk changes nothing a page
|
|
// can see.
|
|
const INT32_MAX = 2 ** 31 - 1;
|
|
|
|
/** shutil.disk_usage(path).total */
|
|
function diskTotal(p: string): number {
|
|
const st = fs.statfsSync(p);
|
|
return st.blocks * st.bsize;
|
|
}
|
|
|
|
/**
|
|
* Half the capacity of the disk a stock Firefox profile would live on, in KB.
|
|
*
|
|
* That is the number Gecko's own GetTemporaryStorageLimit() would compute on
|
|
* this machine, and it is what `dom.quotaManager.temporaryStorage.fixedLimit`
|
|
* takes. The disk a stock profile lives on, not the one Playwright's throwaway
|
|
* profile lands on: on a host whose temp directory is a tmpfs, that profile
|
|
* sits on a RAM-sized volume no real Firefox profile would.
|
|
*/
|
|
function stockProfileDiskCapacityKb(): number | null {
|
|
const home = os.homedir();
|
|
let candidates: string[];
|
|
const osName = utilsDeps.osName();
|
|
if (osName === "win") {
|
|
const appdata = process.env.APPDATA;
|
|
candidates = [appdata ? path.join(appdata, "Mozilla") : home, home];
|
|
} else if (osName === "mac") {
|
|
candidates = [
|
|
path.join(home, "Library", "Application Support", "Firefox"),
|
|
home,
|
|
];
|
|
} else {
|
|
candidates = [path.join(home, ".mozilla"), home];
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
// The directory only exists if Firefox has ever run here; walk up to the
|
|
// first path that does, which is on the same filesystem anyway.
|
|
let probe = candidate;
|
|
while (!fs.existsSync(probe) && probe !== path.dirname(probe)) {
|
|
probe = path.dirname(probe);
|
|
}
|
|
let total: number;
|
|
try {
|
|
total = utilsDeps.diskTotal(probe);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (total > 0) {
|
|
return Math.min(Math.floor(Math.floor(total / 2) / 1024), INT32_MAX);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Injection points: everything launchOptions() reaches outside this module.
|
|
* Mirrors the names the Python tests monkeypatch on camoufox.utils.
|
|
*/
|
|
export const utilsDeps = {
|
|
osName: (): TargetOS => OS_NAME,
|
|
installDir: (): string => INSTALL_DIR,
|
|
hostOsKey,
|
|
diskTotal,
|
|
stockProfileDiskCapacityKb,
|
|
hasDisplay,
|
|
largestDisplay,
|
|
getScreenCons: (headless?: boolean) => getScreenCons(headless),
|
|
print: (line: string): void => {
|
|
process.stdout.write(`${line}\n`);
|
|
},
|
|
ensureBrowserProfileDir,
|
|
ensureCamoufoxInstalled,
|
|
addDefaultAddons,
|
|
confirmPaths,
|
|
installedVerStr: () => installedVerStr(),
|
|
effectiveVersionMin,
|
|
resolvedPlaywrightVersionStr,
|
|
getPath,
|
|
launchPath,
|
|
identitySalt,
|
|
identitySeed,
|
|
generateFingerprint: async (
|
|
options: Parameters<typeof generateFingerprint>[0],
|
|
): Promise<Record<string, any>> => {
|
|
// fpgen's model is fetched on first use, like Python's.
|
|
await ensureModel();
|
|
return generateFingerprint(options);
|
|
},
|
|
fromFpgen,
|
|
fromPreset,
|
|
getRandomPreset,
|
|
fixNavigatorArch,
|
|
fixHardwareConcurrency,
|
|
fixScreenNoTaskbar,
|
|
clampScreenToDisplay,
|
|
clampWindowDimensions,
|
|
clampWindowPosition,
|
|
raiseScreenToModernFloor,
|
|
generateRandomFontSubset,
|
|
generateRandomVoiceSubset,
|
|
setMediaDevicesDefaults,
|
|
// The WebGL draws read fpgen's model, fetched on first use like Python's.
|
|
webglForGpu: async (...args: Parameters<typeof webglForGpu>) => {
|
|
await ensureModel();
|
|
return webglForGpu(...args);
|
|
},
|
|
sampleWebglForScreen: async (
|
|
...args: Parameters<typeof sampleWebglForScreen>
|
|
) => {
|
|
await ensureModel();
|
|
return sampleWebglForScreen(...args);
|
|
},
|
|
publicIp: publicIP,
|
|
getGeolocation,
|
|
validateConfig: (config: Record<string, any>, p?: string | null) =>
|
|
validateConfig(config, p),
|
|
getEnvVars: (config: Record<string, any>, uaOs: string, p?: string | null) =>
|
|
getEnvVars(config, uaOs, p),
|
|
findInstalledVersion: async (spec: string): Promise<string | null> =>
|
|
(await import("./multiversion.js")).findInstalledVersion(spec) ?? null,
|
|
};
|
|
|
|
/*
|
|
* Python-compatible serialisation helpers
|
|
*/
|
|
|
|
/** type(value).__name__ for a JSON-ish value. */
|
|
export function pyTypeName(value: unknown): string {
|
|
if (value === null || value === undefined) return "NoneType";
|
|
if (typeof value === "boolean") return "bool";
|
|
if (value instanceof PyFloat) return "float";
|
|
if (typeof value === "number")
|
|
return Number.isInteger(value) ? "int" : "float";
|
|
if (typeof value === "bigint") return "int";
|
|
if (typeof value === "string") return "str";
|
|
if (Array.isArray(value)) return "list";
|
|
return "dict";
|
|
}
|
|
|
|
/**
|
|
* json.dumps(value, ensure_ascii=True, separators=(',', ':')): compact, every
|
|
* non-ASCII (and DEL) character \u-escaped, floats in Python's repr.
|
|
*/
|
|
export function pyJsonDumpsAscii(value: unknown): string {
|
|
const encode = (v: unknown): string => {
|
|
if (v === null || v === undefined) return "null";
|
|
if (v === true) return "true";
|
|
if (v === false) return "false";
|
|
if (typeof v === "number") {
|
|
if (Number.isInteger(v)) return String(v);
|
|
if (Number.isNaN(v)) return "NaN";
|
|
if (!Number.isFinite(v)) return v > 0 ? "Infinity" : "-Infinity";
|
|
return formatPyFloatRepr(v);
|
|
}
|
|
if (typeof v === "bigint") return v.toString();
|
|
if (v instanceof PyFloat) return formatPyFloatRepr(v.value);
|
|
if (typeof v === "string") {
|
|
return JSON.stringify(v).replace(
|
|
/[\u007f-]/g,
|
|
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
);
|
|
}
|
|
if (Array.isArray(v)) return `[${v.map(encode).join(",")}]`;
|
|
return `{${Object.entries(v as Record<string, unknown>)
|
|
.map(([k, x]) => `${encode(k)}:${encode(x)}`)
|
|
.join(",")}}`;
|
|
};
|
|
return encode(value);
|
|
}
|
|
|
|
/**
|
|
* orjson.dumps(config): compact, UTF-8, insertion order. JavaScript has one
|
|
* number type, so the Python int/float split is carried the way pycompat
|
|
* carries it: a safe-integer number is an int, a bigint is an int beyond
|
|
* 2**53, a PyFloat is an integral float, and any other number is a float.
|
|
*/
|
|
export function configJson(value: unknown): string {
|
|
return orjsonDumps(value, false);
|
|
}
|
|
|
|
/** Slice `s` into chunks of `size` code points (Python str slicing). */
|
|
function chunkCodePoints(s: string, size: number): string[] {
|
|
// Fast path: no astral characters, so UTF-16 units == code points.
|
|
if (!/[\ud800-\udfff]/.test(s)) {
|
|
const out: string[] = [];
|
|
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));
|
|
return out;
|
|
}
|
|
const cps = Array.from(s);
|
|
const out: string[] = [];
|
|
for (let i = 0; i < cps.length; i += size) {
|
|
out.push(cps.slice(i, i + size).join(""));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/*
|
|
* Environment
|
|
*/
|
|
|
|
/**
|
|
* Generates a runtime fontconfig that resolves bundled font paths absolutely.
|
|
* The bundled fonts.conf uses prefix="cwd" relative paths which break when
|
|
* Playwright's working directory differs from the browser install directory.
|
|
* Writes a patched copy to the platform cache dir (deterministic, only
|
|
* regenerated when content changes). This must not live inside the versioned
|
|
* browser bundle: the bundle is commonly baked into an image as root and run
|
|
* as a non-root user, so it is read-only at launch time.
|
|
*/
|
|
export function generateFontconfig(
|
|
fontconfigPath: string,
|
|
executablePath?: string | null,
|
|
osDir?: string | null,
|
|
): string {
|
|
// Beside the caller's own binary when they supplied one; see getEnvVars.
|
|
const fontsDir = executablePath
|
|
? path.join(path.dirname(executablePath), "fonts")
|
|
: utilsDeps.getPath("fonts");
|
|
|
|
// Which directories this identity's OS may see.
|
|
//
|
|
// fontconfig scans <dir> RECURSIVELY, so the parent must never be named: it
|
|
// would make every other OS's files reachable by the renderer -- hidden by
|
|
// the allowlist for direct lookups, but still candidates for glyph fallback.
|
|
//
|
|
// The bundle stores each face ONCE, in a directory named for the set of
|
|
// OSes that use it (L, M, W, LM, LW, MW, LMW) -- see bundle/fonts/groups.json
|
|
// and scripts/gen-font-groups.py. An OS reads the groups its letter appears
|
|
// in, so nothing has to be hidden after the fact.
|
|
let scanDirs: string[] = [];
|
|
const groupsPath = path.join(fontsDir, "groups.json");
|
|
const osKey = (
|
|
{ linux: "lin", macos: "mac", windows: "win" } as Record<string, string>
|
|
)[osDir ?? ""];
|
|
if (osKey && fs.existsSync(groupsPath)) {
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(groupsPath, "utf-8"));
|
|
const readBy: string[] = parsed?.readBy?.[osKey] ?? [];
|
|
scanDirs = readBy
|
|
.map((g) => path.join(fontsDir, g))
|
|
.filter((d) => isDir(d));
|
|
} catch {
|
|
scanDirs = [];
|
|
}
|
|
}
|
|
if (!scanDirs.length) {
|
|
// Older bundles ship fonts/<os>/ with each OS's set duplicated in full.
|
|
if (osDir && isDir(path.join(fontsDir, osDir))) {
|
|
scanDirs = [path.join(fontsDir, osDir)];
|
|
} else {
|
|
scanDirs = [fontsDir];
|
|
}
|
|
}
|
|
|
|
const fontsConfSrc = path.join(fontconfigPath, "fonts.conf");
|
|
let confContent = fs.readFileSync(fontsConfSrc, "utf-8");
|
|
confContent = confContent.replace(
|
|
'<dir prefix="cwd">fonts</dir>',
|
|
scanDirs.map((d) => `<dir>${d}</dir>`).join("\n\t"),
|
|
);
|
|
|
|
// INSTALL_DIR is platformdirs' user_cache_dir("camoufox"); see paths.ts.
|
|
const cacheDir = path.join(utilsDeps.installDir(), "fontconfig");
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
|
|
const contentHash = createHash("sha256")
|
|
.update(confContent)
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
const runtimeConf = path.join(cacheDir, `fonts-${contentHash}.conf`);
|
|
if (!fs.existsSync(runtimeConf)) {
|
|
fs.writeFileSync(runtimeConf, confContent);
|
|
}
|
|
return runtimeConf;
|
|
}
|
|
|
|
function isDir(p: string): boolean {
|
|
try {
|
|
return fs.statSync(p).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Warn when a caller's own binary is older than their Playwright needs.
|
|
*
|
|
* A managed install below the floor is simply upgraded (pkgman resolves it),
|
|
* but `executable_path` deliberately bypasses that. That leaves the one
|
|
* pairing nothing checks: an old build driven by Playwright >= 1.61, which
|
|
* sends viewport fields the older Juggler schema rejects. Warns rather than
|
|
* raises: the default no-viewport path still works on an old build. A build
|
|
* with no version.json beside it tells us nothing, so it is left alone.
|
|
*/
|
|
export function warnIfExecutablePredatesPlaywright(
|
|
executablePath?: string | null,
|
|
): void {
|
|
if (!executablePath) return;
|
|
let installed: Version;
|
|
try {
|
|
installed = Version.fromPath(path.dirname(executablePath));
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const required = utilsDeps.effectiveVersionMin();
|
|
if (!installed.lessThan(required)) return;
|
|
|
|
warn(
|
|
`The Camoufox build at ${executablePath} is ${installed.build}, but Playwright ` +
|
|
`${utilsDeps.resolvedPlaywrightVersionStr()} needs at least ${required.build}. ` +
|
|
"Contexts created with an explicit viewport will fail with " +
|
|
'"Protocol error (Browser.setDefaultViewport)". Update the build, or pin ' +
|
|
"playwright<1.61.",
|
|
"RuntimeWarning",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Pass the launcher's Firefox prefs to settings/camoufox.cfg, which applies them
|
|
* at STARTUP (CAMOU_PREFS_1..N, chunked like CAMOU_CONFIG).
|
|
*
|
|
* Playwright's non-persistent launch writes no user.js: firefoxUserPrefs only
|
|
* reach the browser at runtime, through juggler's Browser.enable, after
|
|
* startup. Anything Gecko reads during startup therefore raced or never
|
|
* applied.
|
|
*/
|
|
export function getPrefEnvVars(
|
|
prefs: Record<string, any>,
|
|
): Record<string, string> {
|
|
if (!prefs || !Object.keys(prefs).length) return {};
|
|
// ASCII only: on Windows autoconfig's getenv() reads the environment through
|
|
// the ANSI code page, which would mangle a raw UTF-8 pref value (\u escapes
|
|
// survive it and JSON.parse restores them).
|
|
const data = pyJsonDumpsAscii(prefs);
|
|
const chunkSize = utilsDeps.osName() === "win" ? 2047 : 32767;
|
|
const out: Record<string, string> = {};
|
|
chunkCodePoints(data, chunkSize).forEach((chunk, i) => {
|
|
out[`CAMOU_PREFS_${i + 1}`] = chunk;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Gets the environment variables for Camoufox: the chunked CAMOU_CONFIG, plus
|
|
* the Linux fontconfig pointer.
|
|
*
|
|
* `executablePath` is the caller's own executable, when they supplied one. The
|
|
* bundled fontconfig is read from beside that binary rather than from the
|
|
* managed install, the same way loadProperties() treats properties.json.
|
|
*/
|
|
export function getEnvVars(
|
|
configMap: Record<string, any>,
|
|
userAgentOs: string,
|
|
executablePath?: string | null,
|
|
): EnvVars {
|
|
const envVars: EnvVars = {};
|
|
const configStr = configJson(configMap);
|
|
|
|
// Split the config into chunks
|
|
const osName = utilsDeps.osName();
|
|
const chunkSize = osName === "win" ? 2047 : 32767;
|
|
chunkCodePoints(configStr, chunkSize).forEach((chunk, i) => {
|
|
envVars[`CAMOU_CONFIG_${i + 1}`] = chunk;
|
|
});
|
|
|
|
if (osName === "lin") {
|
|
// https://github.com/coryking/camoufox/commit/f21eeb2850a74cc104fb57e17e0a2fa27b7a2a28
|
|
// Thanks @coryking
|
|
const directoryMap: Record<string, string> = {
|
|
lin: "linux",
|
|
mac: "macos",
|
|
win: "windows",
|
|
};
|
|
const osDir = directoryMap[userAgentOs] ?? userAgentOs;
|
|
|
|
// v150+ uses "fontconfig/" (matching the Go launcher); older bundles
|
|
// shipped "fontconfigs/".
|
|
const bundlePath = (...parts: string[]): string =>
|
|
executablePath
|
|
? path.join(path.dirname(executablePath), ...parts)
|
|
: utilsDeps.getPath(path.join(...parts));
|
|
|
|
let fontconfigPath = bundlePath("fontconfig", osDir);
|
|
if (!fs.existsSync(path.join(fontconfigPath, "fonts.conf"))) {
|
|
fontconfigPath = bundlePath("fontconfigs", osDir);
|
|
}
|
|
|
|
if (!fs.existsSync(path.join(fontconfigPath, "fonts.conf"))) {
|
|
const err = new Error(
|
|
`fonts.conf not found in ${fontconfigPath}! Something ain't right with your camoufox bundle.`,
|
|
);
|
|
err.name = "FileNotFoundError";
|
|
throw err;
|
|
}
|
|
|
|
envVars.FONTCONFIG_FILE = generateFontconfig(
|
|
fontconfigPath,
|
|
executablePath,
|
|
osDir,
|
|
);
|
|
}
|
|
|
|
return envVars;
|
|
}
|
|
|
|
/**
|
|
* Loads the properties.json file.
|
|
*/
|
|
function loadProperties(
|
|
executablePath?: string | null,
|
|
): Record<string, string> {
|
|
let propFile: string;
|
|
if (executablePath) {
|
|
propFile = path.join(path.dirname(executablePath), "properties.json");
|
|
if (!fs.existsSync(propFile)) {
|
|
// macOS app bundle: the binary is Contents/MacOS/camoufox, the
|
|
// packaged settings live in Contents/Resources/.
|
|
const bundled = path.join(
|
|
path.dirname(path.dirname(executablePath)),
|
|
"Resources",
|
|
"properties.json",
|
|
);
|
|
if (fs.existsSync(bundled)) propFile = bundled;
|
|
}
|
|
} else {
|
|
propFile = utilsDeps.getPath("properties.json");
|
|
}
|
|
const propDict: Array<{ property: string; type: string }> = JSON.parse(
|
|
fs.readFileSync(propFile, "utf-8"),
|
|
);
|
|
const out: Record<string, string> = {};
|
|
for (const prop of propDict) out[prop.property] = prop.type;
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Validates the config map.
|
|
*/
|
|
export function validateConfig(
|
|
configMap: Record<string, any>,
|
|
executablePath?: string | null,
|
|
): void {
|
|
const propertyTypes = loadProperties(executablePath);
|
|
|
|
for (const [key, value] of Object.entries(configMap)) {
|
|
const expectedType = propertyTypes[key];
|
|
if (!expectedType) {
|
|
utilsDeps.print(`Skipping unknown patch ${key} : ${pyStr(value)}`);
|
|
continue; // Property not supported by this browser version; skip silently
|
|
}
|
|
|
|
if (!validateType(value, expectedType)) {
|
|
throw new InvalidPropertyType(
|
|
`Invalid type for property ${key}. Expected ${expectedType}, got ${pyTypeName(value)}`,
|
|
);
|
|
}
|
|
|
|
if (key === "voices") {
|
|
validateVoices(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates the type of the value. (Python's bool is an int, so a boolean
|
|
* passes the numeric checks there, and here.)
|
|
*/
|
|
export function validateType(value: any, expectedType: string): boolean {
|
|
if (value instanceof PyFloat) {
|
|
// A Python float: int/uint only when it is integral (float.is_integer()).
|
|
const v = value.value;
|
|
if (expectedType === "double") return true;
|
|
if (expectedType === "int") return Number.isInteger(v);
|
|
if (expectedType === "uint") return Number.isInteger(v) && v >= 0;
|
|
return false;
|
|
}
|
|
const isInt =
|
|
typeof value === "boolean" ||
|
|
typeof value === "bigint" ||
|
|
(typeof value === "number" && Number.isInteger(value));
|
|
switch (expectedType) {
|
|
case "str":
|
|
return typeof value === "string";
|
|
case "int":
|
|
return isInt;
|
|
case "uint":
|
|
return isInt && Number(value) >= 0;
|
|
case "double":
|
|
return typeof value === "number" || isInt;
|
|
case "bool":
|
|
return typeof value === "boolean";
|
|
case "array":
|
|
return Array.isArray(value);
|
|
case "dict":
|
|
return (
|
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The five fields MaskConfig::MVoices() requires of every `voices` entry. It
|
|
// skips anything missing one of them, so a bare "Name:lang:type" string or a
|
|
// half-filled object registers nothing -- and a voice list that registers
|
|
// nothing leaves the host's native voices exposed (#731).
|
|
export const VOICE_FIELDS = [
|
|
"lang",
|
|
"name",
|
|
"voiceUri",
|
|
"isDefault",
|
|
"isLocalService",
|
|
] as const;
|
|
|
|
/**
|
|
* Validates that every `voices` entry is a complete voice object.
|
|
*/
|
|
export function validateVoices(voices: any): void {
|
|
if (!Array.isArray(voices)) {
|
|
throw new InvalidPropertyType(
|
|
`Invalid type for property voices. Expected array, got ${pyTypeName(voices)}`,
|
|
);
|
|
}
|
|
voices.forEach((voice, index) => {
|
|
if (typeof voice !== "object" || voice === null || Array.isArray(voice)) {
|
|
throw new InvalidPropertyType(
|
|
`Invalid voices[${index}]: expected an object with ` +
|
|
`{${VOICE_FIELDS.join(", ")}}, got ${pyTypeName(voice)} ` +
|
|
`(${pyRepr(voice)}). Camoufox needs full voice objects, not names.`,
|
|
);
|
|
}
|
|
const missing = VOICE_FIELDS.filter((field) => !(field in voice));
|
|
if (missing.length) {
|
|
throw new InvalidPropertyType(
|
|
`Invalid voices[${index}]: missing ${missing.join(", ")}. ` +
|
|
`Every voice needs {${VOICE_FIELDS.join(", ")}}.`,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets the OS from the config if the user agent is set, otherwise returns the
|
|
* OS of the current system.
|
|
*/
|
|
export function getTargetOs(config: Record<string, any>): TargetOS {
|
|
if (config["navigator.userAgent"]) {
|
|
return determineUaOs(config["navigator.userAgent"]);
|
|
}
|
|
return utilsDeps.osName();
|
|
}
|
|
|
|
/**
|
|
* Determines the OS from the user agent string.
|
|
*/
|
|
export function determineUaOs(userAgent: string): TargetOS {
|
|
// Python's ua_parser answers "Other" rather than nothing for an
|
|
// unrecognised UA, so its `raise` never fires and "lin" is what runs.
|
|
const parsed = new UAParser(userAgent).getOS().name || "Other";
|
|
// ua-parser-js reports "macOS"; the Python ua_parser reports "Mac OS X".
|
|
if (parsed.startsWith("Mac") || parsed.startsWith("macOS")) return "mac";
|
|
if (parsed.startsWith("Windows")) return "win";
|
|
return "lin";
|
|
}
|
|
|
|
/**
|
|
* Determines a sane viewport size for Camoufox if being ran in headful mode.
|
|
*
|
|
* Bounds are CSS pixels, the unit Firefox lays its windows out in -- see
|
|
* display.ts for why that differs from the monitor's physical size.
|
|
*/
|
|
export function getScreenCons(headless?: boolean): Screen | null {
|
|
if (headless === true) {
|
|
return null; // Skip if headless
|
|
}
|
|
const display = utilsDeps.largestDisplay();
|
|
if (display === null) {
|
|
return null; // Skip if the display can't be probed
|
|
}
|
|
return new Screen({ maxWidth: display.width, maxHeight: display.height });
|
|
}
|
|
|
|
/**
|
|
* Updates the fonts for the target OS.
|
|
*/
|
|
export function updateFonts(
|
|
config: Record<string, any>,
|
|
targetOs: string,
|
|
): void {
|
|
const fonts: string[] = JSON.parse(
|
|
fs.readFileSync(path.join(LOCAL_DATA, "fonts.json"), "utf-8"),
|
|
)[targetOs];
|
|
|
|
// Merge with existing fonts (np.unique sorts)
|
|
if ("fonts" in config) {
|
|
config.fonts = [...new Set([...fonts, ...config.fonts])].sort(pyCompare);
|
|
} else {
|
|
config.fonts = fonts;
|
|
}
|
|
}
|
|
|
|
/** Python's str ordering: by code point, not UTF-16 unit. */
|
|
function pyCompare(a: string, b: string): number {
|
|
const ca = Array.from(a);
|
|
const cb = Array.from(b);
|
|
for (let i = 0; i < Math.min(ca.length, cb.length); i++) {
|
|
const d =
|
|
(ca[i].codePointAt(0) as number) - (cb[i].codePointAt(0) as number);
|
|
if (d) return d;
|
|
}
|
|
return ca.length - cb.length;
|
|
}
|
|
|
|
/**
|
|
* Asserts that the passed fingerprint is a valid Firefox fingerprint, and
|
|
* warns that passing one is not recommended.
|
|
*/
|
|
export function checkCustomFingerprint(fingerprint: Record<string, any>): void {
|
|
const userAgent = fingerprint?.navigator?.userAgent || "";
|
|
const browserName = parseUaFamily(userAgent);
|
|
if (browserName !== "Firefox") {
|
|
throw new NonFirefoxFingerprint(
|
|
`"${browserName}" fingerprints are not supported in Camoufox. ` +
|
|
"Using fingerprints from a browser other than Firefox WILL lead to detection. " +
|
|
"If this is intentional, pass `i_know_what_im_doing=True`.",
|
|
);
|
|
}
|
|
|
|
LeakWarning.warn("custom_fingerprint", false);
|
|
}
|
|
|
|
/** ua_parser.user_agent_parser.ParseUserAgent(ua)['family']. */
|
|
function parseUaFamily(userAgent: string): string {
|
|
const name = new UAParser(userAgent).getBrowser().name;
|
|
if (!name) return "Other";
|
|
// ua-parser-js and uap-core name a few browsers differently.
|
|
const map: Record<string, string> = {
|
|
"Mobile Firefox": "Firefox Mobile",
|
|
"Mobile Chrome": "Chrome Mobile",
|
|
"Mobile Safari": "Mobile Safari",
|
|
};
|
|
return map[name] ?? name;
|
|
}
|
|
|
|
/**
|
|
* Checks if the target OS is valid.
|
|
*/
|
|
export function checkValidOs(osValue: ListOrString): void {
|
|
if (typeof osValue !== "string") {
|
|
for (const osName of osValue) checkValidOs(osName);
|
|
return;
|
|
}
|
|
// Assert that the OS is lowercase (str.islower(): has a cased char, none upper)
|
|
if (
|
|
!(osValue !== osValue.toUpperCase() && osValue === osValue.toLowerCase())
|
|
) {
|
|
throw new InvalidOS(`OS values must be lowercase: '${osValue}'`);
|
|
}
|
|
// Assert that the OS is supported by Camoufox
|
|
if (!["windows", "macos", "linux"].includes(osValue)) {
|
|
throw new InvalidOS(`Camoufox does not support the OS: '${osValue}'`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merges new keys/values from the source into the target, given that the key
|
|
* does not exist in the target.
|
|
*/
|
|
export function mergeInto(
|
|
target: Record<string, any>,
|
|
source: Record<string, any>,
|
|
): void {
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (!(key in target)) target[key] = value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets a new key/value into the target, given that the key does not exist.
|
|
*/
|
|
export function setInto(
|
|
target: Record<string, any>,
|
|
key: string,
|
|
value: any,
|
|
): void {
|
|
if (!(key in target)) target[key] = value;
|
|
}
|
|
|
|
/**
|
|
* Checks if a domain is set in the config.
|
|
*/
|
|
export function isDomainSet(
|
|
config: Record<string, any>,
|
|
...properties: string[]
|
|
): boolean {
|
|
for (const prop of properties) {
|
|
// If the . prefix exists, check if the domain is a prefix of any key
|
|
if (prop.endsWith(".") || prop.endsWith(":")) {
|
|
if (Object.keys(config).some((key) => key.startsWith(prop))) return true;
|
|
} else if (prop in config) {
|
|
// Otherwise, check if the domain is a direct key in the config
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Warns the user if they are manually setting properties that Camoufox already
|
|
* sets internally.
|
|
*/
|
|
export function warnManualConfig(config: Record<string, any>): void {
|
|
// Manual locale setting
|
|
if (
|
|
isDomainSet(
|
|
config,
|
|
"navigator.language",
|
|
"headers.Accept-Language",
|
|
"locale:",
|
|
)
|
|
) {
|
|
LeakWarning.warn("locale", false);
|
|
}
|
|
// Manual geolocation and timezone setting
|
|
if (isDomainSet(config, "geolocation:", "timezone")) {
|
|
LeakWarning.warn("geolocation", false);
|
|
}
|
|
// Manual User-Agent setting
|
|
if (isDomainSet(config, "headers.User-Agent")) {
|
|
LeakWarning.warn("header-ua", false);
|
|
}
|
|
// Manual navigator setting
|
|
if (isDomainSet(config, "navigator.")) {
|
|
LeakWarning.warn("navigator", false);
|
|
}
|
|
// Touchscreen digitizer spoofing. Called out separately from the blanket
|
|
// navigator warning because the knock-on effects reach past navigator into
|
|
// CSS pointer media queries and the TouchEvent interfaces.
|
|
if (isDomainSet(config, "navigator.maxTouchPoints")) {
|
|
LeakWarning.warn("max_touch_points", false);
|
|
}
|
|
if (isTruthy(config.instantAnimations)) {
|
|
LeakWarning.warn("instant_animations", false);
|
|
}
|
|
// Manual screen/window setting
|
|
if (isDomainSet(config, "screen.", "window.", "document.body.")) {
|
|
LeakWarning.warn("viewport", false);
|
|
}
|
|
}
|
|
|
|
const WINDOW_DIM_KEYS = [
|
|
"window.outerWidth",
|
|
"window.outerHeight",
|
|
"window.innerWidth",
|
|
"window.innerHeight",
|
|
];
|
|
|
|
/** The CAMOU_CONFIG chunks of a set of launch options, reassembled in order. */
|
|
export function camouConfigBlob(fromOptions: Record<string, any>): string {
|
|
const env: Record<string, any> = fromOptions.env ?? {};
|
|
return Object.entries(env)
|
|
.filter(([k]) => k.startsWith("CAMOU_CONFIG_"))
|
|
.map(
|
|
([k, v]) =>
|
|
[
|
|
Number.parseInt(k.slice(k.lastIndexOf("_") + 1), 10),
|
|
String(v),
|
|
] as const,
|
|
)
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([, v]) => v)
|
|
.join("");
|
|
}
|
|
|
|
/**
|
|
* The core count the browser must be pinned to for these launch options, or
|
|
* null: the identity's navigator.hardwareConcurrency when this host can honour
|
|
* it (see cpu_affinity), so a page measuring parallelism sees the reported
|
|
* number.
|
|
*/
|
|
export function pinnedCoreCount(
|
|
fromOptions: Record<string, any>,
|
|
): number | null {
|
|
const blob = camouConfigBlob(fromOptions);
|
|
if (!blob || !cpuAffinity.supported()) return null;
|
|
let value: unknown;
|
|
try {
|
|
const parsed = JSON.parse(blob);
|
|
value =
|
|
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
? parsed["navigator.hardwareConcurrency"]
|
|
: undefined;
|
|
} catch {
|
|
return null;
|
|
}
|
|
// The host's count, not this process's live mask: a concurrent pinned
|
|
// launch narrows that while its browser spawns.
|
|
const cores = cpuAffinity.hostCoreCount();
|
|
if (
|
|
typeof value === "number" &&
|
|
Number.isInteger(value) &&
|
|
value >= 1 &&
|
|
value < cores
|
|
) {
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* PID of the process that will spawn the browser (its children inherit the
|
|
* CPU affinity set on it). Python's driver is a separate Node process;
|
|
* playwright-core runs in this one.
|
|
*/
|
|
export function driverPid(): number {
|
|
return process.pid;
|
|
}
|
|
|
|
/**
|
|
* Whether the CAMOU_CONFIG in a set of launch options spoofs any window
|
|
* dimension. The config is chunked across CAMOU_CONFIG_<n> env vars, so
|
|
* reassemble it in index order before looking.
|
|
*/
|
|
export function spoofsWindowDimensions(
|
|
fromOptions: Record<string, any>,
|
|
): boolean {
|
|
const blob = camouConfigBlob(fromOptions);
|
|
if (!blob) return false;
|
|
return WINDOW_DIM_KEYS.some((key) => blob.includes(key));
|
|
}
|
|
|
|
/**
|
|
* Playwright emulates four media features on every context it creates, whether
|
|
* or not the caller asked: `colorScheme` defaults to "light" and reducedMotion /
|
|
* forcedColors / contrast to their no-preference values. That is an override,
|
|
* not a passthrough -- the page then reports it whatever the host is set to. The
|
|
* JS API's `null` (Python's "no-override") is Playwright's own opt-out: it sends
|
|
* no emulation at all and the browser answers from the host.
|
|
*/
|
|
export const STOCK_MEDIA_DEFAULTS = {
|
|
colorScheme: null,
|
|
reducedMotion: null,
|
|
forcedColors: null,
|
|
contrast: null,
|
|
} as const;
|
|
|
|
/** Fill STOCK_MEDIA_DEFAULTS into any media option the caller left unset. */
|
|
export function withStockMediaDefaults(
|
|
opts: Record<string, any>,
|
|
): Record<string, any> {
|
|
const out = { ...opts };
|
|
for (const [option, value] of Object.entries(STOCK_MEDIA_DEFAULTS)) {
|
|
if (out[option] === undefined) out[option] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Default newPage()/newContext() to the host's own media features.
|
|
*
|
|
* Explicit colorScheme / reducedMotion / forcedColors / contrast from the caller
|
|
* always wins; this only replaces Playwright's silent defaults.
|
|
*/
|
|
export function attachStockMediaDefaults<T>(target: T): T {
|
|
for (const name of ["newPage", "newContext"] as const) {
|
|
const original = (target as any)[name];
|
|
if (typeof original !== "function") continue;
|
|
(target as any)[name] = (options?: Record<string, any>, ...rest: any[]) =>
|
|
original.call(target, withStockMediaDefaults(options ?? {}), ...rest);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Warn when newPage()/newContext() asks for isMobile: Camoufox only has desktop
|
|
* identities, and Juggler ignores the option (TargetRegistry.js).
|
|
*/
|
|
export function attachDesktopOnlyWarning<T>(target: T): T {
|
|
for (const name of ["newPage", "newContext"] as const) {
|
|
const original = (target as any)[name];
|
|
if (typeof original !== "function") continue;
|
|
(target as any)[name] = (options?: Record<string, any>, ...rest: any[]) => {
|
|
if (options?.isMobile || options?.is_mobile)
|
|
LeakWarning.warn("is_mobile");
|
|
return original.call(target, options, ...rest);
|
|
};
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Normalise a context-options object onto the JS API's `viewport: null`,
|
|
* defaulting to it when the caller expressed no preference. Playwright-Python
|
|
* takes `no_viewport=True`; a caller-supplied `noViewport` is accepted and
|
|
* translated.
|
|
*/
|
|
export function applyNoViewport(
|
|
opts: Record<string, any>,
|
|
): Record<string, any> {
|
|
const out = { ...opts };
|
|
if ("noViewport" in out) {
|
|
const noViewport = out.noViewport;
|
|
delete out.noViewport;
|
|
if (noViewport && !("viewport" in out)) out.viewport = null;
|
|
return out;
|
|
}
|
|
if (!("viewport" in out)) out.viewport = null;
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Default newPage()/newContext() to no viewport.
|
|
*
|
|
* Playwright applies a 1280x720 viewport by default, which makes Juggler ask
|
|
* the content window to become 1280x720 (TargetRegistry.updateViewportSize).
|
|
* When Camoufox is pinning the window to a spoofed size, that request can
|
|
* never be satisfied, and awaitViewportDimensions has no timeout -- so the
|
|
* second newPage() hangs forever (daijro/camoufox#666). An explicit viewport
|
|
* from the caller always wins.
|
|
*/
|
|
export function attachNoViewportDefault<T>(target: T): T {
|
|
for (const name of ["newPage", "newContext"] as const) {
|
|
const original = (target as any)[name];
|
|
if (typeof original !== "function") continue;
|
|
(target as any)[name] = (options?: Record<string, any>, ...rest: any[]) =>
|
|
original.call(target, applyNoViewport(options ?? {}), ...rest);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* Attaches the virtual display to the browser's cleanup. (Python has an async
|
|
* and a sync variant; playwright-core has one API.)
|
|
*/
|
|
export function attachVirtualDisplay<T>(
|
|
browser: T,
|
|
virtualDisplay?: VirtualDisplay | null,
|
|
): T {
|
|
if (!virtualDisplay) return browser; // Skip if no virtual display is provided
|
|
|
|
const target = browser as any;
|
|
const originalClose = target.close.bind(target);
|
|
|
|
target.close = async (...args: any[]) => {
|
|
try {
|
|
return await originalClose(...args);
|
|
} finally {
|
|
virtualDisplay.kill();
|
|
}
|
|
};
|
|
// close() never runs on an unexpected disconnect/close, so wire teardown
|
|
// directly too; kill() is idempotent.
|
|
target.on?.(target.contexts ? "disconnected" : "close", () =>
|
|
virtualDisplay.kill(),
|
|
);
|
|
target._virtualDisplay = virtualDisplay;
|
|
|
|
return browser;
|
|
}
|
|
|
|
/**
|
|
* The version of the build about to be launched.
|
|
*
|
|
* installedVerStr() answers "which release did `camoufox fetch` put in the
|
|
* cache", which is the wrong question when the caller named a binary. Firefox
|
|
* writes application.ini beside the executable, so when a path is given the
|
|
* answer is right there. Falls back to the installed release when it is not.
|
|
*/
|
|
export function resolveVerstr(executablePath?: string | null): string {
|
|
if (executablePath) {
|
|
const ini = path.join(path.dirname(executablePath), "application.ini");
|
|
try {
|
|
for (const line of fs.readFileSync(ini, "utf-8").split(/\r\n|\r|\n/)) {
|
|
if (line.startsWith("Version=")) {
|
|
const version = line.split("=").slice(1).join("=").trim();
|
|
if (version) return version;
|
|
}
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return utilsDeps.installedVerStr();
|
|
}
|
|
|
|
/** A bound on the screen a generated fingerprint may claim. */
|
|
export type ScreenConstraint =
|
|
| Screen
|
|
| {
|
|
min_width?: number;
|
|
max_width?: number;
|
|
min_height?: number;
|
|
max_height?: number;
|
|
minWidth?: number;
|
|
maxWidth?: number;
|
|
minHeight?: number;
|
|
maxHeight?: number;
|
|
};
|
|
|
|
function toScreen(screen: ScreenConstraint): Screen {
|
|
if (screen instanceof Screen) return screen;
|
|
const s = screen as Record<string, number | undefined>;
|
|
return new Screen({
|
|
minWidth: s.minWidth ?? s.min_width,
|
|
maxWidth: s.maxWidth ?? s.max_width,
|
|
minHeight: s.minHeight ?? s.min_height,
|
|
maxHeight: s.maxHeight ?? s.max_height,
|
|
});
|
|
}
|
|
|
|
export interface LaunchOptions {
|
|
/** Camoufox properties to use.
|
|
* (read https://github.com/daijro/camoufox/blob/main/README.md) */
|
|
config?: Record<string, any>;
|
|
/** Operating system to use for the fingerprint generation. Can be
|
|
* "windows", "macos", "linux", or a list to randomly choose from. */
|
|
os?: ListOrString;
|
|
/** Whether to block all images. */
|
|
block_images?: boolean;
|
|
/** Whether to block WebRTC entirely. */
|
|
block_webrtc?: boolean;
|
|
/** Whether to block WebGL. To prevent leaks, only use this for special cases. */
|
|
block_webgl?: boolean;
|
|
/** Disables the Cross-Origin-Opener-Policy, allowing elements in cross-origin
|
|
* iframes, such as the Turnstile checkbox, to be clicked. */
|
|
disable_coop?: boolean;
|
|
/** Use a specific WebGL vendor/renderer pair, as [vendor, renderer]. */
|
|
webgl_config?: [string, string];
|
|
/** Calculate longitude, latitude, timezone, country, & locale based on the IP
|
|
* address. Pass the target IP address to use, or `true` to find it. */
|
|
geoip?: string | boolean;
|
|
/** Name of the GeoIP database to use (e.g. "MaxMind GeoLite2"). */
|
|
geoip_db?: string;
|
|
/** Humanize the cursor movement: `true`, or the MAX duration in seconds. */
|
|
humanize?: boolean | number;
|
|
/** Locale(s) to use. The first listed locale is used for the Intl API. */
|
|
locale?: string | string[];
|
|
/** List of Firefox addons to use (paths to extracted addons). */
|
|
addons?: string[];
|
|
/** Fonts to load into Camoufox (in addition to the target `os`'s). */
|
|
fonts?: string[];
|
|
/** If enabled, OS-specific system fonts will not be passed to Camoufox. */
|
|
custom_fonts_only?: boolean;
|
|
/** Default addons to exclude. */
|
|
exclude_addons?: DefaultAddon[];
|
|
/** Constrains the screen dimensions of the generated fingerprint. */
|
|
screen?: ScreenConstraint;
|
|
/** Set a fixed window size instead of generating a random one. */
|
|
window?: [number, number];
|
|
/** Use a custom fpgen fingerprint. */
|
|
fingerprint?: Record<string, any>;
|
|
/** Opt into real fingerprint presets: `true` for a random bundled preset,
|
|
* or a preset object. */
|
|
fingerprint_preset?: boolean | Record<string, any>;
|
|
/** Firefox version to use. Defaults to the current Camoufox version. */
|
|
ff_version?: number;
|
|
/** Whether to run the browser in headless mode. Defaults to false. */
|
|
headless?: boolean;
|
|
/** Whether to enable running scripts in the main world ("mw:" prefix). */
|
|
main_world_eval?: boolean;
|
|
/** Whether to allow addons to open new tabs. Defaults to false. */
|
|
allow_addon_new_tab?: boolean;
|
|
/** Custom Camoufox browser executable path. */
|
|
executable_path?: string;
|
|
/** Select a specific installed browser version ("official/beta.20",
|
|
* "beta.20", "134.0.2-beta.20"). Defaults to the active version. */
|
|
browser?: string;
|
|
/** Firefox user preferences to set. */
|
|
firefox_user_prefs?: Record<string, any>;
|
|
/** Proxy to use for the browser. */
|
|
proxy?: ProxyConfig;
|
|
/** Cache previous pages, requests, etc (uses more memory). */
|
|
enable_cache?: boolean;
|
|
/** Arguments to pass to the browser. */
|
|
args?: string[];
|
|
/** Environment variables to set. Defaults to a copy of process.env. */
|
|
env?: EnvVars;
|
|
/** Suppress leak warnings for options you are deliberately overriding. */
|
|
i_know_what_im_doing?: boolean;
|
|
/** Prints the config being sent to Camoufox. */
|
|
debug?: boolean;
|
|
/** Virtual display number, e.g. ":99". Handled by Camoufox & NewBrowser. */
|
|
virtual_display?: string;
|
|
/** Pin the browser to navigator.hardwareConcurrency cores (Linux/Windows).
|
|
* OFF by default -- it costs real CPU and serializes concurrent launches. */
|
|
pin_cpu_cores?: boolean;
|
|
/** Additional Firefox launch options, passed straight through to Playwright. */
|
|
[key: string]: any;
|
|
}
|
|
|
|
/**
|
|
* Builds the Playwright Firefox launch options for Camoufox.
|
|
*
|
|
* Accepts all Playwright Firefox launch options, along with the Camoufox ones
|
|
* documented on {@link LaunchOptions}.
|
|
*/
|
|
export async function launchOptions({
|
|
config,
|
|
os: targetOsOption,
|
|
block_images,
|
|
block_webrtc,
|
|
block_webgl,
|
|
disable_coop,
|
|
webgl_config,
|
|
geoip,
|
|
geoip_db,
|
|
humanize,
|
|
locale,
|
|
addons,
|
|
fonts,
|
|
custom_fonts_only,
|
|
exclude_addons,
|
|
screen,
|
|
window,
|
|
fingerprint,
|
|
fingerprint_preset,
|
|
ff_version,
|
|
headless,
|
|
main_world_eval,
|
|
allow_addon_new_tab,
|
|
executable_path,
|
|
browser,
|
|
firefox_user_prefs,
|
|
proxy,
|
|
enable_cache,
|
|
args,
|
|
env,
|
|
i_know_what_im_doing,
|
|
debug,
|
|
virtual_display,
|
|
pin_cpu_cores,
|
|
...passthrough
|
|
}: LaunchOptions = {}): Promise<Record<string, any>> {
|
|
utilsDeps.ensureBrowserProfileDir(env);
|
|
|
|
// Build the config
|
|
config ??= {};
|
|
|
|
// Set default values for optional arguments
|
|
headless ??= false;
|
|
addons ??= [];
|
|
args ??= [];
|
|
firefox_user_prefs ??= {};
|
|
custom_fonts_only ??= false;
|
|
i_know_what_im_doing ??= false;
|
|
// Keep per-launch overrides isolated from the process environment and from
|
|
// mappings supplied by callers. In particular, DISPLAY must not outlive the
|
|
// virtual display that owns it.
|
|
env = env == null ? ({ ...process.env } as EnvVars) : { ...env };
|
|
if (executable_path == null) {
|
|
// Point every launch at a specific build without threading the path
|
|
// through each call site. Absent the variable nothing changes.
|
|
const envExecutable = (process.env.CAMOUFOX_EXECUTABLE_PATH ?? "").trim();
|
|
if (envExecutable) {
|
|
executable_path = envExecutable;
|
|
}
|
|
}
|
|
if (typeof executable_path === "string") {
|
|
executable_path = path.resolve(executable_path);
|
|
}
|
|
|
|
// Handle virtual display
|
|
if (virtual_display) {
|
|
env.DISPLAY = virtual_display;
|
|
// Virtual display uses Xvfb (X11). If the host session forces Wayland via
|
|
// env vars, GTK/Firefox may try Wayland and ignore DISPLAY.
|
|
env.GDK_BACKEND = "x11";
|
|
delete env.WAYLAND_DISPLAY;
|
|
env.MOZ_ENABLE_WAYLAND = "0";
|
|
}
|
|
|
|
// Warn the user for manual config settings
|
|
if (!i_know_what_im_doing) {
|
|
warnManualConfig(config);
|
|
}
|
|
|
|
// Snapshot which domains the USER set before fingerprint generation fills in
|
|
// the rest. The post-generation corrections below must only touch generated
|
|
// values, never override what the user passed.
|
|
const userSetNavigator = isDomainSet(config, "navigator.");
|
|
const userSetScreenWindow = isDomainSet(config, "screen.", "window.");
|
|
const userSetMediaDevices = isDomainSet(config, "mediaDevices:");
|
|
const userSetFonts = Boolean(fonts?.length) || isDomainSet(config, "fonts");
|
|
const userSetVoices = isDomainSet(config, "voices");
|
|
const userSetDnt = "navigator.doNotTrack" in config;
|
|
const userSetGpc = "navigator.globalPrivacyControl" in config;
|
|
const userSetAcceptEncoding = "headers.Accept-Encoding" in config;
|
|
const userSetAudioSeed = "audio:seed" in config;
|
|
|
|
// The salt that makes every seeded draw belong to this identity (see
|
|
// fingerprints.identitySalt): stable when the caller pinned the identity --
|
|
// a fingerprint, a preset object, or their own config naming the UA -- and
|
|
// fresh otherwise.
|
|
let salt: bigint | number;
|
|
if (fingerprint != null) {
|
|
salt = utilsDeps.identitySalt(fingerprint);
|
|
} else if (isPlainObject(fingerprint_preset)) {
|
|
salt = utilsDeps.identitySalt(fingerprint_preset);
|
|
} else if ("navigator.userAgent" in config) {
|
|
salt = utilsDeps.identitySalt({ ...config });
|
|
} else {
|
|
salt = utilsDeps.identitySalt();
|
|
}
|
|
|
|
// Assert the target OS is valid
|
|
if (isTruthy(targetOsOption)) {
|
|
checkValidOs(targetOsOption as ListOrString);
|
|
} else if (isTruthy(webgl_config)) {
|
|
// webgl_config requires OS to be set
|
|
throw new ValueError("OS must be set when using webgl_config");
|
|
}
|
|
|
|
// Add the default addons
|
|
await utilsDeps.addDefaultAddons(addons, exclude_addons);
|
|
|
|
// Confirm all addon paths are valid
|
|
if (addons.length) {
|
|
utilsDeps.confirmPaths(addons);
|
|
config.addons = addons;
|
|
}
|
|
|
|
// The managed install is resolved lazily in Python (camoufox_path() may
|
|
// download); here that download is async, so do it before any sync lookup.
|
|
if (!executable_path) {
|
|
await utilsDeps.ensureCamoufoxInstalled();
|
|
}
|
|
|
|
// Get the Firefox version
|
|
let ffVersionStr: string;
|
|
if (ff_version) {
|
|
ffVersionStr = String(ff_version);
|
|
LeakWarning.warn("ff_version", i_know_what_im_doing);
|
|
} else {
|
|
ffVersionStr = resolveVerstr(executable_path).split(".")[0];
|
|
}
|
|
|
|
// Generate a fingerprint
|
|
let usedPreset = false;
|
|
if (fingerprint != null) {
|
|
// User passed a custom fingerprint
|
|
if (!i_know_what_im_doing) {
|
|
checkCustomFingerprint(fingerprint);
|
|
}
|
|
} else if (isTruthy(fingerprint_preset)) {
|
|
// User opted into real fingerprint presets
|
|
const preset = isPlainObject(fingerprint_preset)
|
|
? fingerprint_preset
|
|
: await utilsDeps.getRandomPreset(targetOsOption, ffVersionStr);
|
|
if (isTruthy(preset)) {
|
|
mergeInto(
|
|
config,
|
|
await utilsDeps.fromPreset(preset as any, ffVersionStr, salt),
|
|
);
|
|
usedPreset = true;
|
|
}
|
|
}
|
|
|
|
// Bound the geometry to the real display. The generator only honours this
|
|
// when its pool has a match, so it is re-applied after generation as well.
|
|
// `headless` and "is there a display to probe" are separate questions.
|
|
const screenCons: Screen | null = screen
|
|
? toScreen(screen)
|
|
: utilsDeps.hasDisplay(env)
|
|
? utilsDeps.getScreenCons(headless)
|
|
: null;
|
|
|
|
if (!usedPreset && fingerprint == null) {
|
|
// Default: synthetic generation via fpgen (infinite unique fingerprints)
|
|
fingerprint = await utilsDeps.generateFingerprint({
|
|
screen: screenCons ?? undefined,
|
|
window,
|
|
os: targetOsOption,
|
|
});
|
|
}
|
|
|
|
if (!usedPreset && fingerprint != null) {
|
|
// Inject the generated fingerprint into the config
|
|
mergeInto(config, utilsDeps.fromFpgen(fingerprint, ffVersionStr));
|
|
}
|
|
|
|
const targetOs = getTargetOs(config);
|
|
|
|
// A preset whose screen is a phone viewport is not a real desktop device.
|
|
if (!userSetScreenWindow && coherence.screenIsImplausible(config)) {
|
|
coherence.repairScreenOrientation(config);
|
|
utilsDeps.raiseScreenToModernFloor(config);
|
|
}
|
|
|
|
// Correct fingerprint inconsistencies that leak as headless /
|
|
// impossible-geometry tells, unless the user is driving these themselves.
|
|
if (!userSetNavigator) {
|
|
utilsDeps.fixNavigatorArch(config, targetOs);
|
|
utilsDeps.fixHardwareConcurrency(config, Boolean(pin_cpu_cores));
|
|
}
|
|
if (!userSetScreenWindow) {
|
|
// Lift netbook-era geometry to something current hardware reports,
|
|
// before the display clamp below so a genuinely small real monitor still
|
|
// wins (#729). Synthetic draws only: a preset is a real device.
|
|
if (!usedPreset) {
|
|
utilsDeps.raiseScreenToModernFloor(config);
|
|
}
|
|
// Headful on a real monitor only: this bound exists so the window fits
|
|
// the screen it is drawn on. headless has no window to overflow, and
|
|
// headless="virtual" reaches here as headless=false with a 1x1 Xvfb.
|
|
if (headless === false && !virtual_display && screenCons) {
|
|
utilsDeps.clampScreenToDisplay(
|
|
config,
|
|
screenCons.maxWidth as number,
|
|
screenCons.maxHeight as number,
|
|
);
|
|
}
|
|
utilsDeps.fixScreenNoTaskbar(config, targetOs);
|
|
utilsDeps.clampWindowDimensions(config);
|
|
utilsDeps.clampWindowPosition(config);
|
|
}
|
|
|
|
// Deliberately NOT setting window.history.length: settings/camoufox.cfg runs
|
|
// Firefox's stock max_entries, so the real value starts at 1 and grows with
|
|
// each navigation; pinning it would contradict history.back().
|
|
|
|
// Update fonts list
|
|
if (fonts?.length) {
|
|
config.fonts = fonts;
|
|
}
|
|
|
|
if (custom_fonts_only) {
|
|
firefox_user_prefs["gfx.bundled-fonts.activate"] = 0;
|
|
if (fonts?.length) {
|
|
LeakWarning.warn("custom_fonts_only");
|
|
} else {
|
|
throw new ValueError(
|
|
"No custom fonts were passed, but `custom_fonts_only` is enabled.",
|
|
);
|
|
}
|
|
} else if (!userSetFonts || !isTruthy(config.fonts)) {
|
|
// Draw the font subset HERE, after every identity fix-up above, so the
|
|
// seed sees the final UA/screen/cores/GPU: the same presented identity
|
|
// always gets the same font list (#442/#765).
|
|
const osName =
|
|
({ win: "windows", mac: "macos", lin: "linux" } as const)[targetOs] ??
|
|
"macos";
|
|
try {
|
|
config.fonts = utilsDeps.generateRandomFontSubset(
|
|
osName,
|
|
utilsDeps.identitySeed(config, salt),
|
|
// host's own OS on macOS/Windows: the real system fonts are used
|
|
// (font-hijacker.patch keeps the bundle inactive), so only the OS
|
|
// base is claimed
|
|
(targetOs === "mac" || targetOs === "win") &&
|
|
utilsDeps.hostOsKey() === targetOs,
|
|
);
|
|
} catch (e) {
|
|
if (!isPyError(e, "OSError", "ValueError")) throw e;
|
|
FallbackWarning.warn(
|
|
"Drawing the font list",
|
|
`every font fonts.json lists for ${targetOs}`,
|
|
e,
|
|
config["navigator.userAgent"],
|
|
);
|
|
updateFonts(config, targetOs);
|
|
}
|
|
}
|
|
|
|
// Draw the identity's media devices unless the caller set any mediaDevices:
|
|
// key. An empty enumerateDevices() list is a headless tell; a wrong label
|
|
// after a grant is a spoof tell.
|
|
if (!userSetMediaDevices) {
|
|
await utilsDeps.setMediaDevicesDefaults(config, salt);
|
|
}
|
|
|
|
// Scrollbars: pin the look-and-feel to the claimed OS so headless == headed
|
|
// == stock, and for Windows to the version the identity's font draw
|
|
// presents (Win11 fonts with classic scrollbars is a pair no real machine
|
|
// produces).
|
|
if (targetOs === "win") {
|
|
const presentedFonts: string[] = config.fonts || [];
|
|
const windows11 =
|
|
!presentedFonts.length ||
|
|
presentedFonts.some((font) => WINDOWS_11_MARKER_FONTS.has(font));
|
|
setInto(firefox_user_prefs, "ui.useOverlayScrollbars", windows11 ? 1 : 0);
|
|
} else {
|
|
setInto(firefox_user_prefs, "ui.useOverlayScrollbars", 1);
|
|
}
|
|
|
|
// Per-character font fallback, LINUX ONLY: with async fallback on, the
|
|
// first measurement of a character only one bundled family provides returns
|
|
// the primary family's .notdef. macOS must NOT get this.
|
|
if (targetOs === "lin") {
|
|
setInto(firefox_user_prefs, "gfx.font_rendering.fallback.async", false);
|
|
}
|
|
|
|
// Storage quota, from the host's own disk (see stockProfileDiskCapacityKb).
|
|
const quotaLimitKb = utilsDeps.stockProfileDiskCapacityKb();
|
|
if (quotaLimitKb) {
|
|
setInto(firefox_user_prefs, QUOTA_FIXED_LIMIT_PREF, quotaLimitKb);
|
|
}
|
|
|
|
// navigator.doNotTrack and navigator.globalPrivacyControl are pref-backed in
|
|
// Firefox; spoofing them anywhere else leaves the wire (or the worker)
|
|
// contradicting the API (daijro/camoufox#760). A stock Firefox 152 reports
|
|
// "unspecified" and false, so generated values are dropped unless the
|
|
// caller set them explicitly. screen.colorDepth is left as drawn.
|
|
if (!userSetDnt) {
|
|
delete config["navigator.doNotTrack"];
|
|
}
|
|
if (!userSetGpc) {
|
|
delete config["navigator.globalPrivacyControl"];
|
|
}
|
|
const dnt = config["navigator.doNotTrack"];
|
|
firefox_user_prefs["privacy.donottrackheader.enabled"] =
|
|
dnt != null && pyStr(dnt) === "1";
|
|
const gpc = config["navigator.globalPrivacyControl"];
|
|
firefox_user_prefs["privacy.globalprivacycontrol.enabled"] =
|
|
gpc != null ? isTruthy(gpc) : false;
|
|
|
|
// Accept-Encoding: Firefox's own value is already what the identity claims,
|
|
// so the generated header is dropped unless the caller set it.
|
|
if (!userSetAcceptEncoding) {
|
|
delete config["headers.Accept-Encoding"];
|
|
}
|
|
|
|
// The audio noise seed follows the identity: a returning "same device" must
|
|
// reproduce its audio hash (#442/#765). Never 0 (0 disables the noise).
|
|
// There is no canvas seed: the browser adds no canvas noise (#528), and no
|
|
// glyph-spacing noise either (ci/tribal-rules.yml: no-glyph-spacing-noise).
|
|
if (!userSetAudioSeed) {
|
|
config["audio:seed"] = audioSeedFromIdentity(
|
|
utilsDeps.identitySeed(config, salt),
|
|
);
|
|
}
|
|
|
|
// Set geolocation
|
|
if (isTruthy(geoip)) {
|
|
geoipAllowed(); // Assert that geoip is allowed
|
|
|
|
let geoipIp: string;
|
|
if (geoip === true) {
|
|
// Find the user's IP address
|
|
geoipIp = proxy
|
|
? await utilsDeps.publicIp(ProxyHelper.asString(proxy))
|
|
: await utilsDeps.publicIp();
|
|
} else {
|
|
geoipIp = geoip as string;
|
|
}
|
|
|
|
// Spoof WebRTC if not blocked
|
|
if (!block_webrtc) {
|
|
if (validIPv4(geoipIp)) {
|
|
setInto(config, "webrtc:ipv4", geoipIp);
|
|
firefox_user_prefs["network.dns.disableIPv6"] = true;
|
|
} else if (validIPv6(geoipIp)) {
|
|
setInto(config, "webrtc:ipv6", geoipIp);
|
|
}
|
|
}
|
|
|
|
const geolocation = await utilsDeps.getGeolocation(geoipIp, geoip_db);
|
|
for (const [key, value] of Object.entries(geolocation.asConfig())) {
|
|
if (
|
|
[
|
|
"timezone",
|
|
"locale:language",
|
|
"locale:region",
|
|
"locale:script",
|
|
].includes(key)
|
|
) {
|
|
setInto(config, key, value);
|
|
} else {
|
|
config[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A page that receives a position without a prompt must also see
|
|
// permissions.query({name: 'geolocation'}) report "granted" (#769).
|
|
if ("geolocation:latitude" in config && "geolocation:longitude" in config) {
|
|
setInto(firefox_user_prefs, "permissions.default.geo", 1);
|
|
} else if (
|
|
// Raise a warning when a proxy is being used without spoofing
|
|
// geolocation. This warning cannot be ignored with i_know_what_im_doing.
|
|
isTruthy(proxy) &&
|
|
!(proxy?.server ?? "").includes("localhost") &&
|
|
!isDomainSet(config, "geolocation")
|
|
) {
|
|
LeakWarning.warn("proxy_without_geoip");
|
|
}
|
|
|
|
// Set locale
|
|
if (isTruthy(locale)) {
|
|
await handleLocales(locale as string | string[], config);
|
|
}
|
|
|
|
// Select the browser's UI locale to match the Intl locale. Always set: an
|
|
// EMPTY value would follow the host OS locale.
|
|
let requested: string;
|
|
if (isTruthy(config["locale:language"])) {
|
|
requested = [
|
|
config["locale:language"],
|
|
config["locale:script"],
|
|
config["locale:region"],
|
|
]
|
|
.filter((part) => isTruthy(part))
|
|
.join("-");
|
|
} else {
|
|
requested = "en-US";
|
|
}
|
|
setInto(firefox_user_prefs, "intl.locale.requested", requested);
|
|
|
|
// Spoof the speech-synthesis voice list. This has to fail CLOSED: leaving
|
|
// `voices` unset exposes every native voice on the box (#731). Drawn after
|
|
// the locale is resolved: the Windows voice list is the display language's
|
|
// pack.
|
|
if (!userSetVoices || !("voices" in config)) {
|
|
const osNameV =
|
|
({ win: "windows", mac: "macos", lin: "linux" } as const)[targetOs] ??
|
|
"macos";
|
|
let voiceLocale = config["navigator.language"];
|
|
if (isTruthy(config["locale:language"])) {
|
|
voiceLocale = [config["locale:language"], config["locale:region"]]
|
|
.filter((part) => isTruthy(part))
|
|
.join("-");
|
|
}
|
|
try {
|
|
config.voices = utilsDeps.generateRandomVoiceSubset(
|
|
osNameV,
|
|
voiceLocale ?? null,
|
|
utilsDeps.identitySeed(config, salt),
|
|
);
|
|
} catch (e) {
|
|
if (!isPyError(e, "OSError", "ValueError", "KeyError")) throw e;
|
|
// An empty list still blocks the host's voices (see below), so a
|
|
// generation failure degrades to "no voices" rather than "all of
|
|
// the host's".
|
|
FallbackWarning.warn(
|
|
"Drawing the speech voices",
|
|
"no speech voices",
|
|
e,
|
|
config["navigator.userAgent"],
|
|
);
|
|
config.voices = [];
|
|
}
|
|
}
|
|
|
|
// Pin the block explicitly instead of relying on a non-empty list to imply
|
|
// it. setInto leaves an explicit caller value alone.
|
|
setInto(config, "voices:blockIfNotDefined", true);
|
|
|
|
// Pass the humanize option
|
|
if (isTruthy(humanize)) {
|
|
setInto(config, "humanize", true);
|
|
// MaskConfig expects maxTime to be a JSON number.
|
|
// float(humanize): a JSON number with a floating-point representation.
|
|
if (typeof humanize === "number") {
|
|
setInto(config, "humanize:maxTime", new PyFloat(humanize));
|
|
}
|
|
}
|
|
|
|
// Enable the main world context creation
|
|
if (main_world_eval) {
|
|
setInto(config, "allowMainWorld", true);
|
|
}
|
|
|
|
// Allow addon open new tabs
|
|
if (allow_addon_new_tab) {
|
|
setInto(config, "allowAddonNewtab", true);
|
|
}
|
|
|
|
// Set Firefox user preferences
|
|
if (block_images) {
|
|
LeakWarning.warn("block_images", i_know_what_im_doing);
|
|
firefox_user_prefs["permissions.default.image"] = 2;
|
|
}
|
|
if (block_webrtc) {
|
|
firefox_user_prefs["media.peerconnection.enabled"] = false;
|
|
}
|
|
if (disable_coop) {
|
|
LeakWarning.warn("disable_coop", i_know_what_im_doing);
|
|
firefox_user_prefs["browser.tabs.remote.useCrossOriginOpenerPolicy"] =
|
|
false;
|
|
}
|
|
// A persistent context takes its context options here.
|
|
if (passthrough.is_mobile || passthrough.isMobile) {
|
|
LeakWarning.warn("is_mobile", i_know_what_im_doing);
|
|
}
|
|
|
|
// Drop values the source supplied that this identity cannot keep, before the
|
|
// WebGL pool below defers to them. Here, not earlier, because the check
|
|
// reads the core count and the screen, which the launch replaces above.
|
|
coherence.dropIncoherentSourceValues(config, targetOs);
|
|
|
|
if (block_webgl) {
|
|
firefox_user_prefs["webgl.disabled"] = true;
|
|
LeakWarning.warn("block_webgl", i_know_what_im_doing);
|
|
} else {
|
|
let webglFp: Record<string, any>;
|
|
const seed = () =>
|
|
utilsDeps.identitySeed(config as Record<string, any>, salt);
|
|
// A pair the caller named, or the preset's own GPU, keeps its name and
|
|
// gets that device's recorded parameters. webglForGpu raises for a GPU
|
|
// fpgen has never seen: the caller asked for something that does not exist.
|
|
if (isTruthy(webgl_config)) {
|
|
const [vendor, renderer] = webgl_config as [string, string];
|
|
webglFp = await utilsDeps.webglForGpu(targetOs, vendor, renderer, seed());
|
|
} else if (
|
|
isTruthy(config["webGl:vendor"]) &&
|
|
isTruthy(config["webGl:renderer"])
|
|
) {
|
|
webglFp = await utilsDeps.webglForGpu(
|
|
targetOs,
|
|
config["webGl:vendor"],
|
|
config["webGl:renderer"],
|
|
seed(),
|
|
);
|
|
} else {
|
|
// Synthetic path: keep the GPU coherent with the screen fpgen already
|
|
// picked. Sampling the two independently yields pairs no real machine
|
|
// ships -- a discrete desktop GPU behind a 1024x600 panel (#729).
|
|
webglFp = await utilsDeps.sampleWebglForScreen(
|
|
targetOs,
|
|
config["screen.width"],
|
|
config["screen.height"],
|
|
seed(),
|
|
config["navigator.hardwareConcurrency"],
|
|
);
|
|
}
|
|
const enableWebgl2 = webglFp.webGl2Enabled;
|
|
delete webglFp.webGl2Enabled;
|
|
|
|
// Merge the WebGL fingerprint into the config
|
|
mergeInto(config, webglFp);
|
|
// Set the WebGL preferences
|
|
mergeInto(firefox_user_prefs, {
|
|
"webgl.enable-webgl2": enableWebgl2,
|
|
"webgl.force-enabled": true,
|
|
});
|
|
}
|
|
|
|
// Every identity passes the whole-identity checks, whatever built it. The
|
|
// pools are sampled independently, so a machine that never existed can be
|
|
// assembled from parts that are each fine on their own. See coherence.ts.
|
|
const incoherent = coherence.apply(config, targetOs);
|
|
if (incoherent.length && debug) {
|
|
for (const violation of incoherent) {
|
|
console.log(
|
|
`Incoherent identity (${violation.rule}): ${violation.detail}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Cache previous pages, requests, etc (uses more memory)
|
|
if (enable_cache) {
|
|
mergeInto(firefox_user_prefs, CACHE_PREFS);
|
|
}
|
|
|
|
// Print the config if debug is enabled
|
|
if (debug) {
|
|
console.log("[DEBUG] Config:");
|
|
console.log(inspect(config, { depth: null, sorted: true }));
|
|
}
|
|
|
|
// Validate the config
|
|
warnIfExecutablePredatesPlaywright(executable_path);
|
|
utilsDeps.validateConfig(config, executable_path);
|
|
|
|
// Prepare environment variables to pass to Camoufox
|
|
const envVars: EnvVars = {
|
|
...utilsDeps.getEnvVars(config, targetOs, executable_path),
|
|
...getPrefEnvVars(firefox_user_prefs),
|
|
...env,
|
|
};
|
|
|
|
// Prepare the executable path
|
|
let resolvedExecutable: string;
|
|
if (executable_path) {
|
|
resolvedExecutable = String(executable_path);
|
|
} else if (browser) {
|
|
// Select a specific installed browser version
|
|
const browserPath = await utilsDeps.findInstalledVersion(browser);
|
|
if (!browserPath) {
|
|
throw new Error(
|
|
`Browser version '${browser}' not found. Run \`camoufox list\` to see installed versions.`,
|
|
);
|
|
}
|
|
resolvedExecutable = utilsDeps.launchPath(browserPath);
|
|
} else {
|
|
resolvedExecutable = utilsDeps.launchPath();
|
|
}
|
|
|
|
const result: Record<string, any> = {
|
|
executablePath: resolvedExecutable,
|
|
args,
|
|
env: envVars,
|
|
firefoxUserPrefs: firefox_user_prefs,
|
|
headless,
|
|
...passthrough,
|
|
};
|
|
// Only include proxy if it's set (Playwright validates this)
|
|
// https://github.com/coryking/camoufox/commit/1336e8e509e8c12a896a09d9ee51f131f739f106
|
|
// Thanks @coryking
|
|
if (proxy != null) {
|
|
result.proxy = proxy;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Python truthiness for the option values launch_options() tests. */
|
|
function isTruthy(value: unknown): boolean {
|
|
if (value === null || value === undefined || value === false) return false;
|
|
if (value === 0 || value === "" || Number.isNaN(value)) return false;
|
|
if (Array.isArray(value)) return value.length > 0;
|
|
if (typeof value === "object") return Object.keys(value as object).length > 0;
|
|
return true;
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, any> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|