fix(wrapper): track real window geometry on headed launches

Headed launches applied a fixed emulated viewport on top of the real
browser window, yielding outerWidth < innerWidth (an impossible window).
Default headed new_page()/new_context() to no_viewport so the page tracks
the real window; headless keeps a deterministic viewport. Covers Python
launch/launch_context/launch_persistent_context (+async) and the JS
Playwright/Puppeteer wrappers. Explicit viewport still honored.
This commit is contained in:
CloakHQ
2026-06-20 22:53:53 +02:00
parent d67c21abbe
commit 50bf14b3f9
11 changed files with 369 additions and 40 deletions
+9 -6
View File
@@ -200,9 +200,11 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif
// ---------------------------------------------------------------------------
// Default stealth arguments
// ---------------------------------------------------------------------------
// Default viewport — realistic maximized Chrome on 1080p Windows
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
// Default viewport — used for HEADLESS only (headed launches use no viewport so
// the page tracks the real window). Headless has no window chrome, so a fixed
// viewport stays coherent (outer == inner) and gives deterministic dimensions.
// Models a maximized Chrome on 1080p Windows: screen=1920x1080,
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
export function getDefaultStealthArgs(): string[] {
@@ -219,8 +221,9 @@ export function getDefaultStealthArgs(): string[] {
return [...base, "--fingerprint-platform=macos"];
}
// Linux/Windows: spoof as Windows desktop
// Hardware concurrency, device memory, screen, window size, and GPU are
// auto-generated by the binary from the seed (v14+).
// Linux/Windows: spoof as Windows desktop.
// Screen and window size come from the real display, not this flag (verified:
// identical across seeds), so the wrapper must not emulate a viewport on top in
// headed mode — that would break outerWidth >= innerWidth coherence.
return [...base, "--fingerprint-platform=windows"];
}
+55 -2
View File
@@ -52,15 +52,43 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
* Useful when integrating CloakBrowser with an existing Playwright Browser while
* keeping the wrapper's stealth-safe defaults for `newContext()`.
*/
/**
* Effective headless mode for viewport decisions. buildLaunchOptions() spreads
* `...options.launchOptions` LAST, so a raw `launchOptions.headless` overrides the
* top-level field at the actual chromium.launch() call. Viewport logic must read
* the same effective value — otherwise a headed browser gets a fixed viewport
* (reintroducing the impossible-window tell). Playwright-specific (Puppeteer
* resolves headless the opposite way).
*/
function effectiveHeadless(options: LaunchOptions): boolean {
return (
(options.launchOptions as { headless?: boolean } | undefined)?.headless ??
options.headless ??
true
);
}
export function buildContextOptions(
options: LaunchContextOptions = {}
): BrowserContextOptions {
// Headed: viewport=null (no emulation) so the page tracks the real window and
// outerWidth >= innerWidth stays coherent — CDP viewport emulation forces
// inner > outer = a physically impossible window = bot tell. Headless has no
// window chrome (outer == inner), so a fixed viewport stays coherent and keeps
// dimensions deterministic. Explicit viewport (incl. null) is always honored.
const headless = effectiveHeadless(options);
const viewport =
options.viewport !== undefined
? options.viewport
: headless
? DEFAULT_VIEWPORT
: null;
return {
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
} as BrowserContextOptions;
}
@@ -127,10 +155,33 @@ export async function humanizeBrowser(
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
const { chromium } = await import("playwright-core");
const browser = await chromium.launch(await buildLaunchOptions(options));
// Headed: a bare browser.newPage() would inherit Playwright's emulated 1280x720
// viewport -> outerWidth < innerWidth (impossible window = bot tell). Default
// newPage()/newContext() to viewport:null so the page tracks the real window.
// Headless keeps Playwright's default viewport (coherent there).
if (!effectiveHeadless(options)) {
applyDefaultNoViewport(browser);
}
await humanizeBrowser(browser, options);
return browser;
}
/**
* Wrap a Browser's newContext()/newPage() to default to viewport:null (no
* emulation) when the caller didn't specify a viewport. setdefault-style: an
* explicit viewport (including null) is always honored. Apply before humanize's
* patchBrowser so the wraps compose.
*/
function applyDefaultNoViewport(browser: Browser): void {
const origNewContext = browser.newContext.bind(browser);
(browser as any).newContext = (options?: Parameters<typeof origNewContext>[0]) =>
origNewContext(options?.viewport === undefined ? { ...options, viewport: null } : options);
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = (options?: Parameters<typeof origNewPage>[0]) =>
origNewPage(options?.viewport === undefined ? { ...options, viewport: null } : options);
}
/**
* Launch stealth browser and return a BrowserContext with common options pre-set.
* Closing the context also closes the browser.
@@ -161,7 +212,9 @@ export async function launchContext(
// --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.
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
// 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 });
let context: BrowserContext;
try {
+21 -1
View File
@@ -6,13 +6,31 @@
import type { Browser } from "puppeteer-core";
import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
import { seedWidevineHint } from "./widevine.js";
/**
* Resolve Puppeteer's defaultViewport. Headed -> null (track the real window so
* outerWidth >= innerWidth stays coherent; Puppeteer otherwise forces an 800x600
* emulated viewport = a physically impossible window = bot tell). Headless has no
* window chrome (outer == inner), so a fixed viewport stays coherent and keeps
* dimensions deterministic. A user-supplied launchOptions.defaultViewport wins.
*/
function resolveDefaultViewport(options: LaunchOptions): { width: number; height: number } | null {
const launchOpts = (options.launchOptions ?? {}) as Record<string, unknown>;
// A user-supplied defaultViewport wins (incl. explicit null). undefined is NOT
// "supplied" — fall through to our default. Puppeteer sets `headless` AFTER the
// launchOptions spread, so the top-level field wins at launch — match it here.
if (launchOpts.defaultViewport !== undefined) {
return launchOpts.defaultViewport as { width: number; height: number } | null;
}
return (options.headless ?? true) ? DEFAULT_VIEWPORT : null;
}
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
@@ -125,6 +143,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
headless: options.headless ?? true,
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
defaultViewport: resolveDefaultViewport(options),
});
await applyPostLaunch(browser, options, proxyAuth);
@@ -165,6 +184,7 @@ export async function launchPersistentContext(
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
userDataDir: options.userDataDir,
defaultViewport: resolveDefaultViewport(options),
});
await applyPostLaunch(browser, options, proxyAuth);