browser-commander 0.23.0 → 0.25.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.
Files changed (77) hide show
  1. package/.jscpd-baseline.json +48 -30
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +19 -7
  4. package/eslint.config.js +1 -1
  5. package/examples/react-test-app/package-lock.json +890 -0
  6. package/examples/react-test-app/package.json +4 -4
  7. package/package.json +20 -17
  8. package/src/browser/attach/snapshot.js +4 -28
  9. package/src/browser/browser-cookie-credentials.js +15 -8
  10. package/src/browser/browser-cookies.js +36 -30
  11. package/src/browser/browser-profile-files.js +31 -0
  12. package/src/browser/browser-profiles.js +11 -13
  13. package/src/browser/browser-sources.js +39 -9
  14. package/src/browser/browser-sources.json +600 -23
  15. package/src/browser/connector.js +46 -4
  16. package/src/browser/launcher.js +26 -1
  17. package/src/browser/migration/capabilities.json +117 -0
  18. package/src/browser/migration/chromium-history.sql +22 -0
  19. package/src/browser/migration/chromium-writers.js +85 -0
  20. package/src/browser/migration/cookies.js +4 -1
  21. package/src/browser/migration/data-classes.js +68 -0
  22. package/src/browser/migration/domains.js +32 -0
  23. package/src/browser/migration/firefox-history.js +106 -0
  24. package/src/browser/migration/firefox.js +39 -25
  25. package/src/browser/migration/history.js +161 -2
  26. package/src/browser/migration/index.js +229 -83
  27. package/src/browser/migration/os-crypt-keys.js +1 -5
  28. package/src/browser/migration/password-metadata.js +95 -0
  29. package/src/browser/migration/passwords.js +78 -41
  30. package/src/browser/migration/safari-import.js +100 -0
  31. package/src/browser/migration/safari.js +110 -0
  32. package/src/browser/migration/sqlite-snapshot.js +31 -65
  33. package/src/browser/migration/validation.js +48 -0
  34. package/src/browser/real-browser.js +21 -3
  35. package/src/browser/safari-access.js +45 -0
  36. package/src/browser/safari-cookies.js +39 -36
  37. package/src/browser/safari-profiles.js +83 -0
  38. package/src/browser/storage-state.js +27 -0
  39. package/src/browser/system-browser.js +31 -143
  40. package/src/browser/webdriver.js +18 -2
  41. package/src/cli/args.js +7 -0
  42. package/src/cli/commands.js +12 -6
  43. package/src/cli/handle-methods.js +19 -2
  44. package/src/cli/high-level-methods.js +22 -3
  45. package/src/cli/modules.js +5 -1
  46. package/src/cli/sessions.js +14 -2
  47. package/src/tests/index.js +1 -0
  48. package/tests/e2e/engine-matrix.e2e.test.js +107 -0
  49. package/tests/e2e/playwright.e2e.test.js +11 -17
  50. package/tests/e2e/puppeteer.e2e.test.js +13 -17
  51. package/tests/unit/browser/attach/snapshot.test.js +30 -0
  52. package/tests/unit/browser/browser-cookies.test.js +35 -0
  53. package/tests/unit/browser/catalogue-launch.test.js +93 -0
  54. package/tests/unit/browser/cookie-sources.test.js +92 -17
  55. package/tests/unit/browser/credential-catalogue.test.js +50 -0
  56. package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
  57. package/tests/unit/browser/migration/data-classes.test.js +89 -0
  58. package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
  59. package/tests/unit/browser/migration/firefox-history.test.js +130 -0
  60. package/tests/unit/browser/migration/history-metadata.test.js +74 -0
  61. package/tests/unit/browser/migration/index.test.js +16 -1
  62. package/tests/unit/browser/migration/opera-keys.test.js +22 -0
  63. package/tests/unit/browser/migration/safari-data.test.js +143 -0
  64. package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
  65. package/tests/unit/browser/migration/validation.test.js +63 -0
  66. package/tests/unit/browser/migration/yandex.test.js +84 -0
  67. package/tests/unit/browser/real-browser.test.js +19 -0
  68. package/tests/unit/browser/safari-cookies.test.js +26 -1
  69. package/tests/unit/browser/safari-profiles.test.js +111 -0
  70. package/tests/unit/cli/dispatcher.test.js +6 -2
  71. package/tests/unit/cli/main.test.js +5 -0
  72. package/tests/unit/cli/selenium-parity.test.js +186 -0
  73. package/tests/unit/scripts/browser-support.test.js +14 -7
  74. package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
  75. package/tests/unit/scripts/migration-support.test.js +31 -0
  76. package/tests/unit/tests/index.test.js +2 -1
  77. package/tests/unit/traces/links.test.js +2 -2
@@ -0,0 +1,100 @@
1
+ import path from 'node:path';
2
+ import { findSafariFile } from '../safari-access.js';
3
+ import {
4
+ readSafariBookmarks,
5
+ readSafariHistory,
6
+ readSafariPasswords,
7
+ } from './safari.js';
8
+ import {
9
+ writeChromiumBookmarks,
10
+ writeChromiumHistory,
11
+ } from './chromium-writers.js';
12
+ import { writeChromiumPasswords } from './firefox.js';
13
+
14
+ function findStore(profileDir, name, legacyDir) {
15
+ return findSafariFile([
16
+ path.join(profileDir, 'Safari', name),
17
+ path.join(profileDir, name),
18
+ ...(legacyDir ? [path.join(legacyDir, name)] : []),
19
+ ]);
20
+ }
21
+
22
+ export async function migrateSafariClass({
23
+ type,
24
+ profileDir,
25
+ targetProfileDir,
26
+ domains,
27
+ passwordCsv,
28
+ passwordKeys,
29
+ platform,
30
+ legacyDir,
31
+ }) {
32
+ const skipped = (reason, detail) => ({
33
+ migrated: 0,
34
+ skipped: [
35
+ { type, item: profileDir, reason, ...(detail ? { detail } : {}) },
36
+ ],
37
+ warnings: [],
38
+ });
39
+ if (type === 'preferences' || type === 'extensions') {
40
+ return skipped(
41
+ 'safari-class-not-supported',
42
+ 'Safari preferences and extensions do not use Chromium formats.'
43
+ );
44
+ }
45
+ if (type === 'passwords') {
46
+ if (!passwordCsv) {
47
+ return skipped(
48
+ 'safari-password-export-required',
49
+ 'Export Passwords from Safari or the Passwords app to CSV, then supply passwordCsv (CLI: --password-csv).'
50
+ );
51
+ }
52
+ const entries = await readSafariPasswords(passwordCsv, domains);
53
+ if (!passwordKeys?.targetKey) {
54
+ return skipped(
55
+ 'target-key-unavailable',
56
+ 'Supply the dedicated target profile encryption key; no plaintext passwords are written.'
57
+ );
58
+ }
59
+ const migrated = await writeChromiumPasswords({
60
+ entries,
61
+ targetProfileDir,
62
+ platform,
63
+ ...passwordKeys,
64
+ });
65
+ return { migrated, skipped: [], warnings: [] };
66
+ }
67
+ const filename = await findStore(
68
+ profileDir,
69
+ type === 'bookmarks' ? 'Bookmarks.plist' : 'History.db',
70
+ legacyDir
71
+ );
72
+ if (!filename) {
73
+ return skipped('source-missing');
74
+ }
75
+ if (type === 'bookmarks') {
76
+ const entries = await readSafariBookmarks(filename);
77
+ const hasReadingList = (nodes) =>
78
+ nodes.some(
79
+ (node) => node.readingList || hasReadingList(node.children ?? [])
80
+ );
81
+ const migrated = await writeChromiumBookmarks(targetProfileDir, entries);
82
+ const warnings = hasReadingList(entries)
83
+ ? [
84
+ {
85
+ type,
86
+ item: filename,
87
+ reason: 'safari-reading-list-translated',
88
+ detail:
89
+ 'Reading-list URLs become bookmarks; read status and preview metadata are not translated.',
90
+ },
91
+ ]
92
+ : [];
93
+ return { migrated, skipped: [], warnings };
94
+ }
95
+ const migrated = await writeChromiumHistory(
96
+ targetProfileDir,
97
+ await readSafariHistory(filename, domains)
98
+ );
99
+ return { migrated, skipped: [], warnings: [] };
100
+ }
@@ -0,0 +1,110 @@
1
+ import { open, readFile } from 'node:fs/promises';
2
+ import * as bplist from 'bplist-parser';
3
+ import * as plist from 'plist';
4
+ import { parse } from 'csv-parse/sync';
5
+ import { matchesDomains } from './domains.js';
6
+ import { readDatabaseSnapshot } from './sqlite-snapshot.js';
7
+
8
+ import { withSafariAccess } from '../safari-access.js';
9
+ export { withSafariAccess } from '../safari-access.js';
10
+
11
+ export function readSafariPlist(filename) {
12
+ return withSafariAccess(filename, async () => {
13
+ const bytes = await readFile(filename);
14
+ return bytes.subarray(0, 8).toString() === 'bplist00'
15
+ ? bplist.parseBuffer(bytes)[0]
16
+ : plist.parse(bytes.toString('utf8'));
17
+ });
18
+ }
19
+
20
+ /** Canonical bookmark tree, including nested reading-list entries. */
21
+ export async function readSafariBookmarks(filename) {
22
+ const document = await readSafariPlist(filename);
23
+ function convert(node, readingList = false) {
24
+ const isReadingList =
25
+ readingList ||
26
+ node.Title === 'com.apple.ReadingList' ||
27
+ Boolean(node.ReadingList);
28
+ if (node.WebBookmarkType === 'WebBookmarkTypeLeaf') {
29
+ if (!node.URLString) {
30
+ return null;
31
+ }
32
+ return {
33
+ type: 'url',
34
+ name: node.URIDictionary?.title ?? node.Title ?? node.URLString,
35
+ url: node.URLString,
36
+ readingList: isReadingList,
37
+ };
38
+ }
39
+ return {
40
+ type: 'folder',
41
+ name: isReadingList ? 'Reading List' : (node.Title ?? ''),
42
+ children: (node.Children ?? [])
43
+ .map((child) => convert(child, isReadingList))
44
+ .filter(Boolean),
45
+ };
46
+ }
47
+ return (document.Children ?? []).map((node) => convert(node)).filter(Boolean);
48
+ }
49
+
50
+ /** All visits, in Unix microseconds; the source is read through online backup. */
51
+ export function readSafariHistory(filename, domains) {
52
+ return withSafariAccess(filename, async () => {
53
+ // Surface an OS access error before SQLite wraps it as SQLITE_CANTOPEN.
54
+ const file = await open(filename, 'r');
55
+ await file.close();
56
+ return readDatabaseSnapshot({
57
+ sourcePath: filename,
58
+ read: (db) =>
59
+ db
60
+ .prepare(
61
+ `SELECT i.url, v.title, v.visit_time FROM history_items i
62
+ JOIN history_visits v ON i.id = v.history_item ORDER BY v.visit_time, v.id`
63
+ )
64
+ .all()
65
+ .filter((row) => matchesDomains(row.url, domains))
66
+ .map((row) => ({
67
+ url: row.url,
68
+ title: row.title ?? '',
69
+ time: Math.round((row.visit_time + 978307200) * 1000000),
70
+ })),
71
+ });
72
+ });
73
+ }
74
+
75
+ /** Only an explicitly supplied Safari/Passwords export is read. */
76
+ export async function readSafariPasswords(filename, domains) {
77
+ const rows = parse(await readFile(filename), {
78
+ columns: (columns) => {
79
+ if (
80
+ !['URL', 'Username', 'Password'].every((column) =>
81
+ columns.includes(column)
82
+ )
83
+ ) {
84
+ throw new TypeError(
85
+ 'Safari password CSV must have URL, Username and Password columns'
86
+ );
87
+ }
88
+ return columns;
89
+ },
90
+ bom: true,
91
+ skip_empty_lines: true,
92
+ });
93
+ return rows
94
+ .filter((row) => row.URL && matchesDomains(row.URL, domains))
95
+ .map((row) => {
96
+ if (
97
+ typeof row.Username !== 'string' ||
98
+ typeof row.Password !== 'string'
99
+ ) {
100
+ throw new TypeError(
101
+ 'Safari password CSV must have URL, Username and Password columns'
102
+ );
103
+ }
104
+ return {
105
+ origin: row.URL,
106
+ username: row.Username,
107
+ password: row.Password,
108
+ };
109
+ });
110
+ }
@@ -1,4 +1,4 @@
1
- import { copyFile, mkdtemp, rm } from 'node:fs/promises';
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { pathExists } from './fs-utils.js';
@@ -11,49 +11,12 @@ import { openSqliteDatabase } from '../browser-cookie-database.js';
11
11
  * Read a Chromium SQLite database (History, Top Sites, Login Data, Web Data)
12
12
  * while the source browser is running, without ever writing to the source.
13
13
  *
14
- * Chrome keeps these databases open in WAL mode and, on Windows, holds a share
15
- * lock that stops another process from opening the file at all. Two techniques
16
- * cover both cases:
17
- *
18
- * 1. The SQLite Online Backup API (`better-sqlite3`'s `.backup()`), which
19
- * copies a transactionally consistent snapshot even while the source is
20
- * being written. This is the same mechanism `browser-cookie-database.js`
21
- * relies on for a stable read, and SQLite documents it as the correct way
22
- * to snapshot a live database
23
- * (https://www.sqlite.org/backup.html).
24
- * 2. When the source cannot be opened at all (a Windows exclusive lock), the
25
- * file and its `-wal`/`-journal` sidecars are copied to a temporary
26
- * directory and the copy is opened instead. Copying the sidecars keeps the
27
- * committed-but-not-checkpointed pages, so the copy is consistent.
28
- *
29
- * Both paths open the source with `readOnly`, so nothing is written back.
14
+ * SQLite online backup includes committed WAL data without writing to the
15
+ * source. If a sharing/exclusive lock prevents backup, callers receive guidance
16
+ * to close the browser or supply a consistent snapshot. A sequential copy of a
17
+ * live database and its sidecars cannot guarantee consistency.
30
18
  */
31
19
 
32
- const SQLITE_SIDECARS = ['-wal', '-shm', '-journal'];
33
-
34
- /**
35
- * Copy a SQLite file and its sidecars into a temporary directory.
36
- *
37
- * @param {string} sourcePath
38
- * @returns {Promise<{snapshotPath: string, cleanup: function(): Promise<void>}>}
39
- */
40
- async function copyDatabaseFiles(sourcePath) {
41
- const dir = await mkdtemp(path.join(os.tmpdir(), 'browser-commander-snap-'));
42
- const base = path.basename(sourcePath);
43
- const snapshotPath = path.join(dir, base);
44
- await copyFile(sourcePath, snapshotPath);
45
- for (const suffix of SQLITE_SIDECARS) {
46
- const sidecar = `${sourcePath}${suffix}`;
47
- if (await pathExists(sidecar)) {
48
- await copyFile(sidecar, `${snapshotPath}${suffix}`);
49
- }
50
- }
51
- return {
52
- snapshotPath,
53
- cleanup: () => rm(dir, { recursive: true, force: true, maxRetries: 3 }),
54
- };
55
- }
56
-
57
20
  /**
58
21
  * Produce a consistent, read-only snapshot copy of a live Chromium database in
59
22
  * a temporary directory, then hand its path to a reader. The snapshot is always
@@ -74,7 +37,6 @@ export async function withDatabaseSnapshot({ sourcePath, read }) {
74
37
  const cleanupDir = () =>
75
38
  rm(dir, { recursive: true, force: true, maxRetries: 3 });
76
39
 
77
- let copyFallback;
78
40
  try {
79
41
  // The backup API needs to open the source. It opens read-only, so nothing
80
42
  // is written to the user's real database.
@@ -83,31 +45,35 @@ export async function withDatabaseSnapshot({ sourcePath, read }) {
83
45
  source = new BetterSqlite3(sourcePath, {
84
46
  readonly: true,
85
47
  fileMustExist: true,
48
+ timeout: 1000,
86
49
  });
87
- } catch {
88
- source = null;
89
- }
90
- if (source) {
91
- try {
92
- await source.backup(snapshotPath);
93
- } finally {
94
- source.close();
95
- }
96
- } else {
97
- // The source is locked exclusively (Windows); fall back to copying the
98
- // file and its sidecars, then read that copy.
99
- await cleanupDir();
100
- copyFallback = await copyDatabaseFiles(sourcePath);
50
+ // Detect an exclusive lock before better-sqlite3's initial zero-page
51
+ // transfer can mistake SQLITE_BUSY for an empty completed backup.
52
+ source.pragma('schema_version');
53
+ let remaining = Infinity;
54
+ let lastProgress = Date.now();
55
+ await source.backup(snapshotPath, {
56
+ progress: ({ remainingPages }) => {
57
+ if (remainingPages < remaining) {
58
+ lastProgress = Date.now();
59
+ } else if (Date.now() - lastProgress >= 1000) {
60
+ throw new Error('SQLite backup made no progress');
61
+ }
62
+ remaining = remainingPages;
63
+ return 256;
64
+ },
65
+ });
66
+ } catch (cause) {
67
+ throw new Error(
68
+ `Consistent SQLite snapshot unavailable for ${sourcePath}; close the source browser and retry, or supply a consistent read-only snapshot. ${cause.message}`,
69
+ { cause }
70
+ );
71
+ } finally {
72
+ source?.close();
101
73
  }
102
-
103
- const readPath = copyFallback?.snapshotPath ?? snapshotPath;
104
- return await read(readPath);
74
+ return await read(snapshotPath);
105
75
  } finally {
106
- if (copyFallback) {
107
- await copyFallback.cleanup();
108
- } else {
109
- await cleanupDir();
110
- }
76
+ await cleanupDir();
111
77
  }
112
78
  }
113
79
 
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import { physicalPath } from '../browser-profile-files.js';
3
+ import { browserFamily } from '../browser-sources.js';
4
+ import { assertDedicatedUserDataDir } from '../system-browser.js';
5
+ import { validateDomains } from './domains.js';
6
+
7
+ export function validateMigrationOptions({
8
+ include,
9
+ domains,
10
+ targetBrowser,
11
+ to,
12
+ platform,
13
+ homeDir,
14
+ environment,
15
+ classes,
16
+ includePaymentCards = false,
17
+ }) {
18
+ if (typeof includePaymentCards !== 'boolean') {
19
+ throw new TypeError('includePaymentCards must be a boolean');
20
+ }
21
+ if (
22
+ !Array.isArray(include) ||
23
+ include.some((type) => !classes.includes(type))
24
+ ) {
25
+ throw new TypeError(
26
+ `include must contain supported data classes: ${classes.join(', ')}`
27
+ );
28
+ }
29
+ validateDomains(domains);
30
+ if (targetBrowser && browserFamily(targetBrowser) !== 'chromium') {
31
+ throw new TypeError(
32
+ `Migration target ${targetBrowser} does not yet have a supported target file writer`
33
+ );
34
+ }
35
+ assertDedicatedUserDataDir(to, { platform, homeDir, environment });
36
+ }
37
+
38
+ export function validateMigrationPaths(source, target) {
39
+ const sourcePath = physicalPath(source);
40
+ const targetPath = physicalPath(target);
41
+ const within = (parent, child) =>
42
+ child === parent || child.startsWith(`${parent}${path.sep}`);
43
+ if (within(sourcePath, targetPath) || within(targetPath, sourcePath)) {
44
+ throw new TypeError(
45
+ 'Migration source and target directories must not overlap'
46
+ );
47
+ }
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { findBrowserSource } from './browser-sources.js';
3
4
 
4
5
  import { startProcess } from '../utilities/subprocess.js';
5
6
  import {
@@ -259,6 +260,10 @@ async function waitForExit(browserProcess, timeout) {
259
260
  }
260
261
 
261
262
  async function requestBrowserClose({ engine, browser, originalClose }) {
263
+ if (engine === 'selenium') {
264
+ await originalClose();
265
+ return;
266
+ }
262
267
  if (engine === 'puppeteer') {
263
268
  // A connected Puppeteer Browser sends Browser.close and waits for it.
264
269
  await originalClose.call(browser);
@@ -278,8 +283,9 @@ function createCloser({
278
283
  userDataDir,
279
284
  temporaryProfile,
280
285
  closeTimeout,
286
+ closeConnection,
281
287
  }) {
282
- const originalClose = browser?.close;
288
+ const originalClose = closeConnection ?? browser?.close;
283
289
  let closing;
284
290
  const close = () => {
285
291
  closing ??= (async () => {
@@ -298,7 +304,7 @@ function createCloser({
298
304
  })();
299
305
  return closing;
300
306
  };
301
- if (browser && originalClose) {
307
+ if (browser && originalClose && engine !== 'selenium') {
302
308
  // `browser.close()` means "close the browser" for every caller, so it
303
309
  // shuts the spawned process down and deletes a temporary profile too.
304
310
  browser.close = close;
@@ -403,12 +409,19 @@ async function spawnOnFreePort({
403
409
  /** Reject a launch request before anything touches the disk. */
404
410
  function validateLaunchRequest({
405
411
  argOptions,
412
+ channel,
406
413
  userDataDir,
407
414
  remoteDebuggingPort,
408
415
  endpoint,
409
416
  attach,
410
417
  migrateFrom,
411
418
  }) {
419
+ const source = findBrowserSource(channel);
420
+ if (source && source.family !== 'chromium') {
421
+ throw new Error(
422
+ `${channel} does not support CDP; use its documented WebDriver setup when available`
423
+ );
424
+ }
412
425
  if (endpoint) {
413
426
  throw new Error(
414
427
  'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint'
@@ -502,13 +515,16 @@ async function runPreLaunchMigration({
502
515
  if (!migrateFrom) {
503
516
  return { migration: undefined, migratedCookies: [] };
504
517
  }
505
- const { include, domains, ...from } = migrateFrom;
518
+ const { include, domains, passwordCsv, includePaymentCards, ...from } =
519
+ migrateFrom;
506
520
  try {
507
521
  const report = await migrate({
508
522
  from,
509
523
  to: path.join(userDataDir, 'Default'),
510
524
  include,
511
525
  domains,
526
+ passwordCsv,
527
+ includePaymentCards,
512
528
  targetBrowser: channel,
513
529
  });
514
530
  const { cookies, ...migration } = report;
@@ -583,6 +599,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
583
599
  };
584
600
  validateLaunchRequest({
585
601
  argOptions,
602
+ channel,
586
603
  userDataDir: requestedUserDataDir,
587
604
  remoteDebuggingPort,
588
605
  endpoint: cdpEndpoint || wsEndpoint,
@@ -670,6 +687,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
670
687
  userDataDir,
671
688
  temporaryProfile,
672
689
  closeTimeout,
690
+ closeConnection: engine === 'selenium' ? connection.close : undefined,
673
691
  });
674
692
  return {
675
693
  ...connection,
@@ -0,0 +1,45 @@
1
+ import { stat } from 'node:fs/promises';
2
+
3
+ /** A protected path remains a source, so reads can explain Full Disk Access. */
4
+ export async function findSafariFile(candidates, statFile = stat) {
5
+ for (const candidate of candidates) {
6
+ try {
7
+ if ((await statFile(candidate)).isFile()) {
8
+ return candidate;
9
+ }
10
+ } catch (error) {
11
+ if (['EPERM', 'EACCES'].includes(error.code)) {
12
+ return candidate;
13
+ }
14
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
15
+ throw error;
16
+ }
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ /** Enrich protection failures for every Safari store, preserving the cause. */
23
+ export async function withSafariAccess(
24
+ filename,
25
+ read,
26
+ environment = process.env
27
+ ) {
28
+ try {
29
+ return await read();
30
+ } catch (error) {
31
+ if (!['EPERM', 'EACCES'].includes(error.code)) {
32
+ throw error;
33
+ }
34
+ const app =
35
+ environment.__CFBundleIdentifier ||
36
+ environment.TERM_PROGRAM ||
37
+ process.execPath;
38
+ const diagnostic = new Error(
39
+ `Safari access denied (${error.code}) at ${filename}. Grant Full Disk Access to ${app}, the app running Browser Commander, then retry: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
40
+ { cause: error }
41
+ );
42
+ diagnostic.code = error.code;
43
+ throw diagnostic;
44
+ }
45
+ }
@@ -4,7 +4,9 @@
4
4
  */
5
5
  import { readFile as readFileBytes, stat } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
+ import { findSafariFile, withSafariAccess } from './safari-access.js';
7
8
  import { TextDecoder } from 'node:util';
9
+ import { matchesDomains } from './migration/domains.js';
8
10
 
9
11
  const APPLE_EPOCH = 978_307_200;
10
12
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
@@ -121,7 +123,7 @@ export function countSafariCookies(data, domains) {
121
123
  const host = string(record, 16).toLowerCase();
122
124
  total += 1;
123
125
  for (const domain of Object.keys(byDomain ?? {})) {
124
- if (host.includes(domain.toLowerCase())) {
126
+ if (matchesDomains(host, [domain])) {
125
127
  byDomain[domain] += 1;
126
128
  }
127
129
  }
@@ -130,44 +132,45 @@ export function countSafariCookies(data, domains) {
130
132
  }
131
133
 
132
134
  /** 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;
135
+ export function findSafariCookieFile(profileDir, statFile = stat) {
136
+ const named =
137
+ path.basename(path.dirname(profileDir)) === 'Profiles' &&
138
+ path.basename(path.dirname(path.dirname(profileDir))) === 'Safari';
139
+ const root = named
140
+ ? path.dirname(path.dirname(path.dirname(profileDir)))
141
+ : profileDir;
142
+ const modern = named
143
+ ? [
144
+ path.join(
145
+ root,
146
+ 'WebKit/WebsiteDataStore',
147
+ path.basename(profileDir).toLowerCase(),
148
+ 'Cookies/Cookies.binarycookies'
149
+ ),
150
+ ]
151
+ : [
152
+ path.join(
153
+ root,
154
+ 'WebKit/WebsiteData/Default/Cookies/Cookies.binarycookies'
155
+ ),
156
+ path.join(
157
+ root,
158
+ 'WebKit/WebsiteDataStore/Default/Cookies/Cookies.binarycookies'
159
+ ),
160
+ ];
161
+ return findSafariFile(
162
+ [
163
+ ...modern,
164
+ path.join(profileDir, 'Cookies', 'Cookies.binarycookies'),
165
+ path.join(profileDir, 'Cookies.binarycookies'),
166
+ ],
167
+ statFile
168
+ );
152
169
  }
153
170
 
154
- export async function readSafariCookieFile(
171
+ export function readSafariCookieFile(
155
172
  filePath,
156
173
  { environment = process.env, readFile = readFileBytes } = {}
157
174
  ) {
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
- }
175
+ return withSafariAccess(filePath, () => readFile(filePath), environment);
173
176
  }