browser-commander 0.21.2 → 0.23.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.
@@ -19,6 +19,7 @@
19
19
  "0fd23be8410c2c95": 1,
20
20
  "0fe4f3b5e5e939b3": 1,
21
21
  "1019ebbe7f07a5ad": 1,
22
+ "119f429fe4d23e4a": 1,
22
23
  "1273365073edd790": 1,
23
24
  "1296adc3c3128048": 1,
24
25
  "13c4b58d3ea0719b": 1,
@@ -54,11 +55,11 @@
54
55
  "2e70be24d2268673": 1,
55
56
  "2f4c9810eae87d6f": 1,
56
57
  "2f9e15467cc2f12a": 1,
58
+ "30da9d4e83f1c939": 1,
57
59
  "3101802d3ea65339": 1,
58
60
  "33357ce94e07cc4b": 1,
59
61
  "35cf2769645f3234": 1,
60
62
  "37bd4114d0089f49": 1,
61
- "389df6fb4b8315a1": 1,
62
63
  "39eaada6d1879f4a": 1,
63
64
  "3a9f1672ecc1d55b": 1,
64
65
  "3adca52a3f04837c": 1,
@@ -77,6 +78,7 @@
77
78
  "4c3b94caa28045d0": 1,
78
79
  "4e090d6bb30e6551": 1,
79
80
  "4f3c541b3f3e3f23": 1,
81
+ "4f93b074bc2ac9e8": 1,
80
82
  "5085765e1f103f3e": 1,
81
83
  "5184c90c9f8406c3": 1,
82
84
  "520a28ee2ced56c2": 1,
@@ -153,11 +155,9 @@
153
155
  "92ddafa7e65be7eb": 1,
154
156
  "94821a4d1b745dfd": 1,
155
157
  "97c5a112fdde7be8": 1,
156
- "98cc22079feaa004": 1,
157
158
  "990d87f21678fa96": 1,
158
159
  "9a72b1c2f22156ca": 1,
159
160
  "9b6ef4df04562546": 1,
160
- "9c055dfd90c369bf": 1,
161
161
  "9ee57dcd5e7a6691": 1,
162
162
  "9fbb8296259dedbe": 1,
163
163
  "a0183465782b0b15": 1,
@@ -167,11 +167,11 @@
167
167
  "a305c6cac16f3af5": 1,
168
168
  "a4c8498d4f2d2191": 1,
169
169
  "a6ad7ab6c05656dc": 1,
170
+ "a6ffab1aacd00af6": 1,
170
171
  "a78d76908162699e": 1,
171
172
  "a9bd4ffef6da5faf": 1,
172
173
  "a9d71c2e50f1b832": 1,
173
174
  "acc95d694eb2f581": 1,
174
- "ad4469f2e8131535": 1,
175
175
  "ad4cfa76daa1b6fc": 1,
176
176
  "ad50b48938af8aef": 1,
177
177
  "adae1329361a81a1": 1,
@@ -190,7 +190,6 @@
190
190
  "bb269582b94ed42d": 1,
191
191
  "bb3e6d0f7e7edccd": 1,
192
192
  "bc9115be3a462930": 1,
193
- "bccf0d5cc57c08df": 1,
194
193
  "bd0ae42c13d5b4f9": 1,
195
194
  "bd6c62a6e1c6a071": 1,
196
195
  "bdb8ca169be36b80": 1,
@@ -207,10 +206,10 @@
207
206
  "c9cf3c28bed33a5b": 1,
208
207
  "cab590a1d04b5a05": 1,
209
208
  "cbd444e9a6a9072e": 1,
210
- "cd49f39b43d14635": 1,
211
209
  "cebff7d1cc87f646": 1,
212
210
  "cf6116d195199f8a": 1,
213
211
  "cfd68135b744c27b": 1,
212
+ "d016035b3bf3340c": 1,
214
213
  "d097f41a6f43e634": 1,
215
214
  "d0da8714c4b7de30": 1,
216
215
  "d17201496391f6cc": 1,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b56775b: Add Safari and Safari Technology Preview cookie sources, including installed,
8
+ default/auto and custom-directory import, counts-only domain discovery, validated
9
+ binarycookies decoding and actionable Full Disk Access errors. Migration reports
10
+ unsupported Safari classes and the SameSite Lax fallback. Other Safari stores,
11
+ Firefox/WebKit targets, full clones and additional data classes remain tracked in
12
+ issues #117–#120; this release does not claim full Safari profile import.
13
+
14
+ ## 0.22.0
15
+
16
+ ### Minor Changes
17
+
18
+ - f4ce84c: Add a shared catalogue of importable browsers and resolve the system default
19
+ (#114).
20
+
21
+ - `browser-sources.json` is a shared, data-driven catalogue of the browsers
22
+ Browser Commander can import from — Chrome and its Beta/Dev/Canary channels,
23
+ Edge channels, Brave variants, Chromium, Opera and Opera GX, Vivaldi, Arc,
24
+ Yandex, and Firefox with its LibreWolf, Waterfox, Zen, Floorp, Developer
25
+ Edition and Nightly forks. It records each browser's per-platform profile
26
+ roots, Chromium Safe Storage identity, and operating-system default
27
+ identifiers, and ships byte-identical to the Python and Rust packages.
28
+ - `browser: 'default'` (or `'auto'`) now resolves the operating-system default
29
+ web browser (macOS LaunchServices, Linux `xdg-settings`/`xdg-mime`, Windows
30
+ `UserChoice` ProgId) to a catalogue id, so an import follows whichever
31
+ browser a person actually uses.
32
+ - Profile discovery and migration classify a browser's engine family from the
33
+ catalogue instead of a hard-coded list, so every catalogued Chromium variant
34
+ and Firefox fork is recognised, and reading cookies honours a custom
35
+ `userDataDir`.
36
+ - New `cookies sources` and `profile sources` commands (and the
37
+ `cookies.sources`/`profile.sources` JSON-RPC methods) report which browsers
38
+ and profiles hold cookies — optionally for specific `--domain`s — as names
39
+ and counts only, never values.
40
+ - A `default`/`auto` migration scoped to `domains` falls back from a default
41
+ browser that holds no cookies for them to the installed profile holding the
42
+ most, and reports which one with a `default-browser-fallback` warning
43
+ (`resolveImportSource`). Migrating from Opera or Opera GX now reads their
44
+ single profile from the user data directory itself.
45
+
3
46
  ## 0.21.2
4
47
 
5
48
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.21.2",
3
+ "version": "0.23.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,29 +1,20 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
 
3
3
  import { runCommand as runSubprocess } from '../utilities/subprocess.js';
4
+ import { safeStorageIdentity } from './browser-sources.js';
4
5
 
5
- const SAFE_STORAGE = {
6
- brave: {
7
- application: 'brave',
8
- folder: 'Brave Keys',
9
- service: 'Brave Safe Storage',
10
- },
11
- chrome: {
12
- application: 'chrome',
13
- folder: 'Chrome Keys',
14
- service: 'Chrome Safe Storage',
15
- },
16
- chromium: {
17
- application: 'chromium',
18
- folder: 'Chromium Keys',
19
- service: 'Chromium Safe Storage',
20
- },
21
- edge: {
22
- application: 'microsoft-edge',
23
- folder: 'Microsoft Edge Keys',
24
- service: 'Microsoft Edge Safe Storage',
25
- },
26
- };
6
+ /**
7
+ * The Chromium Safe Storage identity for a browser, from the shared
8
+ * `browser-sources.json` catalogue. Firefox-family browsers return
9
+ * `undefined` because they do not use OSCrypt.
10
+ */
11
+ function safeStorageFor(browser) {
12
+ try {
13
+ return safeStorageIdentity(browser);
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
27
18
 
28
19
  /**
29
20
  * Run a credential tool (`security`, `secret-tool`, `kwallet-query`,
@@ -35,7 +26,7 @@ async function runCredentialCommand(command, args, environment) {
35
26
  }
36
27
 
37
28
  async function readLinuxSafeStoragePassword(browser, environment, runCommand) {
38
- const identity = SAFE_STORAGE[browser];
29
+ const identity = safeStorageFor(browser);
39
30
  try {
40
31
  const password = await runCommand(
41
32
  'secret-tool',
@@ -72,7 +63,7 @@ export async function readSafeStoragePassword({
72
63
  environment = process.env,
73
64
  runCredentialCommand: runCommand = runCredentialCommand,
74
65
  }) {
75
- const identity = SAFE_STORAGE[browser];
66
+ const identity = safeStorageFor(browser);
76
67
  if (!identity) {
77
68
  throw new Error(`No Safe Storage identity is known for ${browser}`);
78
69
  }
@@ -24,12 +24,20 @@ import {
24
24
  openSqliteDatabase,
25
25
  preserveIntegerPrecision,
26
26
  } from './browser-cookie-database.js';
27
+ import { browserFamily } from './browser-sources.js';
27
28
  import {
28
29
  findCookieDatabase,
30
+ isDefaultBrowserKeyword,
29
31
  listBrowserProfiles,
30
- normalizeCookieBrowser,
31
32
  resolveBrowserProfile,
33
+ resolveSourceBrowser,
32
34
  } from './browser-profiles.js';
35
+ import { resolveDefaultBrowser } from './default-browser.js';
36
+ import {
37
+ countSafariCookies,
38
+ parseSafariCookies,
39
+ readSafariCookieFile,
40
+ } from './safari-cookies.js';
33
41
 
34
42
  const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
35
43
  const MICROSECONDS_PER_SECOND = 1_000_000n;
@@ -266,20 +274,33 @@ export async function readBrowserCookiesWithDependencies(
266
274
  if (!options || typeof options !== 'object') {
267
275
  throw new TypeError('readBrowserCookies requires an options object');
268
276
  }
269
- const browser = normalizeCookieBrowser(options.browser);
270
277
  const platform = dependencies.platform ?? process.platform;
271
278
  const homeDir = dependencies.homeDir ?? os.homedir();
272
279
  const environment = dependencies.environment ?? process.env;
273
- const profile = await resolveBrowserProfile({
274
- browser,
275
- profile: options.profile,
280
+ const runCommand = dependencies.runCommand;
281
+ const browser = await resolveSourceBrowser(options.browser, {
276
282
  platform,
277
- homeDir,
278
283
  environment,
284
+ runCommand,
279
285
  });
280
- const cookiePath = await findCookieDatabase(browser, profile.path, platform);
286
+ // A caller that already resolved the profile directory (for example a
287
+ // migration honouring a custom `userDataDir`) passes it as `profileDir`, so
288
+ // the reader does not re-resolve the default profile location.
289
+ const profilePath = options.profileDir
290
+ ? options.profileDir
291
+ : (
292
+ await resolveBrowserProfile({
293
+ browser,
294
+ profile: options.profile,
295
+ platform,
296
+ homeDir,
297
+ environment,
298
+ runCommand,
299
+ })
300
+ ).path;
301
+ const cookiePath = await findCookieDatabase(browser, profilePath, platform);
281
302
  if (!cookiePath) {
282
- throw new Error(`No cookie database exists in ${profile.path}`);
303
+ throw new Error(`No cookie database exists in ${profilePath}`);
283
304
  }
284
305
  const cache = normalizeCookieCache(
285
306
  options.cache,
@@ -288,7 +309,7 @@ export async function readBrowserCookiesWithDependencies(
288
309
  );
289
310
  const identity = JSON.stringify({
290
311
  browser,
291
- profile: profile.path,
312
+ profile: profilePath,
292
313
  domainFilter: options.domainFilter ?? null,
293
314
  ignoreDecryptionErrors: options.ignoreDecryptionErrors === true,
294
315
  });
@@ -303,10 +324,18 @@ export async function readBrowserCookiesWithDependencies(
303
324
  return cachedCookies;
304
325
  }
305
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
+ }
306
335
  const database = await openCookieDatabase(cookiePath);
307
336
  let cookies;
308
337
  try {
309
- if (browser === 'firefox') {
338
+ if (browserFamily(browser) === 'firefox') {
310
339
  cookies = mapFirefoxCookieRows(
311
340
  readFirefoxRows(database, options.domainFilter)
312
341
  );
@@ -323,7 +352,7 @@ export async function readBrowserCookiesWithDependencies(
323
352
  ignoreDecryptionErrors: options.ignoreDecryptionErrors === true,
324
353
  now,
325
354
  platform,
326
- profile,
355
+ profile: { path: profilePath },
327
356
  readSafeStoragePassword:
328
357
  dependencies.readSafeStoragePassword ?? readSafeStoragePassword,
329
358
  readWindowsEncryptionKey:
@@ -343,6 +372,217 @@ export function readBrowserCookies(options) {
343
372
  return readBrowserCookiesWithDependencies(options);
344
373
  }
345
374
 
375
+ /**
376
+ * Count cookies in a database by domain, without ever reading a cookie value.
377
+ * Only host names and row counts are touched, so this is safe to expose for a
378
+ * "which browser holds cookies for this domain" listing.
379
+ */
380
+ function countCookiesByDomain(database, family, domains) {
381
+ const column = family === 'firefox' ? 'host' : 'host_key';
382
+ const table = family === 'firefox' ? 'moz_cookies' : 'cookies';
383
+ const countFor = (filter) => {
384
+ const query = filter
385
+ ? `SELECT COUNT(*) AS n FROM ${table} WHERE ${column} LIKE ?`
386
+ : `SELECT COUNT(*) AS n FROM ${table}`;
387
+ const statement = database.prepare(query);
388
+ const row = filter ? statement.get(`%${filter}%`) : statement.get();
389
+ return Number(row?.n ?? 0);
390
+ };
391
+ const total = countFor(null);
392
+ if (!Array.isArray(domains) || domains.length === 0) {
393
+ return { total, byDomain: null };
394
+ }
395
+ const byDomain = {};
396
+ for (const domain of domains) {
397
+ byDomain[domain] = countFor(domain);
398
+ }
399
+ return { total, byDomain };
400
+ }
401
+
402
+ /**
403
+ * List the installed browser profiles that hold cookies, with per-domain
404
+ * counts when `domains` is given. Values are never read or returned — this is
405
+ * the data behind the `cookies sources` command.
406
+ *
407
+ * @param {Object} [options]
408
+ * @param {string[]} [options.domains] - Restrict and count by these domains
409
+ * @param {string} [options.platform=process.platform]
410
+ * @param {string} [options.homeDir=os.homedir()]
411
+ * @param {Object} [options.environment=process.env]
412
+ * @returns {Promise<Array<Object>>} One entry per profile:
413
+ * `{browser, profile, path, isDefault, cookies, byDomain}`, plus `error`
414
+ * when the database could not be read
415
+ */
416
+ export async function listCookieSources({
417
+ domains,
418
+ platform = process.platform,
419
+ homeDir = os.homedir(),
420
+ environment = process.env,
421
+ } = {}) {
422
+ const profiles = await listBrowserProfiles({
423
+ platform,
424
+ homeDir,
425
+ environment,
426
+ });
427
+ const sources = [];
428
+ for (const profile of profiles) {
429
+ const cookiePath = await findCookieDatabase(
430
+ profile.browser,
431
+ profile.path,
432
+ platform
433
+ );
434
+ if (!cookiePath) {
435
+ continue;
436
+ }
437
+ const family = browserFamily(profile.browser);
438
+ let counts;
439
+ let error;
440
+ let database;
441
+ try {
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
+ }
451
+ } catch (openError) {
452
+ error = openError.message;
453
+ } finally {
454
+ database?.close();
455
+ }
456
+ if (error) {
457
+ sources.push({
458
+ browser: profile.browser,
459
+ profile: profile.name,
460
+ path: profile.path,
461
+ isDefault: profile.isDefault,
462
+ error,
463
+ });
464
+ continue;
465
+ }
466
+ // When filtering by domain, skip profiles that hold none of them.
467
+ const matchedCount = counts.byDomain
468
+ ? Object.values(counts.byDomain).reduce((sum, n) => sum + n, 0)
469
+ : counts.total;
470
+ if (Array.isArray(domains) && domains.length > 0 && matchedCount === 0) {
471
+ continue;
472
+ }
473
+ sources.push({
474
+ browser: profile.browser,
475
+ profile: profile.name,
476
+ path: profile.path,
477
+ isDefault: profile.isDefault,
478
+ cookies: counts.total,
479
+ byDomain: counts.byDomain,
480
+ });
481
+ }
482
+ return sources;
483
+ }
484
+
485
+ function matchedCookies(source) {
486
+ return Object.values(source.byDomain ?? {}).reduce((sum, n) => sum + n, 0);
487
+ }
488
+
489
+ /**
490
+ * Pick the source browser for an import. A `default`/`auto` request scoped to
491
+ * `domains` uses the system default browser when it holds cookies for them
492
+ * and otherwise falls back to the installed browser profile holding the most,
493
+ * so "import my github.com sign-in" works whichever browser has it. Only
494
+ * names and counts are read (see `listCookieSources`), never cookie values.
495
+ *
496
+ * @param {Object} options
497
+ * @param {string} options.browser - Catalogue id, `default` or `auto`
498
+ * @param {string[]} [options.domains]
499
+ * @param {string} [options.platform=process.platform]
500
+ * @param {string} [options.homeDir=os.homedir()]
501
+ * @param {Object} [options.environment=process.env]
502
+ * @param {Function} [options.runCommand] - Injected for default-browser lookup
503
+ * @returns {Promise<Object>} `{browser, profile, warning}`: `profile` is the
504
+ * profile holding the cookies (when one was chosen) and `warning` is a
505
+ * migration-report warning explaining a fallback away from the system
506
+ * default (reason `default-browser-fallback` or `default-browser-unknown`),
507
+ * or `null`
508
+ */
509
+ export async function resolveImportSource({
510
+ browser,
511
+ domains,
512
+ platform = process.platform,
513
+ homeDir = os.homedir(),
514
+ environment = process.env,
515
+ runCommand,
516
+ }) {
517
+ if (
518
+ !isDefaultBrowserKeyword(browser) ||
519
+ !Array.isArray(domains) ||
520
+ domains.length === 0
521
+ ) {
522
+ return {
523
+ browser: await resolveSourceBrowser(browser, {
524
+ platform,
525
+ environment,
526
+ runCommand,
527
+ }),
528
+ profile: undefined,
529
+ warning: null,
530
+ };
531
+ }
532
+ const systemDefault = await resolveDefaultBrowser({
533
+ platform,
534
+ environment,
535
+ runCommand,
536
+ });
537
+ const listedSources = await listCookieSources({
538
+ domains,
539
+ platform,
540
+ homeDir,
541
+ environment,
542
+ });
543
+ const holders = listedSources.filter((source) => !source.error);
544
+ const fromDefault = holders.filter(
545
+ (source) => source.browser === systemDefault
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
+ }
555
+ const candidates = fromDefault.length > 0 ? fromDefault : holders;
556
+ if (candidates.length === 0) {
557
+ if (!systemDefault) {
558
+ throw new Error(
559
+ `Could not determine the system default browser, and no installed browser holds cookies for ${domains.join(', ')}.`
560
+ );
561
+ }
562
+ return { browser: systemDefault, profile: undefined, warning: null };
563
+ }
564
+ // The first profile with the most matching cookies; listing order breaks
565
+ // ties, so a browser's default profile wins over its others.
566
+ const best = candidates.reduce((chosen, source) =>
567
+ matchedCookies(source) > matchedCookies(chosen) ? source : chosen
568
+ );
569
+ let warning = null;
570
+ if (best.browser !== systemDefault) {
571
+ const reason = systemDefault
572
+ ? `The default browser (${systemDefault}) holds no cookies for`
573
+ : 'Could not determine the default browser to read cookies for';
574
+ warning = {
575
+ type: 'source',
576
+ item: best.browser,
577
+ reason: systemDefault
578
+ ? 'default-browser-fallback'
579
+ : 'default-browser-unknown',
580
+ detail: `${reason} ${domains.join(', ')}; imported from ${best.browser} instead.`,
581
+ };
582
+ }
583
+ return { browser: best.browser, profile: best.profile, warning };
584
+ }
585
+
346
586
  export {
347
587
  clearBrowserCookieMemoryCache,
348
588
  decryptChromiumCookie,