Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@
*/

// Launch functions (Playwright API)
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
export {
launch,
launchContext,
launchPersistentContext,
buildLaunchOptions,
buildLaunchPersistentContextOptions,
buildContextOptions,
humanizeBrowser,
humanizeContext
} from "./playwright.js";

// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
Expand Down
120 changes: 52 additions & 68 deletions js/src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
* Mirrors Python cloakbrowser/browser.py.
*/

import type { Browser, BrowserContext, BrowserContextOptions, LaunchOptions as PlaywrightLaunchOptions } from "playwright-core";
import type {
Browser,
BrowserContext,
BrowserContextOptions,
BrowserType,
LaunchOptions as PlaywrightLaunchOptions
} from "playwright-core";
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
import {
DEFAULT_VIEWPORT,
Expand Down Expand Up @@ -139,6 +145,27 @@ export async function buildLaunchOptions(
} as PlaywrightLaunchOptions;
}

/**
* Build Playwright launchPersistentContextOptions options for CloakBrowser
* without starting Chromium.
*
* Useful when integrating CloakBrowser with a custom Playwright build or another
* wrapper that needs to call `chromium.launchPersistentContext()` itself.
*/
export async function buildLaunchPersistentContextOptions(
options: LaunchPersistentContextOptions
): Promise<Parameters<BrowserType["launchPersistentContext"]>[1]> {
const launchOptions = await buildLaunchOptions(options);
const contextOptions = buildContextOptions(options);

seedWidevineHint(options.userDataDir, launchOptions.executablePath as string);

return {
...launchOptions,
...contextOptions,
}
}

/**
* Apply CloakBrowser's human-like behavioral layer to an existing Playwright browser.
*/
Expand All @@ -157,6 +184,24 @@ export async function humanizeBrowser(
patchBrowser(browser, cfg);
}

/**
* Apply CloakBrowser's human-like behavioral layer to an existing Playwright context.
*/
export async function humanizeContext(
context: BrowserContext,
options: LaunchContextOptions = {}
): Promise<void> {
if (!options.humanize) return;

const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}

/**
* Launch stealth Chromium browser via Playwright.
*
Expand Down Expand Up @@ -223,18 +268,7 @@ function applyDefaultNoViewport(browser: Browser): void {
export async function launchContext(
options: LaunchContextOptions = {}
): Promise<BrowserContext> {
options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
let launchArgs = await resolveWebrtcArgs(options);
// Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call)
launchArgs = appendWebrtcExitIp(launchArgs, exitIp);
// --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// so it applies to ALL contexts, not just the default one.
// locale and timezone are set via binary flags only — no CDP emulation.
// humanize:false on the inner launch — patchContext below applies humanize
// exactly once (else launch()'s humanizeBrowser would patch it a second time).
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false, humanize: false });
const browser = await launch(options);

let context: BrowserContext;
try {
Expand All @@ -251,17 +285,7 @@ export async function launchContext(
await browser.close();
};

// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}

await humanizeContext(context, options);
return context;
}

Expand Down Expand Up @@ -291,51 +315,11 @@ export async function launchPersistentContext(
): Promise<BrowserContext> {
options = resolveTimezone(options);
const { chromium } = await import("playwright-core");

const binaryPath =
process.env.CLOAKBROWSER_BINARY_PATH ||
(await ensureBinary(options.licenseKey, options.browserVersion));
const { exitIp, ...resolved } = await maybeResolveGeoip(options);
const { proxyOption, proxyArgs } = resolveProxyConfig(options.proxy, options.browserVersion);
let resolvedArgs = await resolveWebrtcArgs(options);
resolvedArgs = appendWebrtcExitIp(resolvedArgs, exitIp);
const args = buildArgs({ ...options, ...resolved, args: [...(resolvedArgs ?? []), ...proxyArgs] });
maybeWarnWindowsFonts(args);

seedWidevineHint(options.userDataDir, binaryPath);

// Resolve env for the browser process (license key injection, if needed).
const { env: userEnv, ...restLaunchOptions } = options.launchOptions ?? {};
const launchEnv = buildLaunchEnv(
options.licenseKey,
userEnv as Record<string, string | undefined> | undefined,
const context = await chromium.launchPersistentContext(
options.userDataDir,
await buildLaunchPersistentContextOptions(options),
);
const envResult = launchEnv !== undefined ? { env: launchEnv } : {};

// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, {
executablePath: binaryPath,
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
...buildContextOptions(options),
...restLaunchOptions,
...envResult,
});

// Human-like behavioral patching
if (options.humanize) {
const { patchContext } = await import('./human/index.js');
const { resolveConfig } = await import('./human/config.js');
const cfg = resolveConfig(
options.humanPreset ?? 'default',
options.humanConfig,
);
patchContext(context, cfg);
}

await humanizeContext(context, options);
return context;
}

Expand Down
57 changes: 57 additions & 0 deletions js/tests/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,47 @@ describe("composable Playwright launch helpers", () => {
expect(options.env!.CLOAKBROWSER_LICENSE_KEY).toBe("cb_key");
});

it("buildLaunchPersistentContextOptions returns Playwright options without launching a browser", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { buildLaunchPersistentContextOptions } = await import("../src/index.js");

const options = await buildLaunchPersistentContextOptions({
userDataDir: "/fake/data-dir",
headless: false,
proxy: "http://user:pass@proxy.example:8080",
args: ["--custom-flag"],
viewport: { width: 1900, height: 1080 },
colorScheme: "dark",
launchOptions: { slowMo: 1234 },
contextOptions: {
acceptDownloads: true,
locale: "fr-FR",
},
});

expect(options).toMatchObject({
executablePath: "/fake/chrome",
headless: false,
proxy: {
server: "http://proxy.example:8080",
username: "user",
password: "pass",
},
viewport: { width: 1900, height: 1080 },
colorScheme: "dark",
slowMo: 1234,
acceptDownloads: true,
});
expect(options.args).toContain("--custom-flag");
expect(options.ignoreDefaultArgs).toContain("--enable-automation");
expect(options.locale).toBeUndefined();
} finally {
vi.restoreAllMocks();
}
});

it("humanizeBrowser patches an existing browser only when requested", async () => {
const { humanizeBrowser } = await import("../src/index.js");
const browser = {
Expand All @@ -225,6 +266,22 @@ describe("composable Playwright launch helpers", () => {
await humanizeBrowser(browser as any, { humanize: true });
expect(browser.newContext).not.toBe(originalNewContext);
});

it("humanizeContext patches an existing context only when requested", async () => {
const { humanizeContext } = await import("../src/index.js");
const context = {
pages: () => [],
on: vi.fn(() => undefined),
newPage: vi.fn(async () => ({})),
};
const originalNewPage = context.newPage;

await humanizeContext(context as any, { humanize: false });
expect(context.newPage).toBe(originalNewPage);

await humanizeContext(context as any, { humanize: true });
expect(context.newPage).not.toBe(originalNewPage);
});
});

// Integration tests require the binary — run with:
Expand Down