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.
@@ -9,11 +9,11 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "react": "^18.2.0",
13
- "react-dom": "^18.2.0"
12
+ "react": "^19.3.0",
13
+ "react-dom": "^19.3.0"
14
14
  },
15
15
  "devDependencies": {
16
- "@vitejs/plugin-react": "^4.2.0",
17
- "vite": "^5.0.0"
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.22.0",
4
- "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
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.0.0"
64
+ "node": ">=22.12.0"
65
65
  },
66
66
  "peerDependencies": {
67
- "playwright": ">=1.40.0",
68
- "puppeteer": ">=21.0.0",
69
- "selenium-webdriver": ">=4.20.0"
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.2.0",
85
- "links-notation": "^0.20.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.2",
90
+ "@changesets/cli": "^3.0.3",
91
91
  "@eslint/js": "^10.0.1",
92
- "eslint": "^10.10.0",
92
+ "eslint": "^10.12.0",
93
93
  "eslint-config-prettier": "^10.1.8",
94
94
  "eslint-plugin-prettier": "^5.5.6",
95
- "jscpd": "^5.1.2",
96
- "jsdoc": "^4.0.4",
97
- "playwright": "^1.62.1",
98
- "prettier": "^3.9.6",
99
- "puppeteer": "^25.10.0",
100
- "selenium-webdriver": "^4.49.0",
101
- "typescript": "^5.9.3",
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
- database = await openCookieDatabase(cookiePath);
430
- counts = countCookiesByDomain(database, family, domains);
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 holders = (
518
- await listCookieSources({ domains, platform, homeDir, environment })
519
- ).filter((source) => !source.error);
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
  }
@@ -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 'puppeteer'`
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 to an already-running Chromium-family browser over CDP.
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);
@@ -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 'puppeteer'
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