browser-commander 0.22.0 → 0.24.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/.jscpd-baseline.json +47 -29
- package/CHANGELOG.md +19 -0
- package/README.md +12 -3
- package/eslint.config.js +1 -1
- package/examples/react-test-app/package-lock.json +890 -0
- package/examples/react-test-app/package.json +4 -4
- package/package.json +17 -17
- package/src/browser/browser-cookies.js +37 -5
- package/src/browser/browser-profiles.js +18 -0
- package/src/browser/browser-sources.json +30 -0
- package/src/browser/connector.js +46 -4
- package/src/browser/launcher.js +26 -1
- package/src/browser/migration/index.js +33 -0
- package/src/browser/real-browser.js +8 -2
- package/src/browser/safari-cookies.js +173 -0
- package/src/browser/storage-state.js +27 -0
- package/src/browser/webdriver.js +18 -2
- package/src/cli/args.js +5 -0
- package/src/cli/commands.js +10 -6
- package/src/cli/handle-methods.js +19 -2
- package/src/cli/high-level-methods.js +20 -3
- package/src/cli/modules.js +5 -1
- package/src/cli/sessions.js +14 -2
- package/src/tests/index.js +1 -0
- package/tests/e2e/engine-matrix.e2e.test.js +107 -0
- package/tests/e2e/playwright.e2e.test.js +11 -17
- package/tests/e2e/puppeteer.e2e.test.js +13 -17
- package/tests/unit/browser/safari-cookies.test.js +246 -0
- package/tests/unit/cli/dispatcher.test.js +2 -2
- package/tests/unit/cli/selenium-parity.test.js +186 -0
- package/tests/unit/tests/index.test.js +2 -1
- package/tests/unit/traces/links.test.js +2 -2
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"preview": "vite preview"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"react": "^
|
|
13
|
-
"react-dom": "^
|
|
12
|
+
"react": "^19.3.0",
|
|
13
|
+
"react-dom": "^19.3.0"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@vitejs/plugin-react": "^
|
|
17
|
-
"vite": "^
|
|
16
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
17
|
+
"vite": "^8.3.2"
|
|
18
18
|
}
|
|
19
19
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-commander",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Universal browser automation library that supports
|
|
3
|
+
"version": "0.24.0",
|
|
4
|
+
"description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"browser-commander": "bin/browser-commander.js"
|
|
@@ -61,12 +61,12 @@
|
|
|
61
61
|
},
|
|
62
62
|
"homepage": "https://github.com/link-foundation/browser-commander#readme",
|
|
63
63
|
"engines": {
|
|
64
|
-
"node": ">=22.
|
|
64
|
+
"node": ">=22.12.0"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
|
-
"playwright": ">=1.
|
|
68
|
-
"puppeteer": ">=
|
|
69
|
-
"selenium-webdriver": ">=4.
|
|
67
|
+
"playwright": ">=1.63.0",
|
|
68
|
+
"puppeteer": ">=25.12.0",
|
|
69
|
+
"selenium-webdriver": ">=4.50.0"
|
|
70
70
|
},
|
|
71
71
|
"peerDependenciesMeta": {
|
|
72
72
|
"playwright": {
|
|
@@ -81,24 +81,24 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"better-sqlite3": "^13.0.3",
|
|
84
|
-
"command-stream": "^1.
|
|
85
|
-
"links-notation": "^0.
|
|
84
|
+
"command-stream": "^1.4.0",
|
|
85
|
+
"links-notation": "^0.22.0",
|
|
86
86
|
"log-lazy": "^1.0.4",
|
|
87
87
|
"test-anywhere": "^0.9.1"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
|
-
"@changesets/cli": "^3.0.
|
|
90
|
+
"@changesets/cli": "^3.0.3",
|
|
91
91
|
"@eslint/js": "^10.0.1",
|
|
92
|
-
"eslint": "^10.
|
|
92
|
+
"eslint": "^10.12.0",
|
|
93
93
|
"eslint-config-prettier": "^10.1.8",
|
|
94
94
|
"eslint-plugin-prettier": "^5.5.6",
|
|
95
|
-
"jscpd": "^5.
|
|
96
|
-
"jsdoc": "^4.0.
|
|
97
|
-
"playwright": "^1.
|
|
98
|
-
"prettier": "^3.9.
|
|
99
|
-
"puppeteer": "^25.
|
|
100
|
-
"selenium-webdriver": "^4.
|
|
101
|
-
"typescript": "^
|
|
95
|
+
"jscpd": "^5.4.0",
|
|
96
|
+
"jsdoc": "^4.0.5",
|
|
97
|
+
"playwright": "^1.63.0",
|
|
98
|
+
"prettier": "^3.9.9",
|
|
99
|
+
"puppeteer": "^25.12.0",
|
|
100
|
+
"selenium-webdriver": "^4.50.0",
|
|
101
|
+
"typescript": "^7.0.2",
|
|
102
102
|
"yaml": "^2.9.1"
|
|
103
103
|
},
|
|
104
104
|
"allowScripts": {
|
|
@@ -33,6 +33,11 @@ import {
|
|
|
33
33
|
resolveSourceBrowser,
|
|
34
34
|
} from './browser-profiles.js';
|
|
35
35
|
import { resolveDefaultBrowser } from './default-browser.js';
|
|
36
|
+
import {
|
|
37
|
+
countSafariCookies,
|
|
38
|
+
parseSafariCookies,
|
|
39
|
+
readSafariCookieFile,
|
|
40
|
+
} from './safari-cookies.js';
|
|
36
41
|
|
|
37
42
|
const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
|
|
38
43
|
const MICROSECONDS_PER_SECOND = 1_000_000n;
|
|
@@ -319,6 +324,14 @@ export async function readBrowserCookiesWithDependencies(
|
|
|
319
324
|
return cachedCookies;
|
|
320
325
|
}
|
|
321
326
|
|
|
327
|
+
if (browserFamily(browser) === 'safari') {
|
|
328
|
+
const cookies = parseSafariCookies(
|
|
329
|
+
await readSafariCookieFile(cookiePath, { environment }),
|
|
330
|
+
options.domainFilter
|
|
331
|
+
);
|
|
332
|
+
await writeCookieResultCache({ cache, identity, cookies, now });
|
|
333
|
+
return cookies;
|
|
334
|
+
}
|
|
322
335
|
const database = await openCookieDatabase(cookiePath);
|
|
323
336
|
let cookies;
|
|
324
337
|
try {
|
|
@@ -426,8 +439,15 @@ export async function listCookieSources({
|
|
|
426
439
|
let error;
|
|
427
440
|
let database;
|
|
428
441
|
try {
|
|
429
|
-
|
|
430
|
-
|
|
442
|
+
if (family === 'safari') {
|
|
443
|
+
counts = countSafariCookies(
|
|
444
|
+
await readSafariCookieFile(cookiePath, { environment }),
|
|
445
|
+
domains
|
|
446
|
+
);
|
|
447
|
+
} else {
|
|
448
|
+
database = await openCookieDatabase(cookiePath);
|
|
449
|
+
counts = countCookiesByDomain(database, family, domains);
|
|
450
|
+
}
|
|
431
451
|
} catch (openError) {
|
|
432
452
|
error = openError.message;
|
|
433
453
|
} finally {
|
|
@@ -514,12 +534,24 @@ export async function resolveImportSource({
|
|
|
514
534
|
environment,
|
|
515
535
|
runCommand,
|
|
516
536
|
});
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
537
|
+
const listedSources = await listCookieSources({
|
|
538
|
+
domains,
|
|
539
|
+
platform,
|
|
540
|
+
homeDir,
|
|
541
|
+
environment,
|
|
542
|
+
});
|
|
543
|
+
const holders = listedSources.filter((source) => !source.error);
|
|
520
544
|
const fromDefault = holders.filter(
|
|
521
545
|
(source) => source.browser === systemDefault
|
|
522
546
|
);
|
|
547
|
+
const unreadableDefault = listedSources.find(
|
|
548
|
+
(source) => source.browser === systemDefault && source.error
|
|
549
|
+
);
|
|
550
|
+
if (fromDefault.length === 0 && unreadableDefault) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Could not inspect the default browser (${systemDefault}): ${unreadableDefault.error}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
523
555
|
const candidates = fromDefault.length > 0 ? fromDefault : holders;
|
|
524
556
|
if (candidates.length === 0) {
|
|
525
557
|
if (!systemDefault) {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveBrowserRoots,
|
|
11
11
|
} from './browser-sources.js';
|
|
12
12
|
import { resolveDefaultBrowser } from './default-browser.js';
|
|
13
|
+
import { findSafariCookieFile } from './safari-cookies.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Keywords that select the operating-system default browser instead of a named
|
|
@@ -112,6 +113,9 @@ function chromiumCookiePaths(profilePath, pathApi = path) {
|
|
|
112
113
|
|
|
113
114
|
export async function findCookieDatabase(browser, profilePath, platform) {
|
|
114
115
|
const pathApi = platformPath(platform ?? process.platform);
|
|
116
|
+
if (browserFamily(browser) === 'safari') {
|
|
117
|
+
return findSafariCookieFile(profilePath);
|
|
118
|
+
}
|
|
115
119
|
if (browserFamily(browser) === 'firefox') {
|
|
116
120
|
const candidate = pathApi.join(profilePath, 'cookies.sqlite');
|
|
117
121
|
return (await pathExists(candidate)) ? candidate : null;
|
|
@@ -274,6 +278,20 @@ async function listProfilesForBrowser(browser, platform, homeDir, environment) {
|
|
|
274
278
|
const family = browserFamily(browser);
|
|
275
279
|
const profiles = [];
|
|
276
280
|
for (const root of roots) {
|
|
281
|
+
if (family === 'safari') {
|
|
282
|
+
if (await findSafariCookieFile(root)) {
|
|
283
|
+
return [
|
|
284
|
+
{
|
|
285
|
+
browser,
|
|
286
|
+
name: 'Default',
|
|
287
|
+
displayName: 'Default',
|
|
288
|
+
path: root,
|
|
289
|
+
isDefault: true,
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
277
295
|
profiles.push(
|
|
278
296
|
...(family === 'firefox'
|
|
279
297
|
? await listFirefoxProfiles(browser, root, platform)
|
|
@@ -394,6 +394,36 @@
|
|
|
394
394
|
"darwin": ["org.floorp.Floorp"],
|
|
395
395
|
"linux": ["floorp.desktop"]
|
|
396
396
|
}
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"id": "safari",
|
|
400
|
+
"family": "safari",
|
|
401
|
+
"aliases": ["apple-safari"],
|
|
402
|
+
"singleProfile": true,
|
|
403
|
+
"roots": {
|
|
404
|
+
"darwin": [
|
|
405
|
+
"{home}/Library/Containers/com.apple.Safari/Data/Library",
|
|
406
|
+
"{home}/Library"
|
|
407
|
+
]
|
|
408
|
+
},
|
|
409
|
+
"default": {
|
|
410
|
+
"darwin": ["com.apple.safari"]
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
"id": "safari-technology-preview",
|
|
415
|
+
"family": "safari",
|
|
416
|
+
"aliases": ["safari-tp", "safari-preview"],
|
|
417
|
+
"singleProfile": true,
|
|
418
|
+
"roots": {
|
|
419
|
+
"darwin": [
|
|
420
|
+
"{home}/Library/Containers/com.apple.SafariTechnologyPreview/Data/Library",
|
|
421
|
+
"{home}/Library/Safari Technology Preview"
|
|
422
|
+
]
|
|
423
|
+
},
|
|
424
|
+
"default": {
|
|
425
|
+
"darwin": ["com.apple.safaritechnologypreview"]
|
|
426
|
+
}
|
|
397
427
|
}
|
|
398
428
|
]
|
|
399
429
|
}
|
package/src/browser/connector.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { attachDownloads } from '../downloads/attach.js';
|
|
2
|
+
import { connectWebDriver, launchWebDriver } from './webdriver.js';
|
|
2
3
|
import {
|
|
3
4
|
loadStorageState,
|
|
4
5
|
restorePlaywrightStorageState,
|
|
5
6
|
restorePuppeteerStorageState,
|
|
7
|
+
restoreWebDriverStorageState,
|
|
6
8
|
} from './storage-state.js';
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -11,9 +13,9 @@ import {
|
|
|
11
13
|
* @param {string} engine
|
|
12
14
|
*/
|
|
13
15
|
export function assertSupportedEngine(engine) {
|
|
14
|
-
if (!['playwright', 'puppeteer'].includes(engine)) {
|
|
16
|
+
if (!['playwright', 'puppeteer', 'selenium'].includes(engine)) {
|
|
15
17
|
throw new Error(
|
|
16
|
-
`Invalid engine: ${engine}. Expected 'playwright' or '
|
|
18
|
+
`Invalid engine: ${engine}. Expected 'playwright', 'puppeteer' or 'selenium'`
|
|
17
19
|
);
|
|
18
20
|
}
|
|
19
21
|
}
|
|
@@ -28,6 +30,28 @@ function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) {
|
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
async function connectSelenium(options, dependencies) {
|
|
34
|
+
if (Boolean(options.serverUrl) === Boolean(options.cdpEndpoint)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'selenium requires exactly one of serverUrl or cdpEndpoint'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (options.wsEndpoint) {
|
|
40
|
+
throw new Error('selenium requires an HTTP cdpEndpoint or serverUrl');
|
|
41
|
+
}
|
|
42
|
+
const connected = options.serverUrl
|
|
43
|
+
? await (dependencies.connectWebDriver ?? connectWebDriver)(options)
|
|
44
|
+
: await (dependencies.launchWebDriver ?? launchWebDriver)({
|
|
45
|
+
...options,
|
|
46
|
+
debuggerAddress: new URL(options.cdpEndpoint).host,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
...connected,
|
|
50
|
+
browser: connected.driver,
|
|
51
|
+
disconnect: connected.close,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
function addDefinedOptions(options, values) {
|
|
32
56
|
for (const [key, value] of Object.entries(values)) {
|
|
33
57
|
if (value !== undefined) {
|
|
@@ -135,10 +159,12 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
|
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
/**
|
|
138
|
-
* Attach
|
|
162
|
+
* Attach over CDP or create a native session on a WebDriver server.
|
|
139
163
|
*
|
|
140
164
|
* @param {Object} options - Connection options
|
|
141
|
-
* @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
|
|
165
|
+
* @param {'playwright'|'puppeteer'|'selenium'} [options.engine='playwright'] - Automation engine
|
|
166
|
+
* @param {string} [options.serverUrl] - Selenium Grid or WebDriver server URL
|
|
167
|
+
* @param {Object} [options.capabilities] - Native WebDriver capabilities
|
|
142
168
|
* @param {string} [options.cdpEndpoint] - HTTP DevTools endpoint, such as http://127.0.0.1:9222
|
|
143
169
|
* @param {string} [options.wsEndpoint] - DevTools browser WebSocket endpoint
|
|
144
170
|
* @param {number} [options.slowMo] - Delay engine operations by this many milliseconds
|
|
@@ -171,6 +197,22 @@ export async function connectBrowserWithDependencies(
|
|
|
171
197
|
verbose: false,
|
|
172
198
|
...options,
|
|
173
199
|
};
|
|
200
|
+
if (normalizedOptions.engine === 'selenium') {
|
|
201
|
+
assertSupportedEngine(normalizedOptions.engine);
|
|
202
|
+
const storageState = await prepareStorageState(normalizedOptions);
|
|
203
|
+
const result = await connectSelenium(normalizedOptions, dependencies);
|
|
204
|
+
try {
|
|
205
|
+
await restoreWebDriverStorageState({ page: result.page, storageState });
|
|
206
|
+
const downloads = await attachDownloads({
|
|
207
|
+
...normalizedOptions,
|
|
208
|
+
...result,
|
|
209
|
+
});
|
|
210
|
+
return { ...result, downloads };
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await result.close();
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
174
216
|
validateConnectionOptions(normalizedOptions);
|
|
175
217
|
|
|
176
218
|
const storageState = await prepareStorageState(normalizedOptions);
|
package/src/browser/launcher.js
CHANGED
|
@@ -16,10 +16,12 @@ import { resolveRestrictions } from './restrictions.js';
|
|
|
16
16
|
import { resolveSystemBrowserExecutable } from './system-browser.js';
|
|
17
17
|
import { applyFingerprint } from '../fingerprint/apply.js';
|
|
18
18
|
import { attachDownloads } from '../downloads/attach.js';
|
|
19
|
+
import { launchWebDriver } from './webdriver.js';
|
|
19
20
|
import {
|
|
20
21
|
loadStorageState,
|
|
21
22
|
restorePlaywrightStorageState,
|
|
22
23
|
restorePuppeteerStorageState,
|
|
24
|
+
restoreWebDriverStorageState,
|
|
23
25
|
} from './storage-state.js';
|
|
24
26
|
|
|
25
27
|
/** How `launchBrowser` starts the browser (issue #103). */
|
|
@@ -131,6 +133,13 @@ async function launchReal(options, dependencies) {
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
async function launchWithEngine(options, dependencies) {
|
|
136
|
+
if (options.engine === 'selenium') {
|
|
137
|
+
const launched = await (dependencies.launchWebDriver ?? launchWebDriver)({
|
|
138
|
+
...options,
|
|
139
|
+
args: [...options.args, ...options.extraArgs],
|
|
140
|
+
});
|
|
141
|
+
return { ...launched, browser: launched.driver };
|
|
142
|
+
}
|
|
134
143
|
const {
|
|
135
144
|
engine,
|
|
136
145
|
userDataDir: requestedUserDataDir,
|
|
@@ -261,7 +270,7 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
|
|
|
261
270
|
* limitations.json (`engine-launch-switches`).
|
|
262
271
|
*
|
|
263
272
|
* @param {Object} options - Configuration options
|
|
264
|
-
* @param {string} [options.engine='playwright'] - Browser automation engine: 'playwright' or '
|
|
273
|
+
* @param {string} [options.engine='playwright'] - Browser automation engine: 'playwright', 'puppeteer' or 'selenium'
|
|
265
274
|
* @param {'real'|'engine'} [options.launch='real'] - Who starts the browser: Browser Commander (a hand-started command line) or the automation engine
|
|
266
275
|
* @param {string} [options.userDataDir] - Persistent profile directory. When omitted a fresh temporary profile is created and deleted on close.
|
|
267
276
|
* @param {boolean} [options.headless=false] - Run in headless mode
|
|
@@ -309,6 +318,16 @@ export async function launchBrowserWithDependencies(
|
|
|
309
318
|
...rest
|
|
310
319
|
} = options;
|
|
311
320
|
validateLaunchMode({ engine, launch, attach: rest.attach });
|
|
321
|
+
if (engine === 'selenium') {
|
|
322
|
+
if (colorScheme !== undefined || fingerprint !== undefined || downloads) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
'selenium does not support CDP emulation, fingerprint overrides or managed downloads (webdriver-no-cdp-emulation)'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (launch === 'real' && rest.browser === 'firefox') {
|
|
328
|
+
throw new Error("selenium Firefox requires launch: 'engine'");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
312
331
|
// Validate arguments before anything is started or written to disk.
|
|
313
332
|
resolveChromeArgs({ args, extraArgs, ignoreDefaultArgs, restrictions });
|
|
314
333
|
const resolvedStorageState = await loadStorageState(storageState);
|
|
@@ -341,6 +360,12 @@ export async function launchBrowserWithDependencies(
|
|
|
341
360
|
}
|
|
342
361
|
|
|
343
362
|
try {
|
|
363
|
+
if (engine === 'selenium' && launch === 'engine') {
|
|
364
|
+
await restoreWebDriverStorageState({
|
|
365
|
+
page,
|
|
366
|
+
storageState: resolvedStorageState,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
344
369
|
// A Playwright-launched context applies colorScheme itself; an attached
|
|
345
370
|
// browser and Puppeteer need page-level emulation.
|
|
346
371
|
const emulateColorScheme =
|
|
@@ -114,6 +114,34 @@ function mergeReport(report, className, fragment) {
|
|
|
114
114
|
report.warnings.push(...(fragment.warnings ?? []));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
function reportSafariClasses(report, selected) {
|
|
118
|
+
for (const type of ALL_DATA_CLASSES.filter(
|
|
119
|
+
(name) => name !== 'cookies' && selected.has(name)
|
|
120
|
+
)) {
|
|
121
|
+
report.skipped.push({
|
|
122
|
+
type,
|
|
123
|
+
item: report.source.browser,
|
|
124
|
+
reason:
|
|
125
|
+
type === 'passwords'
|
|
126
|
+
? 'safari-password-export-required'
|
|
127
|
+
: 'safari-class-not-supported',
|
|
128
|
+
detail:
|
|
129
|
+
type === 'passwords'
|
|
130
|
+
? 'Safari passwords live in the Keychain. Export Passwords from Safari or the Passwords app to CSV; CSV import is tracked separately and is not supported yet.'
|
|
131
|
+
: 'Safari currently supports cookie import only; this data class has not been translated.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (report.cookies.length > 0) {
|
|
135
|
+
report.warnings.push({
|
|
136
|
+
type: 'cookies',
|
|
137
|
+
item: report.source.browser,
|
|
138
|
+
reason: 'safari-samesite-unavailable',
|
|
139
|
+
detail:
|
|
140
|
+
'Cookies.binarycookies does not store SameSite; imported cookies use Lax.',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
async function resolvePasswordKeys({
|
|
118
146
|
keys,
|
|
119
147
|
browser,
|
|
@@ -234,6 +262,11 @@ export async function migrateProfile({
|
|
|
234
262
|
}
|
|
235
263
|
}
|
|
236
264
|
|
|
265
|
+
if (browserFamily(browser) === 'safari') {
|
|
266
|
+
reportSafariClasses(report, selected);
|
|
267
|
+
return report;
|
|
268
|
+
}
|
|
269
|
+
|
|
237
270
|
if (selected.has('bookmarks')) {
|
|
238
271
|
mergeReport(
|
|
239
272
|
report,
|
|
@@ -259,6 +259,10 @@ async function waitForExit(browserProcess, timeout) {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
async function requestBrowserClose({ engine, browser, originalClose }) {
|
|
262
|
+
if (engine === 'selenium') {
|
|
263
|
+
await originalClose();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
262
266
|
if (engine === 'puppeteer') {
|
|
263
267
|
// A connected Puppeteer Browser sends Browser.close and waits for it.
|
|
264
268
|
await originalClose.call(browser);
|
|
@@ -278,8 +282,9 @@ function createCloser({
|
|
|
278
282
|
userDataDir,
|
|
279
283
|
temporaryProfile,
|
|
280
284
|
closeTimeout,
|
|
285
|
+
closeConnection,
|
|
281
286
|
}) {
|
|
282
|
-
const originalClose = browser?.close;
|
|
287
|
+
const originalClose = closeConnection ?? browser?.close;
|
|
283
288
|
let closing;
|
|
284
289
|
const close = () => {
|
|
285
290
|
closing ??= (async () => {
|
|
@@ -298,7 +303,7 @@ function createCloser({
|
|
|
298
303
|
})();
|
|
299
304
|
return closing;
|
|
300
305
|
};
|
|
301
|
-
if (browser && originalClose) {
|
|
306
|
+
if (browser && originalClose && engine !== 'selenium') {
|
|
302
307
|
// `browser.close()` means "close the browser" for every caller, so it
|
|
303
308
|
// shuts the spawned process down and deletes a temporary profile too.
|
|
304
309
|
browser.close = close;
|
|
@@ -670,6 +675,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
670
675
|
userDataDir,
|
|
671
676
|
temporaryProfile,
|
|
672
677
|
closeTimeout,
|
|
678
|
+
closeConnection: engine === 'selenium' ? connection.close : undefined,
|
|
673
679
|
});
|
|
674
680
|
return {
|
|
675
681
|
...connection,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Read Safari's unencrypted, mixed-endian Cookies.binarycookies store.
|
|
2
|
+
* Format: https://github.com/libyal/dtformats/blob/main/documentation/Safari%20Cookies.asciidoc
|
|
3
|
+
* No SameSite attribute is stored in this format; use Lax and report that loss.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile as readFileBytes, stat } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { TextDecoder } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const APPLE_EPOCH = 978_307_200;
|
|
10
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
11
|
+
|
|
12
|
+
function invalid(detail) {
|
|
13
|
+
throw new Error(`Invalid Safari binarycookies: ${detail}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function bounded(data, offset, size) {
|
|
17
|
+
if (offset < 0 || size < 0 || offset + size > data.length) {
|
|
18
|
+
invalid('truncated or out-of-range data');
|
|
19
|
+
}
|
|
20
|
+
return data.subarray(offset, offset + size);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function uint(data, offset, bigEndian = false) {
|
|
24
|
+
const bytes = bounded(data, offset, 4);
|
|
25
|
+
return bigEndian ? bytes.readUInt32BE() : bytes.readUInt32LE();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function string(record, field) {
|
|
29
|
+
const offset = uint(record, field);
|
|
30
|
+
if (offset < 56 || offset >= record.length) {
|
|
31
|
+
invalid('string offset');
|
|
32
|
+
}
|
|
33
|
+
const end = record.indexOf(0, offset);
|
|
34
|
+
if (end < 0) {
|
|
35
|
+
invalid('unterminated string');
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return utf8.decode(record.subarray(offset, end));
|
|
39
|
+
} catch {
|
|
40
|
+
return invalid('invalid UTF-8');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate all structural offsets before walking records; counts are bounded
|
|
45
|
+
* by the file size, so forged counts cannot cause unbounded allocations.
|
|
46
|
+
*/
|
|
47
|
+
function* records(data) {
|
|
48
|
+
if (bounded(data, 0, 4).toString('ascii') !== 'cook') {
|
|
49
|
+
invalid('file signature');
|
|
50
|
+
}
|
|
51
|
+
const pages = uint(data, 4, true);
|
|
52
|
+
bounded(data, 8, pages * 4);
|
|
53
|
+
let pageStart = 8 + pages * 4;
|
|
54
|
+
for (let index = 0; index < pages; index += 1) {
|
|
55
|
+
const page = bounded(data, pageStart, uint(data, 8 + index * 4, true));
|
|
56
|
+
pageStart += page.length;
|
|
57
|
+
if (uint(page, 0) !== 0x00010000) {
|
|
58
|
+
invalid('page signature');
|
|
59
|
+
}
|
|
60
|
+
const count = uint(page, 4);
|
|
61
|
+
const headerSize = 8 + count * 4;
|
|
62
|
+
bounded(page, 0, headerSize);
|
|
63
|
+
let previousEnd = headerSize;
|
|
64
|
+
for (let cookie = 0; cookie < count; cookie += 1) {
|
|
65
|
+
const start = uint(page, 8 + cookie * 4);
|
|
66
|
+
if (start < previousEnd) {
|
|
67
|
+
invalid('overlapping record');
|
|
68
|
+
}
|
|
69
|
+
const size = uint(page, start);
|
|
70
|
+
if (size < 56) {
|
|
71
|
+
invalid('short record');
|
|
72
|
+
}
|
|
73
|
+
const record = bounded(page, start, size);
|
|
74
|
+
previousEnd = start + size;
|
|
75
|
+
yield record;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Safari may append a checksum and a plist trailer. They are not cookies.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Decode cookies in automation shape, using the existing substring filter. */
|
|
82
|
+
export function parseSafariCookies(data, domainFilter) {
|
|
83
|
+
const cookies = [];
|
|
84
|
+
for (const record of records(data)) {
|
|
85
|
+
const domain = string(record, 16);
|
|
86
|
+
if (
|
|
87
|
+
domainFilter &&
|
|
88
|
+
!domain.toLowerCase().includes(domainFilter.toLowerCase())
|
|
89
|
+
) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const expiry = record.readDoubleLE(40) + APPLE_EPOCH;
|
|
93
|
+
if (
|
|
94
|
+
!Number.isFinite(expiry) ||
|
|
95
|
+
Math.abs(expiry) > Number.MAX_SAFE_INTEGER
|
|
96
|
+
) {
|
|
97
|
+
invalid('expiry');
|
|
98
|
+
}
|
|
99
|
+
const flags = uint(record, 8);
|
|
100
|
+
cookies.push({
|
|
101
|
+
name: string(record, 20),
|
|
102
|
+
value: string(record, 28),
|
|
103
|
+
domain,
|
|
104
|
+
path: string(record, 24) || '/',
|
|
105
|
+
expires: Math.floor(expiry),
|
|
106
|
+
httpOnly: Boolean(flags & 4),
|
|
107
|
+
secure: Boolean(flags & 1),
|
|
108
|
+
sameSite: 'Lax',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return cookies;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Counts decode only domain strings. Names and values are never decoded. */
|
|
115
|
+
export function countSafariCookies(data, domains) {
|
|
116
|
+
const byDomain = domains?.length
|
|
117
|
+
? Object.fromEntries(domains.map((domain) => [domain, 0]))
|
|
118
|
+
: null;
|
|
119
|
+
let total = 0;
|
|
120
|
+
for (const record of records(data)) {
|
|
121
|
+
const host = string(record, 16).toLowerCase();
|
|
122
|
+
total += 1;
|
|
123
|
+
for (const domain of Object.keys(byDomain ?? {})) {
|
|
124
|
+
if (host.includes(domain.toLowerCase())) {
|
|
125
|
+
byDomain[domain] += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { total, byDomain };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A protected path is still a source: preserve it for an actionable read error. */
|
|
133
|
+
export async function findSafariCookieFile(profileDir, statFile = stat) {
|
|
134
|
+
for (const candidate of [
|
|
135
|
+
path.join(profileDir, 'Cookies', 'Cookies.binarycookies'),
|
|
136
|
+
path.join(profileDir, 'Cookies.binarycookies'),
|
|
137
|
+
]) {
|
|
138
|
+
try {
|
|
139
|
+
if ((await statFile(candidate)).isFile()) {
|
|
140
|
+
return candidate;
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (['EPERM', 'EACCES'].includes(error.code)) {
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function readSafariCookieFile(
|
|
155
|
+
filePath,
|
|
156
|
+
{ environment = process.env, readFile = readFileBytes } = {}
|
|
157
|
+
) {
|
|
158
|
+
try {
|
|
159
|
+
return await readFile(filePath);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!['EPERM', 'EACCES'].includes(error.code)) {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const app =
|
|
165
|
+
environment.__CFBundleIdentifier ||
|
|
166
|
+
environment.TERM_PROGRAM ||
|
|
167
|
+
process.execPath;
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Safari cookie access denied (${error.code}) at ${filePath}. Grant Full Disk Access to ${app}, the app running Browser Commander, then retry: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
|
|
170
|
+
{ cause: error }
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -30,6 +30,33 @@ function restoreOriginLocalStorage(origins) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Restore classic WebDriver cookies; origin preload scripts require BiDi. */
|
|
34
|
+
export async function restoreWebDriverStorageState({ page, storageState }) {
|
|
35
|
+
if (!storageState) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const origins = storageState.origins ?? [];
|
|
39
|
+
if (origins.length) {
|
|
40
|
+
await page.evaluateOnNewDocument(restoreOriginLocalStorage, origins);
|
|
41
|
+
await page.evaluate(restoreOriginLocalStorage, origins);
|
|
42
|
+
}
|
|
43
|
+
const originalUrl = page.url();
|
|
44
|
+
const cookies = storageState.cookies ?? [];
|
|
45
|
+
try {
|
|
46
|
+
for (const cookie of cookies) {
|
|
47
|
+
const origin = cookie.url
|
|
48
|
+
? new URL(cookie.url).origin
|
|
49
|
+
: `${cookie.secure ? 'https' : 'http'}://${cookie.domain.replace(/^\./u, '')}`;
|
|
50
|
+
await page.goto(origin);
|
|
51
|
+
await page.setCookie(cookie);
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
if (cookies.length) {
|
|
55
|
+
await page.goto(originalUrl);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
/**
|
|
34
61
|
* Apply Playwright-compatible storage state to a Puppeteer page.
|
|
35
62
|
* @param {Object} options - Restore options
|