mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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:
+9
-6
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user