capiumbrowser 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,7 @@ await browser.close();
66
66
  - [Install](#install) · [Why Capium](#why-capium) · [Licensing & tiers](#licensing)
67
67
  - [What a persona reads](#what-a-persona-reads) · [How it works](#how-it-works)
68
68
  - [API](#api) · [Options](#options) · [Personas & seeds](#personas--seeds) · [Fingerprint switches](#fingerprint-switches)
69
+ - [Browser and mobile profiles](#browser-and-mobile-profiles)
69
70
  - [Human behavior](#human-behavior) · [Proxies & geo](#proxies--geo) · [Fonts](#fonts)
70
71
  - [Framework integrations](#framework-integrations) · [Deployment](#deployment) · [Platforms](#platforms)
71
72
  - [CLI](#cli--environment) · [Troubleshooting](#troubleshooting) · [FAQ](#faq) · [Security](#security) · [License](#license)
@@ -340,7 +341,9 @@ Python SDK, in camelCase.
340
341
  | Option | Type | Default | Description |
341
342
  |---|---|---|---|
342
343
  | `seed` | `number` \| `null` | `null` | Persona seed → a complete coherent device. `null` = fresh random seed each launch. The range selects the OS family (see [Personas & seeds](#personas--seeds)). |
343
- | `platform` | `string` | `"windows"` | Spoofed OS: `"windows"` \| `"macos"` \| `"linux"`. Sets `--fingerprint-platform` and picks the seed range if you didn't. |
344
+ | `platform` | `string` | `"windows"` | OS profile: `"windows"` \| `"macos"` \| `"linux"` \| `"android"` \| `"ios"`. Mobile profiles are opt-in. |
345
+ | `browserBrand` | `string` | `"chrome"` | Desktop profile: `chrome`, `edge`, `opera`, `vivaldi`. |
346
+ | `mobileDevice` | `string` | seed/default | Phone catalog ID; requires `android` or `ios`. |
344
347
  | `headless` | `boolean` | `false` | Headed is recommended (reads cleaner). On a server, run headed under Xvfb. |
345
348
  | `stealthArgs` | `boolean` | `true` | Apply the default coherent fingerprint flags. Set `false` to build the whole command line yourself via `args`. |
346
349
  | `args` | `string[]` | — | Extra raw Chrome flags appended verbatim (see [Fingerprint switches](#fingerprint-switches)). |
@@ -442,6 +445,43 @@ Full runnable version (personas, persistent profiles, a fleet, proxy+geo, full c
442
445
 
443
446
  ---
444
447
 
448
+ ## Browser and mobile profiles
449
+
450
+ CapiumBrowser 1.2.1 supports desktop Chrome, Edge, Opera and Vivaldi identity
451
+ profiles, plus opt-in Android and iPhone profiles on Windows x64, macOS arm64
452
+ and Linux x64. Chrome remains the default.
453
+
454
+ | Profile | Python | Node.js |
455
+ | --- | --- | --- |
456
+ | Desktop browser | `browser_brand="edge"` (also `chrome`, `opera`, `vivaldi`) | `browserBrand: 'edge'` |
457
+ | Android phone | `platform="android", mobile_device="pixel-9"` | `platform: 'android', mobileDevice: 'pixel-9'` |
458
+ | iPhone 16 | `platform="ios", mobile_device="iphone-16"` | `platform: 'ios', mobileDevice: 'iphone-16'` |
459
+
460
+ Android includes Pixel 7/8/9 families and Samsung Galaxy A55. Omit the Android
461
+ device to retain the original seed selection; iOS defaults to iPhone 16. Mobile
462
+ profiles set the mobile layout and identity while leaving desktop defaults intact.
463
+ These are experimental desktop emulation profiles: they do not install Android
464
+ or iOS, reproduce WebKit, or certify physical-phone fingerprints. Android Chrome
465
+ and Chrome iOS are the supported mobile browser profiles; arbitrary app/WebView
466
+ UAs are outside these presets.
467
+
468
+ Desktop profiles use verified browser/Chromium version pairs: Edge 155/155,
469
+ Opera 137/153 and Vivaldi 8.2/152. Vivaldi follows its usual Chrome branding.
470
+ Capium's engine remains Chromium 155; vendor-specific features are not emulated.
471
+ A version catalog is not an older browser engine. Native compatibility checks
472
+ reject binaries that do not implement the selected profile.
473
+
474
+ ```js
475
+ const { launchContext } = require('capiumbrowser');
476
+
477
+ const { browser, page } = await launchContext({
478
+ seed: 200123, platform: 'ios', mobileDevice: 'iphone-16',
479
+ });
480
+ // Android: platform: 'android', mobileDevice: 'pixel-9'
481
+ // Desktop Edge: platform: 'windows', browserBrand: 'edge'
482
+ await browser.close();
483
+ ```
484
+
445
485
  ## Fingerprint switches
446
486
 
447
487
  The SDK sets the essentials from `seed`/`platform`. Everything below is available for precise control —
@@ -670,13 +710,12 @@ launches **headed**, which reads cleaner than headless.
670
710
  | Linux arm64 | `linux-arm64` | Declared in the manifest; not yet published or covered by the three-host audit |
671
711
 
672
712
  The SDK auto-detects your host and downloads the matching tag; macOS Intel and Windows ARM are **not**
673
- published and throw a clear error. The **1.2.0** manifest selects **Chromium 155.0.8059.39, revision 1** on the three
674
- supported hosts. This release is prepared as a draft; downloads become available when the browser
675
- artifacts are published. Linux arm64 remains unpublished.
713
+ published and throw a clear error. The **1.2.1** manifest selects **Chromium 155.0.8059.39, revision 2** on the three
714
+ supported hosts. Browser downloads are available with a valid license. Linux arm64 remains unpublished.
676
715
 
677
716
  ---
678
717
 
679
- The 1.2.0 launch APIs wait for the packaged browser's licensing decision before
718
+ The launch APIs wait for the packaged browser's licensing decision before
680
719
  returning a handle. Invalid keys raise a typed error during launch.
681
720
 
682
721
  ## CLI & environment
package/index.d.ts CHANGED
@@ -9,7 +9,11 @@
9
9
  * const browser = (await launch({ seed: 42 })) as unknown as Browser;
10
10
  */
11
11
 
12
- export type Platform = 'windows' | 'macos' | 'linux';
12
+ /** Mobile profiles emulate identity/layout on the supported desktop hosts. */
13
+ export type Platform = 'windows' | 'macos' | 'linux' | 'android' | 'ios';
14
+ export type AndroidDevice = 'pixel-7' | 'pixel-8' | 'pixel-8-pro' | 'pixel-8a' | 'pixel-9' |
15
+ 'samsung-galaxy-a55' | 'pixel-9-pro' | 'pixel-9-pro-xl';
16
+ export type IOSDevice = 'iphone-16';
13
17
 
14
18
  export interface ProxySpec {
15
19
  /** "scheme://host:port" (http, https, socks5). */
@@ -21,10 +25,14 @@ export interface ProxySpec {
21
25
  }
22
26
 
23
27
  export interface LaunchOptions {
28
+ /** Desktop identity profile; vendor features are not emulated. */
29
+ browserBrand?: 'chrome' | 'edge' | 'opera' | 'vivaldi' | null;
24
30
  /** Identity seed (a stable, coherent device per seed). Random if omitted. */
25
31
  seed?: number | null;
26
32
  /** The spoofed OS persona. Default "windows". */
27
33
  platform?: Platform;
34
+ /** Phone catalog ID. Android uses the original seed mapping; iOS defaults to iPhone 16. */
35
+ mobileDevice?: AndroidDevice | IOSDevice | null;
28
36
  /** Default false (a windowed browser is the coherent persona default). */
29
37
  headless?: boolean;
30
38
  /** "http://user:pass@host:port" | "socks5://host:port" | ProxySpec. */
@@ -102,7 +110,8 @@ export function status(licenseKey?: string | null, licenseServer?: string | null
102
110
 
103
111
  export namespace config {
104
112
  function getDefaultStealthArgs(seed?: number | null, platform?: Platform,
105
- screen?: [number, number] | null): string[];
113
+ screen?: [number, number] | null, mobileDevice?: AndroidDevice | null,
114
+ browserBrand?: 'chrome' | 'edge' | 'opera' | 'vivaldi' | null): string[];
106
115
  function newSeed(): number;
107
116
  function findBinary(binary?: string | null): string;
108
117
  }
@@ -0,0 +1,99 @@
1
+ {
2
+ "schema_version": 2,
3
+ "minimum_release_year": 2022,
4
+ "browser": "chrome",
5
+ "status": "experimental-emulation",
6
+ "gpu_policy": "host",
7
+ "hardware_policy": "host",
8
+ "physical_device_validation": "pending",
9
+ "default_profile_ids": [
10
+ "pixel-7",
11
+ "pixel-8",
12
+ "pixel-8-pro",
13
+ "pixel-8a",
14
+ "pixel-9"
15
+ ],
16
+ "sources": [
17
+ "https://support.google.com/pixelphone/answer/7158570?hl=en",
18
+ "https://news.samsung.com/global/samsung-galaxy-a55-5g-and-galaxy-a35-5g-awesome-innovations-and-security-engineered-for-everyone",
19
+ "https://www.samsung.com/ie/business/smartphones/galaxy-a/galaxy-a55-5g-awesome-navy-256gb-sm-a556bzkceub/",
20
+ "https://chromium.googlesource.com/devtools/devtools-frontend/+/cd7bfad9bba968ac23ba67e538f9e1d499991d25/front_end/models/emulation/EmulatedDevices.ts"
21
+ ],
22
+ "profiles": [
23
+ {
24
+ "id": "pixel-7",
25
+ "model": "Pixel 7",
26
+ "release_year": 2022,
27
+ "platform_version": "13",
28
+ "width": 412,
29
+ "height": 915,
30
+ "dpr": 2.625
31
+ },
32
+ {
33
+ "id": "pixel-8",
34
+ "model": "Pixel 8",
35
+ "release_year": 2023,
36
+ "platform_version": "14",
37
+ "width": 412,
38
+ "height": 915,
39
+ "dpr": 2.625
40
+ },
41
+ {
42
+ "id": "pixel-8-pro",
43
+ "model": "Pixel 8 Pro",
44
+ "release_year": 2023,
45
+ "platform_version": "14",
46
+ "width": 448,
47
+ "height": 997,
48
+ "dpr": 3.0
49
+ },
50
+ {
51
+ "id": "pixel-8a",
52
+ "model": "Pixel 8a",
53
+ "release_year": 2024,
54
+ "platform_version": "14",
55
+ "width": 412,
56
+ "height": 915,
57
+ "dpr": 2.625
58
+ },
59
+ {
60
+ "id": "pixel-9",
61
+ "model": "Pixel 9",
62
+ "release_year": 2024,
63
+ "platform_version": "14",
64
+ "width": 412,
65
+ "height": 924,
66
+ "dpr": 2.625
67
+ },
68
+ {
69
+ "id": "samsung-galaxy-a55",
70
+ "model": "SM-A556B",
71
+ "display_name": "Samsung Galaxy A55",
72
+ "release_year": 2024,
73
+ "platform_version": "14",
74
+ "width": 360,
75
+ "height": 800,
76
+ "dpr": 2.25,
77
+ "geometry_status": "devtools-emulation-only",
78
+ "geometry_note": "These Chromium preset dimensions/DPR do not directly map to the published 1080x2340 panel; physical Chrome capture required."
79
+ },
80
+ {
81
+ "id": "pixel-9-pro",
82
+ "model": "Pixel 9 Pro",
83
+ "release_year": 2024,
84
+ "platform_version": "14",
85
+ "width": 427,
86
+ "height": 952,
87
+ "dpr": 3.0
88
+ },
89
+ {
90
+ "id": "pixel-9-pro-xl",
91
+ "model": "Pixel 9 Pro XL",
92
+ "release_year": 2024,
93
+ "platform_version": "14",
94
+ "width": 448,
95
+ "height": 997,
96
+ "dpr": 3.0
97
+ }
98
+ ]
99
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "schema_version": 1,
3
+ "verified_on": "2026-10-10",
4
+ "engine_version": "155.0.8059.39",
5
+ "scope": "Desktop UA and Client Hints profiles; does not install vendor engines or features.",
6
+ "profiles": [
7
+ {
8
+ "brand": "edge",
9
+ "product_version": "155.0.4283.45",
10
+ "chromium_version": "155.0.8059.40",
11
+ "hint_brand": "Microsoft Edge",
12
+ "ua_suffix": " Edg/",
13
+ "ua_version": "155.0.0.0",
14
+ "sources": [
15
+ "https://edgeupdates.microsoft.com/api/products?view=enterprise"
16
+ ],
17
+ "measurement": "Stock Edge 155 Windows and Linux owned-origin captures"
18
+ },
19
+ {
20
+ "brand": "opera",
21
+ "product_version": "137.0.6036.39",
22
+ "chromium_version": "153.0.8010.55",
23
+ "hint_brand": "Opera",
24
+ "ua_suffix": " OPR/",
25
+ "ua_version": "137.0.0.0",
26
+ "sources": [
27
+ "https://blogs.opera.com/desktop/changelog-for-137/",
28
+ "https://blogs.opera.com/desktop/2026/10/opera-137-stable/"
29
+ ],
30
+ "measurement": "Stock Opera 137 Linux owned-origin capture"
31
+ },
32
+ {
33
+ "brand": "vivaldi",
34
+ "product_version": "8.2.4133.84",
35
+ "chromium_version": "152.0.7977.160",
36
+ "hint_brand": "Google Chrome",
37
+ "ua_suffix": "",
38
+ "ua_version": "",
39
+ "sources": [
40
+ "https://vivaldi.com/blog/minor-update-nine-8-2/",
41
+ "https://vivaldi.com/blog/desktop/",
42
+ "https://help.vivaldi.com/developers/web/vivaldi-user-agent-and-client-hints-user-agent/"
43
+ ],
44
+ "measurement": "Stock Vivaldi 8.2 Linux owned-origin capture; default branding only, partner-site exceptions excluded"
45
+ }
46
+ ]
47
+ }
package/lib/channels.json CHANGED
@@ -1,32 +1,36 @@
1
1
  {
2
- "_comment": "SINGLE SOURCE OF TRUTH for the current stable Capium browser build per OS/arch. Each OS reaches stable independently -- edit one entry without touching the others. This file ships inside the wheel (the SDK reads it to pick each host's stable build) AND is published to the download server by the release pipeline. `version` is the Chromium version (what the browser reports to the customer -- unchanged by the revision); `revision` is our packaging counter -- the served R2 folder is chromium-v<version>.<revision>/, ALWAYS dotted-suffixed starting at 1 (chromium-v<version>.1/ is the first build, .2/ a same-engine re-spin), so publishing a re-spin never overwrites the artifact older SDKs still fetch. `published: false` means the tag is declared but no artifact exists yet -- the SDK raises a clear 'not published yet' error instead of a raw 404.",
2
+ "_comment": "SINGLE SOURCE OF TRUTH for the current stable Capium browser build per OS/arch. Each OS reaches stable independently -- edit one entry without touching the others. This file ships inside the wheel (the SDK reads it to pick each host's stable build) AND is published to the download server by the release pipeline. `version` is the reported Chromium version (what the browser reports to the customer, names the R2 folder); `engine_version` is the actual built Chromium engine (box_package verifies the binary against it) -- it differs from `version` when a platform reports a spoofed patch (Windows reports .85 on the .84 engine). `revision` is our packaging counter -- the served R2 folder is chromium-v<version>.<revision>/, ALWAYS dotted-suffixed starting at 1. A Chromium version bump resets revision to 1. `published: false` means the tag is declared but no artifact exists yet.",
3
3
  "schema": 1,
4
- "updated": "2026-10-10",
4
+ "updated": "2026-10-21",
5
5
  "channels": {
6
6
  "stable": {
7
7
  "windows-x64": {
8
- "version": "155.0.8059.39",
8
+ "version": "155.0.8059.85",
9
+ "engine_version": "155.0.8059.84",
9
10
  "revision": 1,
10
- "sha256": "d973e2460ce05e99f7e5c1dddc319ae96733434d9986dadc4cec5bac1c2597fb",
11
+ "sha256": "e10c944fc23637adb8d2f9206dbd69c595d51ba9de0cfd8140c9a2b5dc3510fc",
11
12
  "published": true,
12
13
  "license_status_protocol": 1
13
14
  },
14
15
  "macos-arm64": {
15
- "version": "155.0.8059.39",
16
+ "version": "155.0.8059.84",
17
+ "engine_version": "155.0.8059.84",
16
18
  "revision": 1,
17
- "sha256": "f07d54b88feb6644388196d6b91c9787592c1f6e07a8122344f47faed76b9262",
19
+ "sha256": "273bdd754b06538f633d2830f6fb8e3f02df226f093cb42acd052c0fece74127",
18
20
  "published": true,
19
21
  "license_status_protocol": 1
20
22
  },
21
23
  "linux-x64": {
22
- "version": "155.0.8059.39",
24
+ "version": "155.0.8059.84",
25
+ "engine_version": "155.0.8059.84",
23
26
  "revision": 1,
24
- "sha256": "a15cd395e1f8b792d9539c8284edcd589c6b91ef77f8b1fa5ea408f1f220b540",
27
+ "sha256": "e01b9b2179d82594e552f17aa0fad620785b4dfbf368c10c0ea146b5ce31c4e4",
25
28
  "published": true,
26
29
  "license_status_protocol": 1
27
30
  },
28
31
  "linux-arm64": {
29
- "version": "155.0.8059.39",
32
+ "version": "155.0.8059.84",
33
+ "engine_version": "155.0.8059.84",
30
34
  "revision": 1,
31
35
  "sha256": null,
32
36
  "published": false
package/lib/config.js CHANGED
@@ -24,12 +24,14 @@ const path = require('path');
24
24
  // the deployed environment for either backend.
25
25
  const IGNORE_DEFAULT_ARGS = ['--enable-automation', '--enable-unsafe-swiftshader'];
26
26
 
27
- // The 155 engine selects a seeded screen within these default bounds. Start
28
- // headless windows small enough to fit its modes without replacing the screen.
29
- // Explicit screen/window flags take
30
- // precedence. HEADLESS_SCREEN remains the upper bound for existing consumers.
27
+ // The 155 engine selects a seeded screen within these default bounds. The headless
28
+ // window is sized PER SEED after launch (fitWindow -> the seed's screen.availWidth/Height),
29
+ // giving a coherent maximized viewport < screen for every profile. HEADLESS_VIEWPORT is only
30
+ // the brief STARTUP fallback before that fit runs; it must fit the smallest default seeded
31
+ // screen (win/linux min is 1280x720), so it is NOT the 800x600 headless-Chrome default tell.
32
+ // Explicit screen/window flags take precedence.
31
33
  const HEADLESS_SCREEN = [1920, 1080];
32
- const HEADLESS_VIEWPORT = [800, 600];
34
+ const HEADLESS_VIEWPORT = [1280, 720];
33
35
 
34
36
  function getHeadlessDefaultArgs() {
35
37
  const [vw, vh] = HEADLESS_VIEWPORT;
@@ -48,13 +50,35 @@ function hasScreenOverride(extra) {
48
50
  * Return the default per-launch fingerprint flags.
49
51
  *
50
52
  * seed: int identity seed (a stable, coherent device per seed). Random if null.
51
- * platform: "windows" | "macos" | "linux" -- the spoofed OS persona.
53
+ * platform: "windows" | "macos" | "linux" | "android" | "ios" -- the OS persona.
54
+ * mobileDevice: optional Android catalog ID; omitted keeps the seed modulo 5 mapping.
52
55
  * screen: null uses the binary's seeded screen and DPR. Development 155 defaults
53
56
  * vary within 1920x1080. Headless starts with an 800x600 window; its screen
54
57
  * remains independent of the viewport. An explicit [w,h] overrides the screen
55
58
  * and startup window, including sizes above the default cap.
56
59
  */
57
- function getDefaultStealthArgs(seed = null, platform = 'windows', screen = null) {
60
+ function getDefaultStealthArgs(seed = null, platform = 'windows', screen = null, mobileDevice = null, browserBrand = null) {
61
+ const desktopArgs = require('./desktop').brandArgs(browserBrand, platform);
62
+ if (mobileDevice !== null && mobileDevice !== undefined && !['android', 'ios'].includes(platform)) {
63
+ throw new Error("mobileDevice requires platform: 'android' or 'ios'");
64
+ }
65
+ if (['android', 'ios'].includes(platform)) {
66
+ const p = require('./mobile').mobileProfile(seed, mobileDevice, platform);
67
+ const [sw, sh] = screen ? screen.map(v => Math.trunc(Number(v))) : [p.width, p.height];
68
+ return [
69
+ `--fingerprint=${seed}`, `--fingerprint-platform=${platform}`,
70
+ ...(mobileDevice !== null && mobileDevice !== undefined ? [`--fingerprint-mobile-device=${mobileDevice}`] : []),
71
+ '--fingerprint-allow-3p-cookies', '--disable-features=TrackingProtection3pcd',
72
+ '--blink-settings=primaryHoverType=0,availableHoverTypes=0,' +
73
+ 'primaryPointerType=2,availablePointerTypes=2,maxTouchPoints=5,' +
74
+ 'viewportEnabled=true,viewportMetaEnabled=true,shrinksViewportContentToFit=true',
75
+ '--touch-events=enabled', '--enable-viewport',
76
+ '--no-first-run', '--no-default-browser-check', '--fingerprint-noise=false',
77
+ `--window-size=${sw},${sh}`,
78
+ ...(platform === 'ios' ? [`--user-agent=${p.user_agent}`, '--capium-preserve-user-agent'] : []),
79
+ ...(screen ? [`--fingerprint-screen-width=${sw}`, `--fingerprint-screen-height=${sh}`] : []),
80
+ ];
81
+ }
58
82
  const args = [];
59
83
  if (seed !== null && seed !== undefined) {
60
84
  const s = Math.trunc(Number(seed));
@@ -106,7 +130,7 @@ function getDefaultStealthArgs(seed = null, platform = 'windows', screen = null)
106
130
  args.push(`--fingerprint-screen-width=${sw}`);
107
131
  args.push(`--fingerprint-screen-height=${sh}`);
108
132
  }
109
- return args;
133
+ return args.concat(desktopArgs);
110
134
  }
111
135
 
112
136
  /** A fresh random identity seed. */
package/lib/desktop.js ADDED
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ const catalog = require('./browser-brands.json');
3
+ const profiles = new Map(catalog.profiles.map(p => [p.brand, p]));
4
+
5
+ function normalizeBrand(brand) {
6
+ if (brand === null || brand === undefined) return 'chrome';
7
+ if (typeof brand !== 'string' || !['chrome', ...profiles.keys()].includes(brand.toLowerCase())) {
8
+ throw new Error('browserBrand must be chrome, edge, opera, or vivaldi');
9
+ }
10
+ return brand.toLowerCase();
11
+ }
12
+
13
+ function brandArgs(brand, platform) {
14
+ brand = normalizeBrand(brand);
15
+ if (brand === 'chrome') return [];
16
+ if (!['windows', 'macos', 'linux'].includes(platform)) throw new Error('browserBrand requires a desktop platform');
17
+ return [`--fingerprint-brand=${brand}`];
18
+ }
19
+
20
+ function validateLaunch(brand, platform, binary, extra, stealthArgs) {
21
+ if (!brandArgs(brand, platform).length) return;
22
+ if (!stealthArgs) throw new Error('browserBrand requires stealthArgs: true');
23
+ const conflicts = ['--user-agent', '--fingerprint-brand', '--fingerprint-brand-version', '--fingerprint-platform'];
24
+ if ((extra || []).some(arg => typeof arg === 'string' && conflicts.some(key => arg === key || arg.startsWith(key + '=')))) {
25
+ throw new Error('browserBrand uses the verified catalog; conflicting identity overrides are not supported');
26
+ }
27
+ }
28
+
29
+ async function engineProbe(expected) {
30
+ const h = await navigator.userAgentData?.getHighEntropyValues(['fullVersionList', 'uaFullVersion']);
31
+ if (!h) return false;
32
+ const chromium = expected.chromium_version;
33
+ const product = expected.hint_brand === 'Google Chrome' ? chromium : expected.product_version;
34
+ const expectedBrands = { Chromium: chromium, [expected.hint_brand]: product };
35
+ const full = Object.fromEntries(h.fullVersionList.map(b => [b.brand, b.version]));
36
+ const low = Object.fromEntries(h.brands.map(b => [b.brand, b.version]));
37
+ return h.uaFullVersion === product && h.mobile === false &&
38
+ Object.entries(expectedBrands).every(([brand, version]) => full[brand] === version && low[brand] === version.split('.')[0]) &&
39
+ ['Google Chrome', 'Microsoft Edge', 'Opera', 'Vivaldi', 'Chromium'].every(brand => !(brand in full) || brand in expectedBrands) &&
40
+ navigator.userAgent.includes('Chrome/' + chromium.split('.')[0] + '.0.0.0') &&
41
+ (!expected.ua_suffix || navigator.userAgent.endsWith(expected.ua_suffix + expected.ua_version)) &&
42
+ (expected.brand !== 'vivaldi' || !navigator.userAgent.includes('Vivaldi/'));
43
+ }
44
+
45
+ function profile(brand) { return { ...profiles.get(normalizeBrand(brand)) }; }
46
+
47
+ async function requireEngine(context, brand) {
48
+ if (normalizeBrand(brand) === 'chrome') return;
49
+ const page = await context.newPage();
50
+ try {
51
+ await page.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html>' }));
52
+ await page.goto('https://capium-desktop-preview.invalid/', { waitUntil: 'domcontentloaded' });
53
+ if (!await page.evaluate(engineProbe, profile(brand))) throw new Error('This binary does not support the verified desktop browser-brand mappings; use Capium 1.2.1 revision 2 or later');
54
+ } finally { await page.close(); }
55
+ }
56
+
57
+ async function requirePuppeteerEngine(browser, brand) {
58
+ if (normalizeBrand(brand) === 'chrome') return;
59
+ const page = await browser.newPage();
60
+ try {
61
+ await page.setRequestInterception(true);
62
+ page.on('request', request => request.respond({ status: 200, contentType: 'text/html', body: '<!doctype html>' }));
63
+ await page.goto('https://capium-desktop-preview.invalid/', { waitUntil: 'domcontentloaded' });
64
+ if (!await page.evaluate(engineProbe, profile(brand))) throw new Error('This binary does not support the verified desktop browser-brand mappings; use Capium 1.2.1 revision 2 or later');
65
+ } finally { await page.close(); }
66
+ }
67
+
68
+ module.exports = { normalizeBrand, brandArgs, validateLaunch, profile, engineProbe, requireEngine, requirePuppeteerEngine };
@@ -0,0 +1,19 @@
1
+ {
2
+ "schema_version": 1,
3
+ "status": "experimental-emulation",
4
+ "minimum_release_year": 2022,
5
+ "physical_device_validation": "pending",
6
+ "profiles": [
7
+ {
8
+ "id": "iphone-16",
9
+ "model": "iPhone 16",
10
+ "release_year": 2024,
11
+ "os_version": "26.6.1",
12
+ "browser_version": "155.0.8059.37",
13
+ "user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 26_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/155.0.8059.37 Mobile/15E148 Safari/604.1",
14
+ "width": 393,
15
+ "height": 852,
16
+ "dpr": 3
17
+ }
18
+ ]
19
+ }
@@ -40,13 +40,13 @@ function proxyAndGeoArgs(spec, geoipOn) {
40
40
  }
41
41
 
42
42
  function buildArgs({ seed, platform, stealthArgs, timezone, locale, extensionPaths, extra,
43
- headless = false }) {
43
+ headless = false, mobileDevice = null, browserBrand = null }) {
44
44
  let args = [];
45
45
  if (stealthArgs) {
46
- args = args.concat(config.getDefaultStealthArgs(seed, platform));
46
+ args = args.concat(config.getDefaultStealthArgs(seed, platform, null, mobileDevice, browserBrand));
47
47
  // Keep the engine's seeded screen. Use initial window bounds only when
48
48
  // the caller has not provided screen/window flags.
49
- if (headless && !config.hasScreenOverride(extra)) {
49
+ if (headless && !['android', 'ios'].includes(platform) && !config.hasScreenOverride(extra)) {
50
50
  args = args.concat(config.getHeadlessDefaultArgs());
51
51
  }
52
52
  }
@@ -29,7 +29,10 @@ async function wait(binary, statusPath, timeoutMs = 30000, signal = null) {
29
29
  try {
30
30
  if (fs.readFileSync(statusPath, 'utf8').trim() === '0\nCAPIUM_LICENSE_READY') return;
31
31
  } catch (error) {
32
- if (error.code !== 'ENOENT') throw error;
32
+ // The native writer briefly locks this owned file on Windows. Keep
33
+ // waiting for a real decision; unreadable status is never readiness.
34
+ if (!['ENOENT', 'EBUSY'].includes(error.code) &&
35
+ !(process.platform === 'win32' && ['EACCES', 'EPERM'].includes(error.code))) throw error;
33
36
  }
34
37
  if (Date.now() >= deadline) {
35
38
  throw new CapiumServerDownError('Timed out waiting for the browser license startup decision');
package/lib/mobile.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ // Shared with the native engine. These are emulation presets, not physical
4
+ // GPU/font/CPU/memory certifications. Desktop defaults never read this catalog.
5
+ const catalog = require('./android-phones.json');
6
+ const profiles = new Map(catalog.profiles.map(p => [p.id, p]));
7
+ const iosProfiles = new Map(require('./ios-phones.json').profiles.map(p => [p.id, p]));
8
+
9
+ function androidProfile(seed, device = null) {
10
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) {
11
+ throw new RangeError('Android seed must be an integer from 0 to 4294967295');
12
+ }
13
+ if (device === null || device === undefined) {
14
+ device = catalog.default_profile_ids[seed % catalog.default_profile_ids.length];
15
+ }
16
+ if (typeof device !== 'string' || !profiles.has(device)) {
17
+ throw new RangeError('Unknown Android mobileDevice; choose a catalog ID: ' + [...profiles.keys()].join(', '));
18
+ }
19
+ return { ...profiles.get(device) };
20
+ }
21
+
22
+ function mobileProfile(seed, device = null, platform = 'android') {
23
+ if (platform === 'android') return androidProfile(seed, device);
24
+ if (platform !== 'ios') throw new Error("Mobile profiles require platform: 'android' or 'ios'");
25
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) {
26
+ throw new RangeError('iPhone seed must be an integer from 0 to 4294967295');
27
+ }
28
+ device = device === null || device === undefined ? 'iphone-16' : device;
29
+ if (typeof device !== 'string' || !iosProfiles.has(device)) {
30
+ throw new RangeError('Unknown iPhone mobileDevice; choose a catalog ID: ' + [...iosProfiles.keys()].join(', '));
31
+ }
32
+ return { ...iosProfiles.get(device) };
33
+ }
34
+
35
+ function contextOptions(seed, options = {}, device = null, platform = 'android') {
36
+ if (options.userAgent !== undefined && options.userAgent !== null) {
37
+ throw new Error((platform === 'ios' ? 'iPhone uses its catalog UA; ' :
38
+ 'Android Chrome uses the native engine UA and matching Client Hints; ') +
39
+ 'context userAgent overrides are not supported');
40
+ }
41
+ if ('viewport' in options && options.viewport === null) return { ...options };
42
+ const p = mobileProfile(seed, device, platform);
43
+ return {
44
+ viewport: { width: p.width, height: p.height },
45
+ screen: { width: p.width, height: p.height },
46
+ deviceScaleFactor: p.dpr, isMobile: true, hasTouch: true,
47
+ ...options,
48
+ };
49
+ }
50
+
51
+ function puppeteerViewport(seed, device = null, platform = 'android') {
52
+ const p = mobileProfile(seed, device, platform);
53
+ return { width: p.width, height: p.height, deviceScaleFactor: p.dpr,
54
+ isMobile: true, hasTouch: true };
55
+ }
56
+
57
+ function validateLaunch(platform, binary, extra, stealthArgs = true, mobileDevice = null) {
58
+ if (typeof platform === 'string' && ['android', 'ios'].includes(platform.toLowerCase()) && platform !== platform.toLowerCase()) {
59
+ throw new Error(`Use platform: '${platform.toLowerCase()}' for the mobile profile`);
60
+ }
61
+ if (!['android', 'ios'].includes(platform)) {
62
+ if (mobileDevice !== null && mobileDevice !== undefined) throw new Error("mobileDevice requires platform: 'android' or 'ios'");
63
+ return;
64
+ }
65
+ if (mobileDevice !== null && mobileDevice !== undefined) mobileProfile(0, mobileDevice, platform);
66
+ if (!stealthArgs) throw new Error('Mobile profiles require stealthArgs: true to enable their native identity');
67
+ const conflicts = ['--user-agent=', '--fingerprint-brand=', '--fingerprint-brand-version=',
68
+ '--fingerprint-platform-version=', '--fingerprint-platform=', '--fingerprint=', '--fingerprint-mobile-device='];
69
+ if ((extra || []).some(arg => typeof arg === 'string' &&
70
+ conflicts.some(key => arg.startsWith(key) || arg === key.slice(0, -1)))) {
71
+ throw new Error('Android preview uses the catalog model/OS and current Chrome; ' +
72
+ 'conflicting identity overrides are not supported');
73
+ }
74
+ }
75
+
76
+ // Runs on a fulfilled empty .invalid page for every mobile launch; no network is sent.
77
+ async function engineProbe(expected) {
78
+ if (expected?.user_agent) {
79
+ if (navigator.platform !== 'iPhone' || navigator.userAgent !== expected.user_agent ||
80
+ 'userAgentData' in navigator || 'NavigatorUAData' in globalThis || navigator.maxTouchPoints < 1) return false;
81
+ const gl = document.createElement('canvas').getContext('webgl');
82
+ const ext = gl?.getExtension('WEBGL_debug_renderer_info');
83
+ return !!ext && gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) === 'Apple Inc.' &&
84
+ gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) === 'Apple GPU';
85
+ }
86
+ const match = navigator.userAgent.match(/Chrome\/(\d+)\.0\.0\.0 Mobile Safari\//);
87
+ if (!expected || navigator.platform !== 'Linux armv81' || !match ||
88
+ !navigator.userAgent.includes('(Linux; Android 10; K)') ||
89
+ navigator.userAgent.includes('Version/4.0') || !navigator.userAgentData) return false;
90
+ const ch = await navigator.userAgentData.getHighEntropyValues([
91
+ 'model', 'platformVersion', 'uaFullVersion', 'fullVersionList', 'formFactors',
92
+ 'architecture', 'bitness', 'wow64']);
93
+ const full = ch.uaFullVersion;
94
+ const versionsMatch = typeof full === 'string' && full.split('.')[0] === match[1] &&
95
+ ['Chromium', 'Google Chrome'].every(brand =>
96
+ ch.brands?.some(item => item.brand === brand && item.version === match[1]) &&
97
+ ch.fullVersionList?.some(item => item.brand === brand && item.version === full));
98
+ return versionsMatch && ch.platform === 'Android' && ch.mobile === true &&
99
+ ch.model === expected.model && ch.platformVersion === expected.platform_version &&
100
+ ch.architecture === '' && ch.bitness === '' && ch.wow64 === false &&
101
+ ch.formFactors?.length === 1 && ch.formFactors[0] === 'Mobile';
102
+ }
103
+
104
+ module.exports = { androidProfile, mobileProfile, contextOptions, puppeteerViewport, validateLaunch, engineProbe };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capiumbrowser",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Drop-in stealth browser SDK on top of Playwright or Puppeteer (Capium Chromium).",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Capzy (https://capzy.ai)",
package/playwright.js CHANGED
@@ -76,24 +76,27 @@ async function resolveBinary(binary, licenseKey) {
76
76
  */
77
77
  async function prepare(opts) {
78
78
  const {
79
- seed = null, platform = 'windows', proxy = null, geoip = null, args = null,
79
+ seed = null, platform = 'windows', mobileDevice = null, browserBrand = null, proxy = null, geoip = null, args = null,
80
80
  stealthArgs = true, timezone = null, locale = null, extensionPaths = null,
81
81
  binary = null, licenseKey = null, licenseServer = null,
82
82
  licenseThroughProxy = false, licensePreflight = true, headless = false,
83
83
  } = opts;
84
+ require('./lib/desktop').validateLaunch(browserBrand, platform, binary, args, stealthArgs);
85
+ require('./lib/mobile').validateLaunch(platform, binary, args, stealthArgs, mobileDevice);
84
86
  if (licensePreflight) license.preflight(licenseKey, licenseServer);
85
87
  const { key } = license.effective(licenseKey, licenseServer);
86
88
  const binPath = await resolveBinary(binary, key);
87
89
  const finalSeed = seed === null || seed === undefined ? config.newSeed() : seed;
88
90
  const { launchOptions, args: proxyArgs } = proxyAndGeoArgs(proxy, geoip);
89
91
  const launchArgs = proxyArgs.concat(buildArgs({
90
- seed: finalSeed, platform, stealthArgs, timezone, locale, extensionPaths, extra: args,
92
+ seed: finalSeed, platform, mobileDevice, browserBrand, stealthArgs, timezone, locale, extensionPaths, extra: args,
91
93
  headless,
92
94
  }));
93
95
  if (licenseThroughProxy) launchArgs.push('--license-through-proxy');
94
96
  const env = license.childEnv(licenseKey, licenseServer);
95
97
  const statusPath = newStatusFile(env);
96
- return { binPath, finalSeed, launchOptions, launchArgs, env, statusPath };
98
+ return { binPath, finalSeed, launchOptions, launchArgs, env, statusPath,
99
+ browserBrand, mobileSeed: ['android', 'ios'].includes(platform) && stealthArgs ? finalSeed : null, mobileDevice, mobilePlatform: platform };
97
100
  }
98
101
 
99
102
  async function throwTranslated(e, statusPath) {
@@ -117,12 +120,12 @@ async function throwTranslated(e, statusPath) {
117
120
  */
118
121
  async function buildLaunchOptions(opts = {}) {
119
122
  const {
120
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
123
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
121
124
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight,
122
125
  headless = false, ...rest
123
126
  } = opts;
124
127
  const prep = await prepare({
125
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
128
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
126
129
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight, headless,
127
130
  });
128
131
  return {
@@ -140,12 +143,13 @@ async function buildLaunchOptions(opts = {}) {
140
143
  * avoid; this closes the plain-launch() path too). An explicit viewport (including a
141
144
  * user-passed null) is always honored.
142
145
  */
143
- function applyViewportDefaults(browser) {
146
+ function applyViewportDefaults(browser, mobileSeed = null, mobileDevice = null, mobilePlatform = 'android') {
144
147
  for (const method of ['newContext', 'newPage']) {
145
148
  const orig = browser[method];
146
149
  if (typeof orig !== 'function') continue;
147
150
  browser[method] = function (options = {}, ...more) {
148
- if (!('viewport' in options)) options = { ...options, viewport: null };
151
+ if (mobileSeed !== null) options = require('./lib/mobile').contextOptions(mobileSeed, options, mobileDevice, mobilePlatform);
152
+ else if (!('viewport' in options)) options = { ...options, viewport: null };
149
153
  return orig.call(this, options, ...more);
150
154
  };
151
155
  }
@@ -171,12 +175,12 @@ function applyViewportDefaults(browser) {
171
175
  */
172
176
  async function launch(opts = {}) {
173
177
  const {
174
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
178
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
175
179
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight,
176
180
  headless = false, ...rest
177
181
  } = opts;
178
182
  const prep = await prepare({
179
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
183
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
180
184
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight, headless,
181
185
  });
182
186
  const pw = requireDriver();
@@ -197,7 +201,33 @@ async function launch(opts = {}) {
197
201
  }
198
202
  clearStatusFile(prep.statusPath);
199
203
  browser._capiumSeed = prep.finalSeed;
200
- return applyViewportDefaults(browser);
204
+ browser._capiumMobile = prep.mobileSeed !== null;
205
+ applyViewportDefaults(browser, prep.mobileSeed, prep.mobileDevice, prep.mobilePlatform);
206
+ if (browser._capiumMobile) {
207
+ try {
208
+ const context = await browser.newContext();
209
+ try { await requireMobileEngine(context, prep.mobileDevice, prep.finalSeed, prep.mobilePlatform); } finally { await context.close(); }
210
+ } catch (e) { await browser.close(); throw e; }
211
+ }
212
+ if (require('./lib/desktop').normalizeBrand(prep.browserBrand) !== 'chrome') {
213
+ try {
214
+ const context = await browser.newContext();
215
+ try { await require('./lib/desktop').requireEngine(context, prep.browserBrand); } finally { await context.close(); }
216
+ } catch (e) { await browser.close(); throw e; }
217
+ }
218
+ return browser;
219
+ }
220
+
221
+ async function requireMobileEngine(context, mobileDevice = null, seed = 0, platform = 'android') {
222
+ const page = await context.newPage();
223
+ try {
224
+ const mobile = require('./lib/mobile');
225
+ const expected = mobile.mobileProfile(seed, mobileDevice, platform);
226
+ await page.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html>' }));
227
+ await page.goto('https://capium-mobile-preview.invalid/', { waitUntil: 'domcontentloaded' });
228
+ const supported = await page.evaluate(mobile.engineProbe, expected);
229
+ if (!supported) throw new Error('This binary does not support the requested mobile profile/device; use Capium 1.2.1 revision 2 or later');
230
+ } finally { await page.close(); }
201
231
  }
202
232
 
203
233
  /**
@@ -207,17 +237,18 @@ async function launch(opts = {}) {
207
237
  */
208
238
  async function launchPersistentContext(userDataDir, opts = {}) {
209
239
  const {
210
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
240
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
211
241
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight,
212
242
  headless = false, ...rest
213
243
  } = opts;
214
244
  const prep = await prepare({
215
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
245
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
216
246
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight, headless,
217
247
  });
218
248
  // Default to the page filling the window (see launchContext) unless the caller pinned a
219
249
  // viewport themselves. viewport: null is Playwright-JS for "no fixed viewport".
220
- if (!('viewport' in rest)) rest.viewport = null;
250
+ if (prep.mobileSeed !== null) Object.assign(rest, require('./lib/mobile').contextOptions(prep.mobileSeed, rest, prep.mobileDevice, prep.mobilePlatform));
251
+ else if (!('viewport' in rest)) rest.viewport = null;
221
252
  const pw = requireDriver();
222
253
  let ctx;
223
254
  try {
@@ -236,6 +267,18 @@ async function launchPersistentContext(userDataDir, opts = {}) {
236
267
  }
237
268
  clearStatusFile(prep.statusPath);
238
269
  ctx._capiumSeed = prep.finalSeed;
270
+ if (prep.mobileSeed !== null) {
271
+ try { await requireMobileEngine(ctx, prep.mobileDevice, prep.finalSeed, prep.mobilePlatform); } catch (e) { await ctx.close(); throw e; }
272
+ }
273
+ try { await require('./lib/desktop').requireEngine(ctx, prep.browserBrand); }
274
+ catch (e) { await ctx.close(); throw e; }
275
+ // Desktop personas: size the window to the seed's screen -- in HEADLESS too, so the
276
+ // viewport is a coherent per-seed maximized size instead of the tiny startup window.
277
+ // Mobile keeps its device viewport.
278
+ if (prep.mobileSeed === null) {
279
+ const page = ctx.pages()[0] || await ctx.newPage();
280
+ await fitWindow(ctx.browser(), ctx, page);
281
+ }
239
282
  return ctx;
240
283
  }
241
284
 
@@ -246,6 +289,7 @@ async function launchPersistentContext(userDataDir, opts = {}) {
246
289
  * window to <= the available screen with a slight offset. No-op on failure/headless.
247
290
  */
248
291
  async function fitWindow(browser, context, page) {
292
+ let pcdp, bcdp;
249
293
  try {
250
294
  const scr = await page.evaluate(() => ({ w: screen.availWidth, h: screen.availHeight }));
251
295
  if (!scr || !scr.w || !scr.h) return;
@@ -257,16 +301,21 @@ async function fitWindow(browser, context, page) {
257
301
  const h = ah;
258
302
  const left = 0;
259
303
  const top = 0;
260
- const pcdp = await context.newCDPSession(page);
304
+ pcdp = await context.newCDPSession(page);
261
305
  const { targetInfo } = await pcdp.send('Target.getTargetInfo');
262
- const bcdp = await browser.newBrowserCDPSession();
306
+ // Persistent contexts may have no Browser handle; their page session still
307
+ // supports the Browser window commands.
308
+ bcdp = browser ? await browser.newBrowserCDPSession() : pcdp;
263
309
  const { windowId } = await bcdp.send('Browser.getWindowForTarget',
264
310
  { targetId: targetInfo.targetId });
265
311
  await bcdp.send('Browser.setWindowBounds', {
266
312
  windowId,
267
313
  bounds: { left, top, width: w, height: h, windowState: 'normal' },
268
314
  });
269
- } catch {}
315
+ } catch {} finally {
316
+ if (bcdp && bcdp !== pcdp) await bcdp.detach().catch(() => {});
317
+ if (pcdp) await pcdp.detach().catch(() => {});
318
+ }
270
319
  }
271
320
 
272
321
  /**
@@ -283,10 +332,10 @@ async function launchContext(opts = {}) {
283
332
  // viewport: null -- let the page fill the real window (fitWindow sizes the window to the
284
333
  // persona's screen). A fixed 1280x720 emulated viewport would leave the page smaller than
285
334
  // the window AND mismatch window.screen -- an incoherent fingerprint.
286
- const context = browser.contexts()[0] || (await browser.newContext({ viewport: null }));
335
+ const context = browser.contexts()[0] || (await browser.newContext(browser._capiumMobile ? {} : { viewport: null }));
287
336
  if (humanize) human.humanize(context, humanPreset);
288
337
  const page = context.pages()[0] || (await context.newPage());
289
- if (!headless) await fitWindow(browser, context, page); // modest window <= spoofed screen
338
+ if (!browser._capiumMobile) await fitWindow(browser, context, page); // per-seed screen fit (headed + headless)
290
339
  if (url) await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
291
340
  return { browser, context, page };
292
341
  }
package/puppeteer.js CHANGED
@@ -76,24 +76,27 @@ async function resolveBinary(binary, licenseKey) {
76
76
 
77
77
  async function prepare(opts) {
78
78
  const {
79
- seed = null, platform = 'windows', proxy = null, geoip = null, args = null,
79
+ seed = null, platform = 'windows', mobileDevice = null, browserBrand = null, proxy = null, geoip = null, args = null,
80
80
  stealthArgs = true, timezone = null, locale = null, extensionPaths = null,
81
81
  binary = null, licenseKey = null, licenseServer = null,
82
82
  licenseThroughProxy = false, licensePreflight = true, headless = false,
83
83
  } = opts;
84
+ require('./lib/desktop').validateLaunch(browserBrand, platform, binary, args, stealthArgs);
85
+ require('./lib/mobile').validateLaunch(platform, binary, args, stealthArgs, mobileDevice);
84
86
  if (licensePreflight) license.preflight(licenseKey, licenseServer);
85
87
  const { key } = license.effective(licenseKey, licenseServer);
86
88
  const binPath = await resolveBinary(binary, key);
87
89
  const finalSeed = seed === null || seed === undefined ? config.newSeed() : seed;
88
90
  const { launchOptions, args: proxyArgs } = proxyAndGeoArgs(proxy, geoip);
89
91
  const launchArgs = proxyArgs.concat(buildArgs({
90
- seed: finalSeed, platform, stealthArgs, timezone, locale, extensionPaths, extra: args,
92
+ seed: finalSeed, platform, mobileDevice, browserBrand, stealthArgs, timezone, locale, extensionPaths, extra: args,
91
93
  headless,
92
94
  }));
93
95
  if (licenseThroughProxy) launchArgs.push('--license-through-proxy');
94
96
  const env = license.childEnv(licenseKey, licenseServer);
95
97
  const statusPath = newStatusFile(env);
96
- return { binPath, finalSeed, launchOptions, launchArgs, env, statusPath };
98
+ return { binPath, finalSeed, launchOptions, launchArgs, env, statusPath,
99
+ browserBrand, mobileSeed: ['android', 'ios'].includes(platform) && stealthArgs ? finalSeed : null, mobileDevice, mobilePlatform: platform };
97
100
  }
98
101
 
99
102
  /**
@@ -131,12 +134,12 @@ async function throwTranslated(e, statusPath) {
131
134
  */
132
135
  async function buildLaunchOptions(opts = {}) {
133
136
  const {
134
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
137
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
135
138
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight,
136
139
  headless = false, ignoreDefaultArgs, ...rest
137
140
  } = opts;
138
141
  const prep = await prepare({
139
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
142
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
140
143
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight, headless,
141
144
  });
142
145
  const proxyOption = prep.launchOptions.proxy || null;
@@ -150,7 +153,8 @@ async function buildLaunchOptions(opts = {}) {
150
153
  headless,
151
154
  args: launchArgs,
152
155
  env: prep.env,
153
- defaultViewport: 'defaultViewport' in rest ? rest.defaultViewport : null,
156
+ defaultViewport: 'defaultViewport' in rest ? rest.defaultViewport :
157
+ (prep.mobileSeed !== null ? require('./lib/mobile').puppeteerViewport(prep.mobileSeed, prep.mobileDevice, prep.mobilePlatform) : null),
154
158
  ignoreDefaultArgs: ignoreDefaultArgs === undefined ? config.IGNORE_DEFAULT_ARGS : ignoreDefaultArgs,
155
159
  ...rest,
156
160
  };
@@ -163,12 +167,12 @@ async function buildLaunchOptions(opts = {}) {
163
167
  */
164
168
  async function launch(opts = {}) {
165
169
  const {
166
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
170
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
167
171
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight,
168
172
  headless = false, ignoreDefaultArgs, ...rest
169
173
  } = opts;
170
174
  const prep = await prepare({
171
- seed, platform, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
175
+ seed, platform, mobileDevice, browserBrand, proxy, geoip, args, stealthArgs, timezone, locale, extensionPaths,
172
176
  binary, licenseKey, licenseServer, licenseThroughProxy, licensePreflight, headless,
173
177
  });
174
178
  const puppeteer = requireDriver();
@@ -191,7 +195,8 @@ async function launch(opts = {}) {
191
195
  headless,
192
196
  args: launchArgs,
193
197
  env: prep.env,
194
- defaultViewport: 'defaultViewport' in rest ? rest.defaultViewport : null,
198
+ defaultViewport: 'defaultViewport' in rest ? rest.defaultViewport :
199
+ (prep.mobileSeed !== null ? require('./lib/mobile').puppeteerViewport(prep.mobileSeed, prep.mobileDevice, prep.mobilePlatform) : null),
195
200
  ignoreDefaultArgs: ida,
196
201
  ...rest,
197
202
  // A rejected native gate never creates a page. Obtain the transport first,
@@ -215,6 +220,23 @@ async function launch(opts = {}) {
215
220
  clearStatusFile(prep.statusPath);
216
221
  wireProxyAuth(browser, proxyOption);
217
222
  browser._capiumSeed = prep.finalSeed;
223
+ browser._capiumMobile = prep.mobileSeed !== null;
224
+ if (browser._capiumMobile) {
225
+ try {
226
+ const page = await browser.newPage();
227
+ try {
228
+ const mobile = require('./lib/mobile');
229
+ const expected = mobile.mobileProfile(prep.finalSeed, prep.mobileDevice, prep.mobilePlatform);
230
+ await page.setRequestInterception(true);
231
+ page.on('request', request => request.respond({ status: 200, contentType: 'text/html', body: '<!doctype html>' }));
232
+ await page.goto('https://capium-mobile-preview.invalid/', { waitUntil: 'domcontentloaded' });
233
+ const supported = await page.evaluate(mobile.engineProbe, expected);
234
+ if (!supported) throw new Error('This binary does not support the requested mobile profile/device; use Capium 1.2.1 revision 2 or later');
235
+ } finally { await page.close(); }
236
+ } catch (e) { await browser.close(); throw e; }
237
+ }
238
+ try { await require('./lib/desktop').requirePuppeteerEngine(browser, prep.browserBrand); }
239
+ catch (e) { await browser.close(); throw e; }
218
240
  return browser;
219
241
  }
220
242
 
@@ -224,11 +246,19 @@ async function launch(opts = {}) {
224
246
  * Returns the Browser.
225
247
  */
226
248
  async function launchPersistentContext(userDataDir, opts = {}) {
227
- return launch({ ...opts, userDataDir });
249
+ const browser = await launch({ ...opts, userDataDir });
250
+ // Desktop personas: fit the window to the seed's screen in HEADLESS too (coherent per-seed
251
+ // viewport, not the tiny startup window). Mobile keeps its device viewport.
252
+ if (!browser._capiumMobile) {
253
+ const page = (await browser.pages())[0] || await browser.newPage();
254
+ await fitWindow(browser, page);
255
+ }
256
+ return browser;
228
257
  }
229
258
 
230
259
  /** Size a modest windowed browser that fits within the seed's spoofed screen. */
231
260
  async function fitWindow(browser, page) {
261
+ let cdp;
232
262
  try {
233
263
  const scr = await page.evaluate(() => ({ w: screen.availWidth, h: screen.availHeight }));
234
264
  if (!scr || !scr.w || !scr.h) return;
@@ -240,7 +270,7 @@ async function fitWindow(browser, page) {
240
270
  const h = ah;
241
271
  const left = 0;
242
272
  const top = 0;
243
- const cdp = await page.createCDPSession();
273
+ cdp = await page.createCDPSession();
244
274
  const { targetInfo } = await cdp.send('Target.getTargetInfo');
245
275
  const { windowId } = await cdp.send('Browser.getWindowForTarget',
246
276
  { targetId: targetInfo.targetId });
@@ -248,8 +278,9 @@ async function fitWindow(browser, page) {
248
278
  windowId,
249
279
  bounds: { left, top, width: w, height: h, windowState: 'normal' },
250
280
  });
251
- await cdp.detach();
252
- } catch {}
281
+ } catch {} finally {
282
+ if (cdp) await cdp.detach().catch(() => {});
283
+ }
253
284
  }
254
285
 
255
286
  /**
@@ -265,7 +296,7 @@ async function launchContext(opts = {}) {
265
296
  const pages = await browser.pages();
266
297
  const page = pages[0] || (await browser.newPage());
267
298
  if (humanize) human.humanize(page, humanPreset);
268
- if (!headless) await fitWindow(browser, page);
299
+ if (!browser._capiumMobile) await fitWindow(browser, page); // per-seed screen fit (headed + headless)
269
300
  if (url) await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
270
301
  return { browser, page };
271
302
  }