capiumbrowser 0.1.0
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/LICENSE +38 -0
- package/README.md +174 -0
- package/bin/capiumbrowser.js +305 -0
- package/index.d.ts +131 -0
- package/index.js +90 -0
- package/lib/channels.json +13 -0
- package/lib/config.js +286 -0
- package/lib/download.js +364 -0
- package/lib/errors.js +114 -0
- package/lib/human.js +142 -0
- package/lib/launch-common.js +82 -0
- package/lib/license.js +217 -0
- package/lib/proxy.js +218 -0
- package/lib/version.js +100 -0
- package/package.json +67 -0
- package/playwright.js +260 -0
- package/puppeteer.js +233 -0
package/lib/download.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* download -- fetch/extract the per-platform Capium binary if it isn't already present.
|
|
3
|
+
*
|
|
4
|
+
* The Node SDK and the browser binary are decoupled: update playwright-core/puppeteer-core with
|
|
5
|
+
* npm, and fetch the matching Capium build separately. If the launch target isn't found locally,
|
|
6
|
+
* ensureBinary() downloads the per-platform tarball and extracts it under ~/.capium (CAPIUM_HOME
|
|
7
|
+
* overrides).
|
|
8
|
+
*
|
|
9
|
+
* Download scheme (signed, path-based):
|
|
10
|
+
* GET {server}/download/distro/chromium-v{version}/capiumbrowser-{os}-{arch}.tar.gz
|
|
11
|
+
*
|
|
12
|
+
* The license key travels in the X-Capzy-License header (over TLS), NEVER in the URL; the
|
|
13
|
+
* request PATH is HMAC-signed (X-Capzy-Timestamp + X-Capzy-Signature =
|
|
14
|
+
* HMAC-SHA256(key, "<ts>.<path>")) -- the same scheme as the licensing calls. The response's
|
|
15
|
+
* X-Capzy-SHA256 is verified against the downloaded bytes before anything is extracted.
|
|
16
|
+
*
|
|
17
|
+
* The archive FILENAME is version-independent -- `capiumbrowser-<os>-<arch>.tar.gz` is always
|
|
18
|
+
* the same; only the `chromium-v<version>/` folder changes.
|
|
19
|
+
*
|
|
20
|
+
* Source resolution (first that works):
|
|
21
|
+
* CAPIUM_BINARY -> use this exact launch target, no download
|
|
22
|
+
* CAPIUM_DOWNLOAD_URL -> a direct .tar.gz URL (may contain {platform}/{version}); unsigned escape hatch
|
|
23
|
+
* license server -> the signed GET above
|
|
24
|
+
*
|
|
25
|
+
* Supported platforms (the four capium publishes):
|
|
26
|
+
* linux-x64 linux-arm64 windows-x64 macos-arm64
|
|
27
|
+
* Anything else (macOS Intel, Windows ARM, ...) throws a clear CapiumError.
|
|
28
|
+
*/
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const crypto = require('crypto');
|
|
32
|
+
const { execFileSync } = require('child_process');
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const os = require('os');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { pipeline } = require('stream/promises');
|
|
37
|
+
const { Readable, Transform } = require('stream');
|
|
38
|
+
|
|
39
|
+
const tar = require('tar');
|
|
40
|
+
|
|
41
|
+
const config = require('./config');
|
|
42
|
+
const license = require('./license');
|
|
43
|
+
const { CapiumError, CapiumExpiredError, CapiumServerDownError } = require('./errors');
|
|
44
|
+
const { binaryVersionFor, isPublished } = require('./version');
|
|
45
|
+
|
|
46
|
+
const NET_TIMEOUT_MS = 300000;
|
|
47
|
+
|
|
48
|
+
// (normalized os, normalized arch) -> distro tag used in the filename/URL.
|
|
49
|
+
const SUPPORTED = {
|
|
50
|
+
'linux/x64': 'linux-x64',
|
|
51
|
+
'linux/arm64': 'linux-arm64',
|
|
52
|
+
'windows/x64': 'windows-x64',
|
|
53
|
+
'macos/arm64': 'macos-arm64',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function normOs(sysname) {
|
|
57
|
+
const s = String(sysname).toLowerCase();
|
|
58
|
+
if (s === 'darwin') return 'macos';
|
|
59
|
+
if (s === 'windows' || s === 'win32') return 'windows';
|
|
60
|
+
return 'linux';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normArch(mach) {
|
|
64
|
+
const m = String(mach).toLowerCase();
|
|
65
|
+
if (['x86_64', 'amd64', 'x64'].includes(m)) return 'x64';
|
|
66
|
+
if (['arm64', 'aarch64'].includes(m)) return 'arm64';
|
|
67
|
+
return m;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The distro tag for THIS host -- one of linux-x64, linux-arm64, windows-x64, macos-arm64.
|
|
72
|
+
*
|
|
73
|
+
* Throws CapiumError for a platform capium doesn't publish (e.g. macOS Intel, Windows ARM).
|
|
74
|
+
*/
|
|
75
|
+
function downloadTag(system = null, machine = null) {
|
|
76
|
+
const os_ = normOs(system || process.platform);
|
|
77
|
+
const arch = normArch(machine || os.arch());
|
|
78
|
+
const tag = SUPPORTED[`${os_}/${arch}`];
|
|
79
|
+
if (!tag) {
|
|
80
|
+
throw new CapiumError(
|
|
81
|
+
`capium has no build for this platform: ${os_}/${arch} ` +
|
|
82
|
+
`(${system || process.platform} ${machine || os.arch()}). ` +
|
|
83
|
+
`Supported: ${Object.values(SUPPORTED).sort().join(', ')}.`);
|
|
84
|
+
}
|
|
85
|
+
return tag;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The signed request path for a build. Filename is version-independent; only the folder
|
|
90
|
+
* carries the version (per the '...end name will all be the same' contract).
|
|
91
|
+
*/
|
|
92
|
+
function distroPath(version, tag) {
|
|
93
|
+
return `/download/distro/chromium-v${version}/capiumbrowser-${tag}.tar.gz`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- extraction ------------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** Where extracted distros live: CAPIUM_HOME, else ~/.capium (survives an npm reinstall). */
|
|
99
|
+
function destRoot() {
|
|
100
|
+
return process.env.CAPIUM_HOME || path.join(os.homedir(), '.capium');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract a distro archive, sniffing gzip/zip/tar so the server can ship any of them.
|
|
105
|
+
*
|
|
106
|
+
* Extracts into a scratch dir first, then normalizes: a single-top archive (the tar layout,
|
|
107
|
+
* e.g. `capium-152-linux-x64/...`) lands as `root/<thatdir>`; a FLAT archive (a Windows tar
|
|
108
|
+
* whose `chrome.exe` / `locales/` sit at the root) is wrapped into `root/<subdir>` so it
|
|
109
|
+
* stays discoverable. Returns the extracted distro directory.
|
|
110
|
+
*/
|
|
111
|
+
async function extract(archivePath, root, subdir) {
|
|
112
|
+
const magic = Buffer.alloc(4);
|
|
113
|
+
const fd = fs.openSync(archivePath, 'r');
|
|
114
|
+
try {
|
|
115
|
+
fs.readSync(fd, magic, 0, 4, 0);
|
|
116
|
+
} finally {
|
|
117
|
+
fs.closeSync(fd);
|
|
118
|
+
}
|
|
119
|
+
const scratch = fs.mkdtempSync(path.join(root, '.capium-extract-'));
|
|
120
|
+
try {
|
|
121
|
+
if (magic[0] === 0x50 && magic[1] === 0x4b && magic[2] === 0x03 && magic[3] === 0x04) {
|
|
122
|
+
// zip: no zip support in the `tar` package -- use the system bsdtar (ships with
|
|
123
|
+
// Windows 10+/macOS; on Linux the server contract is tar.gz so this path is rare).
|
|
124
|
+
try {
|
|
125
|
+
execFileSync('tar', ['-xf', archivePath, '-C', scratch], { stdio: 'ignore' });
|
|
126
|
+
} catch (e) {
|
|
127
|
+
throw new CapiumError(
|
|
128
|
+
`the server sent a zip archive but this host's \`tar\` could not extract it (${e.message}). ` +
|
|
129
|
+
'Install bsdtar/unzip or set CAPIUM_DOWNLOAD_URL to a .tar.gz build.');
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
// gzip / plain tar -- the tar package sniffs compression itself.
|
|
133
|
+
await tar.x({ file: archivePath, cwd: scratch });
|
|
134
|
+
}
|
|
135
|
+
const entries = fs.readdirSync(scratch);
|
|
136
|
+
if (entries.length === 1 &&
|
|
137
|
+
fs.statSync(path.join(scratch, entries[0])).isDirectory()) {
|
|
138
|
+
// single top-level dir: the archive carries its own capium-*/ folder
|
|
139
|
+
const target = path.join(root, entries[0]);
|
|
140
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
141
|
+
fs.renameSync(path.join(scratch, entries[0]), target);
|
|
142
|
+
return target;
|
|
143
|
+
}
|
|
144
|
+
// flat archive: wrap it so discovery (capium-*/ glob) still finds it
|
|
145
|
+
const target = path.join(root, subdir);
|
|
146
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
147
|
+
fs.renameSync(scratch, target);
|
|
148
|
+
return target;
|
|
149
|
+
} finally {
|
|
150
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const MARKER = '.capium-build';
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read the build version stamped next to an installed binary, or null (an old install
|
|
158
|
+
* from before version-stamping, which we treat as 'unknown' -> upgrade it).
|
|
159
|
+
*/
|
|
160
|
+
function readInstalledVersion(binPath) {
|
|
161
|
+
try {
|
|
162
|
+
return fs.readFileSync(path.join(path.dirname(binPath), MARKER), 'utf8').trim();
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Public: the stamped build version of the installed capium binary, or null if it isn't
|
|
170
|
+
* installed / wasn't version-stamped. `binPath` defaults to the discovered binary.
|
|
171
|
+
*/
|
|
172
|
+
function installedVersion(binPath = null) {
|
|
173
|
+
if (binPath === null) {
|
|
174
|
+
try {
|
|
175
|
+
binPath = config.findBinary();
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return readInstalledVersion(binPath);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function stampVersion(binPath, version) {
|
|
184
|
+
try {
|
|
185
|
+
fs.writeFileSync(path.join(path.dirname(binPath), MARKER), String(version));
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Delete previously-extracted capium distros under `root`. A host only ever holds its own
|
|
191
|
+
* platform's build, so on a version change we drop the stale one before installing the new
|
|
192
|
+
* one ('old removed, new downloaded'). Never touches CAPIUM_BINARY / PATH targets outside
|
|
193
|
+
* `root`.
|
|
194
|
+
*/
|
|
195
|
+
function removeInstalls(root) {
|
|
196
|
+
let names;
|
|
197
|
+
try {
|
|
198
|
+
names = fs.readdirSync(root);
|
|
199
|
+
} catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const n of names) {
|
|
203
|
+
if (!n.startsWith('capium-')) continue;
|
|
204
|
+
const d = path.join(root, n);
|
|
205
|
+
try {
|
|
206
|
+
if (fs.statSync(d).isDirectory()) fs.rmSync(d, { recursive: true, force: true });
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- download --------------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Stream `url` to `dest`, mapping transport failures to typed errors and verifying the
|
|
215
|
+
* server's X-Capzy-SHA256 against the bytes. Returns the hex sha256.
|
|
216
|
+
*/
|
|
217
|
+
async function download(url, headers, dest, version, tag) {
|
|
218
|
+
let res;
|
|
219
|
+
try {
|
|
220
|
+
res = await fetch(url, { headers: headers || {}, signal: AbortSignal.timeout(NET_TIMEOUT_MS) });
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new CapiumServerDownError(
|
|
223
|
+
`could not reach the download server ${url}: ${e.cause?.message || e.message || e}`);
|
|
224
|
+
}
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
if (res.status === 404) {
|
|
227
|
+
throw new CapiumError(
|
|
228
|
+
`no capium build published for version ${version} on ${tag} (HTTP 404). Check ` +
|
|
229
|
+
'CAPIUM_VERSION, or that this platform/version is published.');
|
|
230
|
+
}
|
|
231
|
+
if (res.status === 401 || res.status === 403) {
|
|
232
|
+
throw new CapiumExpiredError(
|
|
233
|
+
`license rejected while downloading (HTTP ${res.status}) -- expired, revoked, or ` +
|
|
234
|
+
'invalid key. Run `npx capiumbrowser status` to check.');
|
|
235
|
+
}
|
|
236
|
+
throw new CapiumServerDownError(`download failed: HTTP ${res.status} from ${url}`);
|
|
237
|
+
}
|
|
238
|
+
const expected = res.headers.get('x-capzy-sha256');
|
|
239
|
+
const hash = crypto.createHash('sha256');
|
|
240
|
+
const hasher = new Transform({
|
|
241
|
+
transform(chunk, _enc, cb) {
|
|
242
|
+
hash.update(chunk);
|
|
243
|
+
cb(null, chunk);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
await pipeline(Readable.fromWeb(res.body), hasher, fs.createWriteStream(dest));
|
|
247
|
+
const got = hash.digest('hex').toLowerCase();
|
|
248
|
+
if (expected && got !== expected.toLowerCase()) {
|
|
249
|
+
throw new CapiumError(
|
|
250
|
+
'capium download failed its integrity check (sha256 mismatch): expected ' +
|
|
251
|
+
`${expected.toLowerCase()}, got ${got} (corrupted or tampered download).`);
|
|
252
|
+
}
|
|
253
|
+
return got;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Return the path to the capium launch target, downloading + extracting the per-platform
|
|
258
|
+
* build if it isn't already present.
|
|
259
|
+
*
|
|
260
|
+
* Throws:
|
|
261
|
+
* CapiumError -- unsupported platform, version/platform not published (404), or a
|
|
262
|
+
* sha256 integrity failure.
|
|
263
|
+
* CapiumConfigError -- no license key configured and no other source (from resolve()).
|
|
264
|
+
* CapiumExpiredError -- the license was rejected (401/403).
|
|
265
|
+
* CapiumServerDownError-- the download server was unreachable / 5xx.
|
|
266
|
+
*/
|
|
267
|
+
async function ensureBinary({ version = null, licenseKey = null, server = null } = {}) {
|
|
268
|
+
// An explicit CAPIUM_BINARY path always wins -- it's the caller's choice, no version check.
|
|
269
|
+
if (process.env.CAPIUM_BINARY) return config.findBinary();
|
|
270
|
+
|
|
271
|
+
// Resolve the host's distro tag first: the default binary version is per-OS (each OS reaches
|
|
272
|
+
// a stable build independently), so we can't pick a default until we know which tag we're on.
|
|
273
|
+
const tag = downloadTag();
|
|
274
|
+
|
|
275
|
+
// If the manifest declares this OS but hasn't marked it published, say so plainly instead of
|
|
276
|
+
// letting the download 404. An explicit CAPIUM_VERSION means the caller knows a build exists,
|
|
277
|
+
// so we don't gate that path.
|
|
278
|
+
if (!version && !process.env.CAPIUM_VERSION && !isPublished(tag)) {
|
|
279
|
+
throw new CapiumError(
|
|
280
|
+
`capium has no stable build published for ${tag} yet (declared but not yet released). ` +
|
|
281
|
+
'Set CAPIUM_VERSION to pin a specific build if one exists.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
version = version || process.env.CAPIUM_VERSION || binaryVersionFor(tag);
|
|
285
|
+
|
|
286
|
+
// Reuse the cached binary ONLY if it's already the target version. A different (older)
|
|
287
|
+
// version -- e.g. after `npm install capiumbrowser@latest` bumps this OS's pinned build --
|
|
288
|
+
// falls through to fetch the new build and drop the old one.
|
|
289
|
+
try {
|
|
290
|
+
const existing = config.findBinary();
|
|
291
|
+
if (readInstalledVersion(existing) === version) return existing;
|
|
292
|
+
} catch {}
|
|
293
|
+
|
|
294
|
+
let url;
|
|
295
|
+
let headers = null;
|
|
296
|
+
const direct = process.env.CAPIUM_DOWNLOAD_URL;
|
|
297
|
+
if (direct) {
|
|
298
|
+
url = direct.replaceAll('{platform}', tag).replaceAll('{version}', version);
|
|
299
|
+
} else {
|
|
300
|
+
// Signed path-based download: key in a header, PATH HMAC-signed, sha256-verified.
|
|
301
|
+
const { key, server: srv } = license.resolve(licenseKey, server); // -> CapiumConfigError if no key
|
|
302
|
+
const p = distroPath(version, tag);
|
|
303
|
+
url = srv + p;
|
|
304
|
+
headers = license.getHeaders(key, p);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const root = destRoot();
|
|
308
|
+
fs.mkdirSync(root, { recursive: true });
|
|
309
|
+
const subdir = `capium-${String(version).split('.')[0]}-${tag}`; // FLAT-archive wrapper name
|
|
310
|
+
const tmp = path.join(os.tmpdir(),
|
|
311
|
+
`capium-dl-${crypto.randomBytes(6).toString('hex')}.tar.gz`);
|
|
312
|
+
try {
|
|
313
|
+
await download(url, headers, tmp, version, tag);
|
|
314
|
+
// Only drop the stale install once the new bytes are in hand (a failed download must
|
|
315
|
+
// never leave the host with no binary), then extract the new version.
|
|
316
|
+
removeInstalls(root);
|
|
317
|
+
const distroDir = await extract(tmp, root, subdir);
|
|
318
|
+
// Stamp the version marker BEFORE discovery: the FLAT Windows tar carries no capium
|
|
319
|
+
// marker files, and findBinary only accepts a bare chrome.exe next to one -- the
|
|
320
|
+
// downloader vouches for the directory it just extracted.
|
|
321
|
+
try {
|
|
322
|
+
fs.writeFileSync(path.join(distroDir, MARKER), String(version));
|
|
323
|
+
} catch {}
|
|
324
|
+
} finally {
|
|
325
|
+
try {
|
|
326
|
+
fs.rmSync(tmp, { force: true });
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let binPath;
|
|
331
|
+
try {
|
|
332
|
+
binPath = config.findBinary();
|
|
333
|
+
} catch {
|
|
334
|
+
throw new CapiumError(
|
|
335
|
+
`downloaded and extracted the ${tag} build but no launch target was found afterwards ` +
|
|
336
|
+
`(unexpected archive layout under ${root}).`);
|
|
337
|
+
}
|
|
338
|
+
stampVersion(binPath, version); // so a later version bump knows to re-download
|
|
339
|
+
// Re-apply exec bits the wrapper + engine binaries need (zip drops unix perms).
|
|
340
|
+
if (process.platform !== 'win32') {
|
|
341
|
+
for (const p of [binPath, path.join(path.dirname(binPath), 'chrome')]) {
|
|
342
|
+
try {
|
|
343
|
+
fs.chmodSync(p, 0o755);
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return binPath;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
module.exports = {
|
|
351
|
+
downloadTag,
|
|
352
|
+
distroPath,
|
|
353
|
+
destRoot,
|
|
354
|
+
ensureBinary,
|
|
355
|
+
installedVersion,
|
|
356
|
+
_SUPPORTED: SUPPORTED,
|
|
357
|
+
_normOs: normOs,
|
|
358
|
+
_normArch: normArch,
|
|
359
|
+
_extract: extract,
|
|
360
|
+
_removeInstalls: removeInstalls,
|
|
361
|
+
_readInstalledVersion: readInstalledVersion,
|
|
362
|
+
_stampVersion: stampVersion,
|
|
363
|
+
_download: download,
|
|
364
|
+
};
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors, chiefly for licensing.
|
|
3
|
+
*
|
|
4
|
+
* Enforcement lives in the browser binary; when it refuses to start it prints a
|
|
5
|
+
* `[capium-license] ...` line to stderr and exits with a coded status. Playwright's /
|
|
6
|
+
* Puppeteer's native launch surfaces that stderr inside its own launch error --
|
|
7
|
+
* `translateLaunchError` turns it into a precise, actionable capium exception.
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
/** Base class for capium SDK errors. */
|
|
14
|
+
class CapiumError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = this.constructor.name;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The browser refused to start for a licensing reason. */
|
|
22
|
+
class CapiumLicenseError extends CapiumError {}
|
|
23
|
+
|
|
24
|
+
/** No license key configured. */
|
|
25
|
+
class CapiumConfigError extends CapiumLicenseError {}
|
|
26
|
+
|
|
27
|
+
/** Concurrent session limit reached across the owner's machines. */
|
|
28
|
+
class CapiumSeatLimitError extends CapiumLicenseError {}
|
|
29
|
+
|
|
30
|
+
/** License expired, revoked, or the key is invalid. */
|
|
31
|
+
class CapiumExpiredError extends CapiumLicenseError {}
|
|
32
|
+
|
|
33
|
+
/** License service unreachable and no valid offline grace. */
|
|
34
|
+
class CapiumServerDownError extends CapiumLicenseError {}
|
|
35
|
+
|
|
36
|
+
function clean(msg) {
|
|
37
|
+
// Pull the '[capium-license] ...' line out of a noisy launch error, if present.
|
|
38
|
+
for (const line of String(msg).split(/\r?\n/)) {
|
|
39
|
+
if (line.includes('[capium-license]')) {
|
|
40
|
+
return line.split('[capium-license]')[1].trim();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return String(msg).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map a driver launch failure to a typed license error when the browser's fail-closed
|
|
48
|
+
* stderr is recognizable. Returns a new error to throw, or null if this isn't a
|
|
49
|
+
* licensing failure (caller should re-throw the original).
|
|
50
|
+
*/
|
|
51
|
+
function translateLaunchError(err) {
|
|
52
|
+
const low = String(err && err.message ? err.message : err).toLowerCase();
|
|
53
|
+
if (!low.includes('capium-license') && !low.includes('no license configured')) return null;
|
|
54
|
+
const hint = clean(err && err.message ? err.message : err);
|
|
55
|
+
if (low.includes('no license configured')) {
|
|
56
|
+
return new CapiumConfigError(
|
|
57
|
+
`${hint}\nSet CAPIUM_LICENSE_KEY, pass licenseKey: ..., or create ` +
|
|
58
|
+
'~/.capium/license (KEY=). Run `npx capiumbrowser status` to check.');
|
|
59
|
+
}
|
|
60
|
+
if (low.includes('session limit') || low.includes('concurrent session')) {
|
|
61
|
+
return new CapiumSeatLimitError(`${hint} — close another Capium browser and retry.`);
|
|
62
|
+
}
|
|
63
|
+
if (low.includes('rejected') || low.includes('expired') || low.includes('revoked')) {
|
|
64
|
+
return new CapiumExpiredError(hint);
|
|
65
|
+
}
|
|
66
|
+
if (low.includes('unreachable') || low.includes('offline grace')) {
|
|
67
|
+
return new CapiumServerDownError(hint);
|
|
68
|
+
}
|
|
69
|
+
return new CapiumLicenseError(hint);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Exit codes written by the binary's fail-closed path (capium_license_main_extra_parts.cc).
|
|
73
|
+
const STATUS_CODES = {
|
|
74
|
+
2: CapiumConfigError, // kExitNoLicense
|
|
75
|
+
3: CapiumSeatLimitError, // kExitSeatLimit
|
|
76
|
+
4: CapiumExpiredError, // kExitExpired (expired / revoked / bad key)
|
|
77
|
+
5: CapiumServerDownError, // kExitServerDown
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Turn the binary's fail-closed status file into a typed error, or null.
|
|
82
|
+
*
|
|
83
|
+
* On a licensing fail-closed the binary writes "<exit_code>\n<message>" to the path the SDK
|
|
84
|
+
* handed it via CAPIUM_LICENSE_STATUS_FILE. Reading THAT is how we surface a precise error
|
|
85
|
+
* from the binary's OWN single verification -- no second server call, and reliable where the
|
|
86
|
+
* driver's stderr relay (translateLaunchError) isn't. Returns null when the file is
|
|
87
|
+
* absent/empty (old binary that doesn't write it -> stderr fallback).
|
|
88
|
+
*/
|
|
89
|
+
function readLaunchStatus(statusPath) {
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = fs.readFileSync(statusPath, 'utf8');
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (!raw.trim()) return null;
|
|
97
|
+
const nl = raw.indexOf('\n');
|
|
98
|
+
const code = (nl === -1 ? raw : raw.slice(0, nl)).trim();
|
|
99
|
+
const msg = (nl === -1 ? '' : raw.slice(nl + 1)).trim() ||
|
|
100
|
+
'the browser refused to start for a licensing reason';
|
|
101
|
+
const Cls = STATUS_CODES[code] || CapiumLicenseError;
|
|
102
|
+
return new Cls(msg);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
CapiumError,
|
|
107
|
+
CapiumLicenseError,
|
|
108
|
+
CapiumConfigError,
|
|
109
|
+
CapiumSeatLimitError,
|
|
110
|
+
CapiumExpiredError,
|
|
111
|
+
CapiumServerDownError,
|
|
112
|
+
translateLaunchError,
|
|
113
|
+
readLaunchStatus,
|
|
114
|
+
};
|
package/lib/human.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* human -- realistic mouse / typing / scroll for Playwright AND Puppeteer pages.
|
|
3
|
+
*
|
|
4
|
+
* Anti-bot systems increasingly score *behavior*: instant teleport-clicks, zero-jitter typing
|
|
5
|
+
* and single-delta scrolls are bot tells. These helpers move the cursor along a bezier path
|
|
6
|
+
* with variable speed, type with per-key jitter, and scroll in eased increments. Pure driver
|
|
7
|
+
* input (page.mouse / page.keyboard) -- no CDP tricks, so it stays consistent with the stealth
|
|
8
|
+
* binary. The Playwright and Puppeteer input APIs align on everything used here except
|
|
9
|
+
* mouse.wheel, which is bridged below.
|
|
10
|
+
*
|
|
11
|
+
* Presets tune the timing:
|
|
12
|
+
* "default" -> natural human pace
|
|
13
|
+
* "careful" -> slower, more deliberate (harder to flag, but slower runs)
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const PRESETS = {
|
|
18
|
+
default: { moveSteps: [18, 34], stepMs: [6, 16], keyMs: [45, 130],
|
|
19
|
+
clickPauseMs: [40, 140], scrollStep: [90, 220], scrollMs: [30, 90] },
|
|
20
|
+
careful: { moveSteps: [30, 55], stepMs: [12, 28], keyMs: [80, 220],
|
|
21
|
+
clickPauseMs: [120, 320], scrollStep: [50, 130], scrollMs: [60, 150] },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const cfg = (preset) => PRESETS[preset] || PRESETS.default;
|
|
25
|
+
const rand = (lo, hi) => lo + Math.random() * (hi - lo);
|
|
26
|
+
const randMs = ([lo, hi]) => rand(lo, hi);
|
|
27
|
+
const randInt = (lo, hi) => Math.round(rand(lo, hi));
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
|
|
30
|
+
function pos(page) {
|
|
31
|
+
return page._capiumMouse || [randInt(2, 60), randInt(2, 60)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bezier(x0, y0, x1, y1, steps) {
|
|
35
|
+
// two random control points -> a curved, non-linear path
|
|
36
|
+
const cx1 = x0 + (x1 - x0) * rand(0.2, 0.45) + rand(-40, 40);
|
|
37
|
+
const cy1 = y0 + (y1 - y0) * rand(0.2, 0.45) + rand(-40, 40);
|
|
38
|
+
const cx2 = x0 + (x1 - x0) * rand(0.55, 0.8) + rand(-40, 40);
|
|
39
|
+
const cy2 = y0 + (y1 - y0) * rand(0.55, 0.8) + rand(-40, 40);
|
|
40
|
+
const pts = [];
|
|
41
|
+
for (let i = 1; i <= steps; i++) {
|
|
42
|
+
const t = i / steps;
|
|
43
|
+
const mt = 1 - t;
|
|
44
|
+
pts.push([
|
|
45
|
+
mt ** 3 * x0 + 3 * mt ** 2 * t * cx1 + 3 * mt * t ** 2 * cx2 + t ** 3 * x1,
|
|
46
|
+
mt ** 3 * y0 + 3 * mt ** 2 * t * cy1 + 3 * mt * t ** 2 * cy2 + t ** 3 * y1,
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
return pts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function wheel(page, dx, dy) {
|
|
53
|
+
// Playwright: mouse.wheel(dx, dy). Puppeteer: mouse.wheel({deltaX, deltaY}).
|
|
54
|
+
if (page.mouse.wheel.length >= 2) return page.mouse.wheel(dx, dy);
|
|
55
|
+
return page.mouse.wheel({ deltaX: dx, deltaY: dy });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Move the cursor to (x, y) along a human-like curved path. */
|
|
59
|
+
async function move(page, x, y, preset = 'default') {
|
|
60
|
+
const c = cfg(preset);
|
|
61
|
+
const [x0, y0] = pos(page);
|
|
62
|
+
const steps = randInt(...c.moveSteps);
|
|
63
|
+
for (const [px, py] of bezier(x0, y0, x, y, steps)) {
|
|
64
|
+
await page.mouse.move(px, py);
|
|
65
|
+
await sleep(randMs(c.stepMs));
|
|
66
|
+
}
|
|
67
|
+
page._capiumMouse = [x, y];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Move to a random point inside `selector` and click with a natural pause. */
|
|
71
|
+
async function click(page, selector, preset = 'default') {
|
|
72
|
+
const c = cfg(preset);
|
|
73
|
+
const el = await page.waitForSelector(selector, { state: 'visible', visible: true });
|
|
74
|
+
const box = await el.boundingBox();
|
|
75
|
+
const tx = box.x + box.width * rand(0.3, 0.7);
|
|
76
|
+
const ty = box.y + box.height * rand(0.3, 0.7);
|
|
77
|
+
await move(page, tx, ty, preset);
|
|
78
|
+
await sleep(randMs(c.clickPauseMs));
|
|
79
|
+
await page.mouse.down();
|
|
80
|
+
await sleep(rand(30, 90));
|
|
81
|
+
await page.mouse.up();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Click the field, then type with per-key jitter (occasional longer pauses). */
|
|
85
|
+
async function typeText(page, selector, text, preset = 'default') {
|
|
86
|
+
const c = cfg(preset);
|
|
87
|
+
await click(page, selector, preset);
|
|
88
|
+
for (const ch of text) {
|
|
89
|
+
await page.keyboard.type(ch);
|
|
90
|
+
let d = randMs(c.keyMs);
|
|
91
|
+
if (Math.random() < 0.06) d += rand(150, 500); // occasional think-pause
|
|
92
|
+
await sleep(d);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Scroll `dy` pixels (positive = down) in eased increments, not one jump. */
|
|
97
|
+
async function scroll(page, dy, preset = 'default') {
|
|
98
|
+
const c = cfg(preset);
|
|
99
|
+
let remaining = dy;
|
|
100
|
+
const direction = dy >= 0 ? 1 : -1;
|
|
101
|
+
while (Math.abs(remaining) > 1) {
|
|
102
|
+
const step = Math.min(Math.abs(remaining), rand(...c.scrollStep)) * direction;
|
|
103
|
+
await wheel(page, 0, step);
|
|
104
|
+
remaining -= step;
|
|
105
|
+
await sleep(randMs(c.scrollMs));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Idle a human-plausible moment (reading/hesitating). */
|
|
110
|
+
async function dwell(loSec = 0.4, hiSec = 1.8) {
|
|
111
|
+
await sleep(rand(loSec * 1000, hiSec * 1000));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Attach page.humanMove / humanClick / humanType / humanScroll / humanDwell.
|
|
116
|
+
*
|
|
117
|
+
* target may be a Page or a BrowserContext (all current + future pages are patched).
|
|
118
|
+
* Returns target (chainable). Does NOT override page.click/type so existing code is safe;
|
|
119
|
+
* call the human* methods where you want human behavior.
|
|
120
|
+
*/
|
|
121
|
+
function humanize(target, preset = 'default') {
|
|
122
|
+
const attach = (page) => {
|
|
123
|
+
if (page._capiumHumanized) return;
|
|
124
|
+
page._capiumHumanized = true;
|
|
125
|
+
page.humanMove = (x, y, p = preset) => move(page, x, y, p);
|
|
126
|
+
page.humanClick = (sel, p = preset) => click(page, sel, p);
|
|
127
|
+
page.humanType = (sel, text, p = preset) => typeText(page, sel, text, p);
|
|
128
|
+
page.humanScroll = (dy, p = preset) => scroll(page, dy, p);
|
|
129
|
+
page.humanDwell = dwell;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// context: patch existing + hook new pages
|
|
133
|
+
if (typeof target.pages === 'function' && typeof target.on === 'function') {
|
|
134
|
+
for (const pg of target.pages()) attach(pg);
|
|
135
|
+
target.on('page', attach);
|
|
136
|
+
} else {
|
|
137
|
+
attach(target);
|
|
138
|
+
}
|
|
139
|
+
return target;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { humanize, move, click, typeText, scroll, dwell, PRESETS };
|