browser-commander 0.21.2 → 0.22.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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f4ce84c: Add a shared catalogue of importable browsers and resolve the system default
8
+ (#114).
9
+
10
+ - `browser-sources.json` is a shared, data-driven catalogue of the browsers
11
+ Browser Commander can import from — Chrome and its Beta/Dev/Canary channels,
12
+ Edge channels, Brave variants, Chromium, Opera and Opera GX, Vivaldi, Arc,
13
+ Yandex, and Firefox with its LibreWolf, Waterfox, Zen, Floorp, Developer
14
+ Edition and Nightly forks. It records each browser's per-platform profile
15
+ roots, Chromium Safe Storage identity, and operating-system default
16
+ identifiers, and ships byte-identical to the Python and Rust packages.
17
+ - `browser: 'default'` (or `'auto'`) now resolves the operating-system default
18
+ web browser (macOS LaunchServices, Linux `xdg-settings`/`xdg-mime`, Windows
19
+ `UserChoice` ProgId) to a catalogue id, so an import follows whichever
20
+ browser a person actually uses.
21
+ - Profile discovery and migration classify a browser's engine family from the
22
+ catalogue instead of a hard-coded list, so every catalogued Chromium variant
23
+ and Firefox fork is recognised, and reading cookies honours a custom
24
+ `userDataDir`.
25
+ - New `cookies sources` and `profile sources` commands (and the
26
+ `cookies.sources`/`profile.sources` JSON-RPC methods) report which browsers
27
+ and profiles hold cookies — optionally for specific `--domain`s — as names
28
+ and counts only, never values.
29
+ - A `default`/`auto` migration scoped to `domains` falls back from a default
30
+ browser that holds no cookies for them to the installed profile holding the
31
+ most, and reports which one with a `default-browser-fallback` warning
32
+ (`resolveImportSource`). Migrating from Opera or Opera GX now reads their
33
+ single profile from the user data directory itself.
34
+
3
35
  ## 0.21.2
4
36
 
5
37
  ### 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.22.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,15 @@ 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';
33
36
 
34
37
  const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
35
38
  const MICROSECONDS_PER_SECOND = 1_000_000n;
@@ -266,20 +269,33 @@ export async function readBrowserCookiesWithDependencies(
266
269
  if (!options || typeof options !== 'object') {
267
270
  throw new TypeError('readBrowserCookies requires an options object');
268
271
  }
269
- const browser = normalizeCookieBrowser(options.browser);
270
272
  const platform = dependencies.platform ?? process.platform;
271
273
  const homeDir = dependencies.homeDir ?? os.homedir();
272
274
  const environment = dependencies.environment ?? process.env;
273
- const profile = await resolveBrowserProfile({
274
- browser,
275
- profile: options.profile,
275
+ const runCommand = dependencies.runCommand;
276
+ const browser = await resolveSourceBrowser(options.browser, {
276
277
  platform,
277
- homeDir,
278
278
  environment,
279
+ runCommand,
279
280
  });
280
- const cookiePath = await findCookieDatabase(browser, profile.path, platform);
281
+ // A caller that already resolved the profile directory (for example a
282
+ // migration honouring a custom `userDataDir`) passes it as `profileDir`, so
283
+ // the reader does not re-resolve the default profile location.
284
+ const profilePath = options.profileDir
285
+ ? options.profileDir
286
+ : (
287
+ await resolveBrowserProfile({
288
+ browser,
289
+ profile: options.profile,
290
+ platform,
291
+ homeDir,
292
+ environment,
293
+ runCommand,
294
+ })
295
+ ).path;
296
+ const cookiePath = await findCookieDatabase(browser, profilePath, platform);
281
297
  if (!cookiePath) {
282
- throw new Error(`No cookie database exists in ${profile.path}`);
298
+ throw new Error(`No cookie database exists in ${profilePath}`);
283
299
  }
284
300
  const cache = normalizeCookieCache(
285
301
  options.cache,
@@ -288,7 +304,7 @@ export async function readBrowserCookiesWithDependencies(
288
304
  );
289
305
  const identity = JSON.stringify({
290
306
  browser,
291
- profile: profile.path,
307
+ profile: profilePath,
292
308
  domainFilter: options.domainFilter ?? null,
293
309
  ignoreDecryptionErrors: options.ignoreDecryptionErrors === true,
294
310
  });
@@ -306,7 +322,7 @@ export async function readBrowserCookiesWithDependencies(
306
322
  const database = await openCookieDatabase(cookiePath);
307
323
  let cookies;
308
324
  try {
309
- if (browser === 'firefox') {
325
+ if (browserFamily(browser) === 'firefox') {
310
326
  cookies = mapFirefoxCookieRows(
311
327
  readFirefoxRows(database, options.domainFilter)
312
328
  );
@@ -323,7 +339,7 @@ export async function readBrowserCookiesWithDependencies(
323
339
  ignoreDecryptionErrors: options.ignoreDecryptionErrors === true,
324
340
  now,
325
341
  platform,
326
- profile,
342
+ profile: { path: profilePath },
327
343
  readSafeStoragePassword:
328
344
  dependencies.readSafeStoragePassword ?? readSafeStoragePassword,
329
345
  readWindowsEncryptionKey:
@@ -343,6 +359,198 @@ export function readBrowserCookies(options) {
343
359
  return readBrowserCookiesWithDependencies(options);
344
360
  }
345
361
 
362
+ /**
363
+ * Count cookies in a database by domain, without ever reading a cookie value.
364
+ * Only host names and row counts are touched, so this is safe to expose for a
365
+ * "which browser holds cookies for this domain" listing.
366
+ */
367
+ function countCookiesByDomain(database, family, domains) {
368
+ const column = family === 'firefox' ? 'host' : 'host_key';
369
+ const table = family === 'firefox' ? 'moz_cookies' : 'cookies';
370
+ const countFor = (filter) => {
371
+ const query = filter
372
+ ? `SELECT COUNT(*) AS n FROM ${table} WHERE ${column} LIKE ?`
373
+ : `SELECT COUNT(*) AS n FROM ${table}`;
374
+ const statement = database.prepare(query);
375
+ const row = filter ? statement.get(`%${filter}%`) : statement.get();
376
+ return Number(row?.n ?? 0);
377
+ };
378
+ const total = countFor(null);
379
+ if (!Array.isArray(domains) || domains.length === 0) {
380
+ return { total, byDomain: null };
381
+ }
382
+ const byDomain = {};
383
+ for (const domain of domains) {
384
+ byDomain[domain] = countFor(domain);
385
+ }
386
+ return { total, byDomain };
387
+ }
388
+
389
+ /**
390
+ * List the installed browser profiles that hold cookies, with per-domain
391
+ * counts when `domains` is given. Values are never read or returned — this is
392
+ * the data behind the `cookies sources` command.
393
+ *
394
+ * @param {Object} [options]
395
+ * @param {string[]} [options.domains] - Restrict and count by these domains
396
+ * @param {string} [options.platform=process.platform]
397
+ * @param {string} [options.homeDir=os.homedir()]
398
+ * @param {Object} [options.environment=process.env]
399
+ * @returns {Promise<Array<Object>>} One entry per profile:
400
+ * `{browser, profile, path, isDefault, cookies, byDomain}`, plus `error`
401
+ * when the database could not be read
402
+ */
403
+ export async function listCookieSources({
404
+ domains,
405
+ platform = process.platform,
406
+ homeDir = os.homedir(),
407
+ environment = process.env,
408
+ } = {}) {
409
+ const profiles = await listBrowserProfiles({
410
+ platform,
411
+ homeDir,
412
+ environment,
413
+ });
414
+ const sources = [];
415
+ for (const profile of profiles) {
416
+ const cookiePath = await findCookieDatabase(
417
+ profile.browser,
418
+ profile.path,
419
+ platform
420
+ );
421
+ if (!cookiePath) {
422
+ continue;
423
+ }
424
+ const family = browserFamily(profile.browser);
425
+ let counts;
426
+ let error;
427
+ let database;
428
+ try {
429
+ database = await openCookieDatabase(cookiePath);
430
+ counts = countCookiesByDomain(database, family, domains);
431
+ } catch (openError) {
432
+ error = openError.message;
433
+ } finally {
434
+ database?.close();
435
+ }
436
+ if (error) {
437
+ sources.push({
438
+ browser: profile.browser,
439
+ profile: profile.name,
440
+ path: profile.path,
441
+ isDefault: profile.isDefault,
442
+ error,
443
+ });
444
+ continue;
445
+ }
446
+ // When filtering by domain, skip profiles that hold none of them.
447
+ const matchedCount = counts.byDomain
448
+ ? Object.values(counts.byDomain).reduce((sum, n) => sum + n, 0)
449
+ : counts.total;
450
+ if (Array.isArray(domains) && domains.length > 0 && matchedCount === 0) {
451
+ continue;
452
+ }
453
+ sources.push({
454
+ browser: profile.browser,
455
+ profile: profile.name,
456
+ path: profile.path,
457
+ isDefault: profile.isDefault,
458
+ cookies: counts.total,
459
+ byDomain: counts.byDomain,
460
+ });
461
+ }
462
+ return sources;
463
+ }
464
+
465
+ function matchedCookies(source) {
466
+ return Object.values(source.byDomain ?? {}).reduce((sum, n) => sum + n, 0);
467
+ }
468
+
469
+ /**
470
+ * Pick the source browser for an import. A `default`/`auto` request scoped to
471
+ * `domains` uses the system default browser when it holds cookies for them
472
+ * and otherwise falls back to the installed browser profile holding the most,
473
+ * so "import my github.com sign-in" works whichever browser has it. Only
474
+ * names and counts are read (see `listCookieSources`), never cookie values.
475
+ *
476
+ * @param {Object} options
477
+ * @param {string} options.browser - Catalogue id, `default` or `auto`
478
+ * @param {string[]} [options.domains]
479
+ * @param {string} [options.platform=process.platform]
480
+ * @param {string} [options.homeDir=os.homedir()]
481
+ * @param {Object} [options.environment=process.env]
482
+ * @param {Function} [options.runCommand] - Injected for default-browser lookup
483
+ * @returns {Promise<Object>} `{browser, profile, warning}`: `profile` is the
484
+ * profile holding the cookies (when one was chosen) and `warning` is a
485
+ * migration-report warning explaining a fallback away from the system
486
+ * default (reason `default-browser-fallback` or `default-browser-unknown`),
487
+ * or `null`
488
+ */
489
+ export async function resolveImportSource({
490
+ browser,
491
+ domains,
492
+ platform = process.platform,
493
+ homeDir = os.homedir(),
494
+ environment = process.env,
495
+ runCommand,
496
+ }) {
497
+ if (
498
+ !isDefaultBrowserKeyword(browser) ||
499
+ !Array.isArray(domains) ||
500
+ domains.length === 0
501
+ ) {
502
+ return {
503
+ browser: await resolveSourceBrowser(browser, {
504
+ platform,
505
+ environment,
506
+ runCommand,
507
+ }),
508
+ profile: undefined,
509
+ warning: null,
510
+ };
511
+ }
512
+ const systemDefault = await resolveDefaultBrowser({
513
+ platform,
514
+ environment,
515
+ runCommand,
516
+ });
517
+ const holders = (
518
+ await listCookieSources({ domains, platform, homeDir, environment })
519
+ ).filter((source) => !source.error);
520
+ const fromDefault = holders.filter(
521
+ (source) => source.browser === systemDefault
522
+ );
523
+ const candidates = fromDefault.length > 0 ? fromDefault : holders;
524
+ if (candidates.length === 0) {
525
+ if (!systemDefault) {
526
+ throw new Error(
527
+ `Could not determine the system default browser, and no installed browser holds cookies for ${domains.join(', ')}.`
528
+ );
529
+ }
530
+ return { browser: systemDefault, profile: undefined, warning: null };
531
+ }
532
+ // The first profile with the most matching cookies; listing order breaks
533
+ // ties, so a browser's default profile wins over its others.
534
+ const best = candidates.reduce((chosen, source) =>
535
+ matchedCookies(source) > matchedCookies(chosen) ? source : chosen
536
+ );
537
+ let warning = null;
538
+ if (best.browser !== systemDefault) {
539
+ const reason = systemDefault
540
+ ? `The default browser (${systemDefault}) holds no cookies for`
541
+ : 'Could not determine the default browser to read cookies for';
542
+ warning = {
543
+ type: 'source',
544
+ item: best.browser,
545
+ reason: systemDefault
546
+ ? 'default-browser-fallback'
547
+ : 'default-browser-unknown',
548
+ detail: `${reason} ${domains.join(', ')}; imported from ${best.browser} instead.`,
549
+ };
550
+ }
551
+ return { browser: best.browser, profile: best.profile, warning };
552
+ }
553
+
346
554
  export {
347
555
  clearBrowserCookieMemoryCache,
348
556
  decryptChromiumCookie,