browser-commander 0.24.0 → 0.26.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 (72) hide show
  1. package/.jscpd-baseline.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +7 -4
  4. package/package.json +4 -1
  5. package/src/browser/attach/snapshot.js +4 -28
  6. package/src/browser/browser-cookie-credentials.js +15 -8
  7. package/src/browser/browser-cookies.js +36 -30
  8. package/src/browser/browser-profile-files.js +31 -0
  9. package/src/browser/browser-profiles.js +11 -13
  10. package/src/browser/browser-sources.js +39 -9
  11. package/src/browser/browser-sources.json +610 -25
  12. package/src/browser/launcher.js +14 -0
  13. package/src/browser/migration/capabilities.json +117 -0
  14. package/src/browser/migration/chromium-history.sql +22 -0
  15. package/src/browser/migration/chromium-writers.js +85 -0
  16. package/src/browser/migration/cookies.js +4 -1
  17. package/src/browser/migration/data-classes.js +68 -0
  18. package/src/browser/migration/domains.js +32 -0
  19. package/src/browser/migration/firefox-history.js +106 -0
  20. package/src/browser/migration/firefox.js +39 -25
  21. package/src/browser/migration/history.js +161 -2
  22. package/src/browser/migration/index.js +229 -83
  23. package/src/browser/migration/os-crypt-keys.js +1 -5
  24. package/src/browser/migration/password-metadata.js +95 -0
  25. package/src/browser/migration/passwords.js +78 -41
  26. package/src/browser/migration/safari-import.js +100 -0
  27. package/src/browser/migration/safari.js +110 -0
  28. package/src/browser/migration/sqlite-snapshot.js +31 -65
  29. package/src/browser/migration/validation.js +48 -0
  30. package/src/browser/real-browser.js +18 -1
  31. package/src/browser/safari-access.js +45 -0
  32. package/src/browser/safari-cookies.js +39 -36
  33. package/src/browser/safari-launch.js +42 -0
  34. package/src/browser/safari-profiles.js +83 -0
  35. package/src/browser/safari-support.js +98 -0
  36. package/src/browser/storage-state.js +28 -8
  37. package/src/browser/system-browser.js +31 -143
  38. package/src/browser/webdriver.js +64 -19
  39. package/src/cli/args.js +2 -0
  40. package/src/cli/commands.js +2 -0
  41. package/src/cli/high-level-methods.js +2 -0
  42. package/src/cli/sessions.js +5 -0
  43. package/src/core/webdriver-page.js +62 -0
  44. package/src/exports.js +6 -0
  45. package/src/traces/recorder.js +5 -0
  46. package/tests/e2e/safari.e2e.test.js +104 -0
  47. package/tests/unit/browser/attach/snapshot.test.js +30 -0
  48. package/tests/unit/browser/browser-cookies.test.js +35 -0
  49. package/tests/unit/browser/catalogue-launch.test.js +93 -0
  50. package/tests/unit/browser/cookie-sources.test.js +92 -17
  51. package/tests/unit/browser/credential-catalogue.test.js +50 -0
  52. package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
  53. package/tests/unit/browser/migration/data-classes.test.js +89 -0
  54. package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
  55. package/tests/unit/browser/migration/firefox-history.test.js +130 -0
  56. package/tests/unit/browser/migration/history-metadata.test.js +74 -0
  57. package/tests/unit/browser/migration/index.test.js +16 -1
  58. package/tests/unit/browser/migration/opera-keys.test.js +22 -0
  59. package/tests/unit/browser/migration/safari-data.test.js +143 -0
  60. package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
  61. package/tests/unit/browser/migration/validation.test.js +63 -0
  62. package/tests/unit/browser/migration/yandex.test.js +84 -0
  63. package/tests/unit/browser/real-browser.test.js +19 -0
  64. package/tests/unit/browser/safari-cookies.test.js +26 -1
  65. package/tests/unit/browser/safari-profiles.test.js +111 -0
  66. package/tests/unit/browser/safari.test.js +192 -0
  67. package/tests/unit/browser/webdriver.test.js +48 -3
  68. package/tests/unit/cli/dispatcher.test.js +5 -1
  69. package/tests/unit/cli/main.test.js +5 -0
  70. package/tests/unit/scripts/browser-support.test.js +14 -7
  71. package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
  72. package/tests/unit/scripts/migration-support.test.js +31 -0
@@ -20,7 +20,6 @@
20
20
  "0caa402c4eae32d8": 1,
21
21
  "0fd23be8410c2c95": 1,
22
22
  "0fe4f3b5e5e939b3": 1,
23
- "119f429fe4d23e4a": 1,
24
23
  "126a1cfe8f2264ce": 1,
25
24
  "1273365073edd790": 1,
26
25
  "13c4b58d3ea0719b": 1,
@@ -210,6 +209,7 @@
210
209
  "c225b9d7d519303e": 1,
211
210
  "c2c155adbcb46f3c": 1,
212
211
  "c30737d62069fc02": 1,
212
+ "c32010db539b4efb": 1,
213
213
  "c3c3569357673af8": 1,
214
214
  "c415ffcd004d6518": 1,
215
215
  "c461a83b51eb7740": 1,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8f9ef1c: Add native Safari and Technology Preview W3C WebDriver control through real/common launchers and CLI, with isolated cookie seeding, setup diagnostics and typed unsupported feature errors.
8
+
9
+ Update the transitive shell-quote lock entry to fix GHSA-pqg4-j6r4-53mv and unblock npm audit.
10
+
11
+ ## 0.25.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 4a79d62: Import Safari bookmarks, history and explicit Passwords CSV exports into Chromium profiles. Discover named Safari profiles and modern WebKit cookie stores. Derive desktop browser executable discovery and profile protection from the shared catalogue, fix Opera Local State lookup, and report Yandex Ya Passman encryption limitations. Enforce domain boundaries for migrated cookies, history and passwords, and validate migration paths and options before writing.
16
+
17
+ Resolve native Safe Storage credentials from the catalogue and provide service-specific macOS Keychain retry guidance. SQLite stores that cannot be backed up consistently return close-browser/retry guidance instead of copying live database files.
18
+
19
+ Match exact hosts and subdomains in cookie-source metadata and automatic source selection, without decoding cookie values or treating domain strings as SQL wildcard patterns.
20
+
21
+ Keep Safari profile-catalogue errors alongside readable profiles and cookie sources, including domain-filtered listings.
22
+
23
+ Filter associated Login Data notes, security records and origin statistics; re-encrypt retained notes with the target key. Reset copied sync state and report unknown metadata omitted during domain-filtered imports.
24
+
25
+ Recognize all 18 migration data classes with explicit unsupported reports. Add separate payment-card consent across native APIs, pre-launch migration, CLI and command streams, plus provider-specific passkey and certificate export limitations without accessing protected stores.
26
+
27
+ Translate Firefox history to Chromium with exact microsecond visit dates, domain filtering, immutable snapshots and per-visit malformed-data reports.
28
+
29
+ Normalize Firefox schema 16+ cookie expiry from milliseconds to seconds in both installed-profile reading and migration, preserving legacy schemas and session markers.
30
+
31
+ Preserve supported Yandex Login Data imports when an unsupported Ya Passman Data store is present, retaining separate encryption diagnostics.
32
+
33
+ Omit derived cluster labels, keywords, duplicate-visit records and unknown History metadata with named warnings during domain-filtered imports; preserve them during unfiltered imports.
34
+
3
35
  ## 0.24.0
4
36
 
5
37
  ### Minor Changes
package/README.md CHANGED
@@ -462,7 +462,7 @@ const session = await launchRealBrowser({
462
462
  browser: 'chrome', // chrome | edge | brave | chromium | firefox
463
463
  profile: 'Default',
464
464
  include: ['cookies', 'bookmarks', 'history', 'passwords', 'preferences'],
465
- domains: ['npmjs.com', 'github.com'], // optional cookie filter
465
+ domains: ['npmjs.com', 'github.com'], // optional cookies/history/passwords filter
466
466
  },
467
467
  });
468
468
  console.log(session.migration.migrated, session.migration.skipped);
@@ -524,9 +524,12 @@ overrides and managed downloads need CDP and throw on this engine; see
524
524
 
525
525
  ### listBrowserProfiles(options)
526
526
 
527
- Discover cookie-bearing profiles for Chrome, Edge, Brave, Chromium, and Firefox.
527
+ Discover profiles for the browsers in the shared catalogue, including Safari.
528
528
  Pass an optional `browser` to narrow discovery. Each result contains
529
- `{ browser, name, displayName, path, isDefault }`.
529
+ `{ browser, name, displayName, path, isDefault }`. If Safari's profile catalogue
530
+ cannot be read, a `Profiles` entry carries `error` alongside readable profiles.
531
+ Protected-file errors retain Full Disk Access instructions; cookie-source
532
+ listings preserve the same error even when a domain filter is supplied.
530
533
 
531
534
  ### readBrowserCookies(options)
532
535
 
@@ -589,7 +592,7 @@ const report = await migrateProfile({
589
592
  'preferences',
590
593
  'extensions',
591
594
  ],
592
- domains: ['github.com'], // optional cookie filter
595
+ domains: ['github.com'], // optional cookies/history/passwords filter
593
596
  });
594
597
  ```
595
598
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
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": {
@@ -81,9 +81,12 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "better-sqlite3": "^13.0.3",
84
+ "bplist-parser": "^0.5.0",
84
85
  "command-stream": "^1.4.0",
86
+ "csv-parse": "^7.0.3",
85
87
  "links-notation": "^0.22.0",
86
88
  "log-lazy": "^1.0.4",
89
+ "plist": "^5.0.0",
87
90
  "test-anywhere": "^0.9.1"
88
91
  },
89
92
  "devDependencies": {
@@ -10,13 +10,11 @@ import {
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
 
13
- import BetterSqlite3 from 'better-sqlite3';
14
-
13
+ import { browserFamily } from '../browser-sources.js';
15
14
  import {
16
15
  browserProfileRoot,
17
16
  normalizeCookieBrowser,
18
17
  } from '../browser-profiles.js';
19
- import { pathExists } from '../migration/fs-utils.js';
20
18
  import { withDatabaseSnapshot } from '../migration/sqlite-snapshot.js';
21
19
  import {
22
20
  createTemporaryUserDataDir,
@@ -171,28 +169,6 @@ async function prepareTarget(to, sourceRoot, profile) {
171
169
  return to;
172
170
  }
173
171
 
174
- /**
175
- * Copy a database snapshot to its place in the target. The Online Backup API
176
- * snapshot is a single self-contained file; the locked-file fallback is a copy
177
- * with its `-wal`/`-journal` sidecars, which is folded into one file with
178
- * another backup (of the temporary copy, never of the source).
179
- */
180
- async function copyDatabase(snapshotPath, destination) {
181
- const hasSidecar =
182
- (await pathExists(`${snapshotPath}-wal`)) ||
183
- (await pathExists(`${snapshotPath}-journal`));
184
- if (!hasSidecar) {
185
- await copyFile(snapshotPath, destination);
186
- return;
187
- }
188
- const database = new BetterSqlite3(snapshotPath, { fileMustExist: true });
189
- try {
190
- await database.backup(destination);
191
- } finally {
192
- database.close();
193
- }
194
- }
195
-
196
172
  /** Copy the directory tree below one entry, recording the outcome. */
197
173
  class SnapshotCopier {
198
174
  constructor(sourceRoot, target) {
@@ -213,7 +189,7 @@ class SnapshotCopier {
213
189
  if (await isSqliteFile(source)) {
214
190
  await withDatabaseSnapshot({
215
191
  sourcePath: source,
216
- read: (snapshotPath) => copyDatabase(snapshotPath, destination),
192
+ read: (snapshotPath) => copyFile(snapshotPath, destination),
217
193
  });
218
194
  this.copied.databases += 1;
219
195
  } else {
@@ -311,9 +287,9 @@ export async function snapshotUserDataDir({
311
287
  throw new TypeError('snapshotUserDataDir requires a browser');
312
288
  }
313
289
  const normalizedBrowser = normalizeCookieBrowser(browser);
314
- if (normalizedBrowser === 'firefox') {
290
+ if (browserFamily(normalizedBrowser) !== 'chromium') {
315
291
  throw new Error(
316
- 'A profile snapshot is only supported for Chromium-family browsers (chrome, chromium, edge, brave): Firefox cannot be driven over CDP by this launcher'
292
+ 'A profile snapshot is only supported for Chromium-family browsers; this launcher uses CDP'
317
293
  );
318
294
  }
319
295
  assertProfileName(profile);
@@ -68,15 +68,22 @@ export async function readSafeStoragePassword({
68
68
  throw new Error(`No Safe Storage identity is known for ${browser}`);
69
69
  }
70
70
  if (platform === 'darwin') {
71
- const password = await runCommand(
72
- 'security',
73
- ['find-generic-password', '-w', '-s', identity.service],
74
- environment
75
- );
76
- if (!password) {
77
- throw new Error(`${identity.service} returned an empty password`);
71
+ try {
72
+ const password = await runCommand(
73
+ 'security',
74
+ ['find-generic-password', '-w', '-s', identity.service],
75
+ environment
76
+ );
77
+ if (!password) {
78
+ throw new Error(`${identity.service} returned an empty password`);
79
+ }
80
+ return password;
81
+ } catch (cause) {
82
+ throw new Error(
83
+ `Could not read ${identity.service} from macOS Keychain. Unlock the login Keychain and allow the app running Browser Commander to access this item, then retry with refresh=true.`,
84
+ { cause }
85
+ );
78
86
  }
79
- return password;
80
87
  }
81
88
  if (platform === 'linux') {
82
89
  return readLinuxSafeStoragePassword(browser, environment, runCommand);
@@ -1,4 +1,4 @@
1
- import path from 'node:path';
1
+ import { localStatePathForProfile } from './browser-profile-files.js';
2
2
  import os from 'node:os';
3
3
 
4
4
  import {
@@ -33,6 +33,7 @@ import {
33
33
  resolveSourceBrowser,
34
34
  } from './browser-profiles.js';
35
35
  import { resolveDefaultBrowser } from './default-browser.js';
36
+ import { matchesDomains } from './migration/domains.js';
36
37
  import {
37
38
  countSafariCookies,
38
39
  parseSafariCookies,
@@ -77,10 +78,16 @@ function readDatabaseVersion(database) {
77
78
  }
78
79
  }
79
80
 
80
- function readFirefoxRows(database, domainFilter) {
81
+ // Firefox schema 16 changed expiry from Unix seconds to milliseconds. Normalize
82
+ // in SQLite before mapping so both installed reading and migration use seconds.
83
+ export function readFirefoxRows(database, domainFilter) {
84
+ const version = Number(
85
+ database.prepare('PRAGMA user_version').get()?.user_version ?? 0
86
+ );
87
+ const expiry = version >= 16 ? 'expiry / 1000 AS expiry' : 'expiry';
81
88
  return queryRows(
82
89
  database,
83
- `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite
90
+ `SELECT name, value, host, path, ${expiry}, isSecure, isHttpOnly, sameSite
84
91
  FROM moz_cookies
85
92
  ${domainFilter ? 'WHERE host LIKE ?' : ''}
86
93
  ORDER BY host, name, path`,
@@ -168,10 +175,7 @@ function chromiumKeyForPrefix(context, prefix) {
168
175
  },
169
176
  create: () =>
170
177
  context.readWindowsEncryptionKey({
171
- localStatePath: path.join(
172
- path.dirname(context.profile.path),
173
- 'Local State'
174
- ),
178
+ localStatePath: localStatePathForProfile(context.profile.path),
175
179
  environment: context.environment,
176
180
  decryptDpapi: context.decryptWindowsDpapi,
177
181
  }),
@@ -380,21 +384,22 @@ export function readBrowserCookies(options) {
380
384
  function countCookiesByDomain(database, family, domains) {
381
385
  const column = family === 'firefox' ? 'host' : 'host_key';
382
386
  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);
387
+ const total = Number(
388
+ database.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()?.n ?? 0
389
+ );
392
390
  if (!Array.isArray(domains) || domains.length === 0) {
393
391
  return { total, byDomain: null };
394
392
  }
395
- const byDomain = {};
396
- for (const domain of domains) {
397
- byDomain[domain] = countFor(domain);
393
+ const byDomain = Object.fromEntries(domains.map((domain) => [domain, 0]));
394
+ const hosts = database.prepare(
395
+ `SELECT ${column} AS host, COUNT(*) AS n FROM ${table} GROUP BY ${column}`
396
+ );
397
+ for (const { host, n } of hosts.all()) {
398
+ for (const domain of Object.keys(byDomain)) {
399
+ if (matchesDomains(host ?? '', [domain])) {
400
+ byDomain[domain] += Number(n);
401
+ }
402
+ }
398
403
  }
399
404
  return { total, byDomain };
400
405
  }
@@ -426,6 +431,16 @@ export async function listCookieSources({
426
431
  });
427
432
  const sources = [];
428
433
  for (const profile of profiles) {
434
+ const source = {
435
+ browser: profile.browser,
436
+ profile: profile.name,
437
+ path: profile.path,
438
+ isDefault: profile.isDefault,
439
+ };
440
+ if (profile.error) {
441
+ sources.push({ ...source, error: profile.error });
442
+ continue;
443
+ }
429
444
  const cookiePath = await findCookieDatabase(
430
445
  profile.browser,
431
446
  profile.path,
@@ -454,13 +469,7 @@ export async function listCookieSources({
454
469
  database?.close();
455
470
  }
456
471
  if (error) {
457
- sources.push({
458
- browser: profile.browser,
459
- profile: profile.name,
460
- path: profile.path,
461
- isDefault: profile.isDefault,
462
- error,
463
- });
472
+ sources.push({ ...source, error });
464
473
  continue;
465
474
  }
466
475
  // When filtering by domain, skip profiles that hold none of them.
@@ -471,10 +480,7 @@ export async function listCookieSources({
471
480
  continue;
472
481
  }
473
482
  sources.push({
474
- browser: profile.browser,
475
- profile: profile.name,
476
- path: profile.path,
477
- isDefault: profile.isDefault,
483
+ ...source,
478
484
  cookies: counts.total,
479
485
  byDomain: counts.byDomain,
480
486
  });
@@ -0,0 +1,31 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /** Opera keeps Local State inside its single-profile root. */
5
+ export function localStatePathForProfile(profileDir) {
6
+ const own = path.join(profileDir, 'Local State');
7
+ return existsSync(own)
8
+ ? own
9
+ : path.join(path.dirname(profileDir), 'Local State');
10
+ }
11
+
12
+ /** Resolve existing ancestors even when the target profile does not exist. */
13
+ export function physicalPath(value) {
14
+ let existing = path.resolve(value);
15
+ const suffix = [];
16
+ while (true) {
17
+ try {
18
+ return path.join(realpathSync(existing), ...suffix.reverse());
19
+ } catch (error) {
20
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
21
+ throw error;
22
+ }
23
+ suffix.push(path.basename(existing));
24
+ const parent = path.dirname(existing);
25
+ if (parent === existing) {
26
+ throw error;
27
+ }
28
+ existing = parent;
29
+ }
30
+ }
31
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from './browser-sources.js';
12
12
  import { resolveDefaultBrowser } from './default-browser.js';
13
13
  import { findSafariCookieFile } from './safari-cookies.js';
14
+ import { listSafariProfiles } from './safari-profiles.js';
14
15
 
15
16
  /**
16
17
  * Keywords that select the operating-system default browser instead of a named
@@ -279,16 +280,9 @@ async function listProfilesForBrowser(browser, platform, homeDir, environment) {
279
280
  const profiles = [];
280
281
  for (const root of roots) {
281
282
  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
- ];
283
+ const safariProfiles = await listSafariProfiles(browser, root);
284
+ if (safariProfiles.length) {
285
+ return safariProfiles;
292
286
  }
293
287
  continue;
294
288
  }
@@ -301,7 +295,7 @@ async function listProfilesForBrowser(browser, platform, homeDir, environment) {
301
295
  return profiles;
302
296
  }
303
297
 
304
- /** Discover cookie-bearing profiles from installed browsers. */
298
+ /** Discover profiles, retaining per-source errors alongside readable profiles. */
305
299
  export async function listBrowserProfiles({
306
300
  browser,
307
301
  platform = process.platform,
@@ -330,10 +324,11 @@ export async function listBrowserProfiles({
330
324
  homeDir,
331
325
  environment
332
326
  )) {
333
- if (seen.has(profile.path)) {
327
+ const identity = JSON.stringify([profile.path, Boolean(profile.error)]);
328
+ if (seen.has(identity)) {
334
329
  continue;
335
330
  }
336
- seen.add(profile.path);
331
+ seen.add(identity);
337
332
  profiles.push(profile);
338
333
  }
339
334
  }
@@ -360,5 +355,8 @@ export async function resolveBrowserProfile(options) {
360
355
  const detail = requested ? ` profile "${requested}"` : ' profile';
361
356
  throw new Error(`Could not find a cookie database for ${browser}${detail}`);
362
357
  }
358
+ if (selected.error) {
359
+ throw new Error(selected.error);
360
+ }
363
361
  return selected;
364
362
  }
@@ -100,6 +100,8 @@ function templateVariables(platform, homeDir, environment) {
100
100
  environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local'),
101
101
  appData:
102
102
  environment.APPDATA ?? pathApi.join(homeDir, 'AppData', 'Roaming'),
103
+ programFiles: environment.PROGRAMFILES,
104
+ programFilesX86: environment['PROGRAMFILES(X86)'],
103
105
  };
104
106
  }
105
107
  return {
@@ -133,7 +135,29 @@ function expandTemplate(template, variables, pathApi) {
133
135
  * @param {Object} [options.environment=process.env]
134
136
  * @returns {string[]}
135
137
  */
136
- export function resolveBrowserRoots(
138
+ export function resolveBrowserRoots(name, options = {}) {
139
+ const source = normalizeBrowserSource(name);
140
+ const templates = source.roots?.[options.platform ?? process.platform] ?? [];
141
+ return resolveTemplates(templates, options);
142
+ }
143
+
144
+ function resolveTemplates(
145
+ templates,
146
+ {
147
+ platform = process.platform,
148
+ homeDir = os.homedir(),
149
+ environment = process.env,
150
+ } = {}
151
+ ) {
152
+ const pathApi = platformPath(platform);
153
+ const variables = templateVariables(platform, homeDir, environment);
154
+ return templates
155
+ .map((template) => expandTemplate(template, variables, pathApi))
156
+ .filter((entry) => entry !== undefined);
157
+ }
158
+
159
+ /** Installed executable candidates, from the same catalogue as profile roots. */
160
+ export function resolveBrowserExecutables(
137
161
  name,
138
162
  {
139
163
  platform = process.platform,
@@ -142,17 +166,23 @@ export function resolveBrowserRoots(
142
166
  } = {}
143
167
  ) {
144
168
  const source = normalizeBrowserSource(name);
145
- const templates = source.roots?.[platform] ?? [];
146
169
  const pathApi = platformPath(platform);
147
- const variables = templateVariables(platform, homeDir, environment);
148
- const roots = [];
149
- for (const template of templates) {
150
- const resolved = expandTemplate(template, variables, pathApi);
151
- if (resolved !== undefined) {
152
- roots.push(resolved);
170
+ const candidates = resolveTemplates(source.executables?.[platform] ?? [], {
171
+ platform,
172
+ homeDir,
173
+ environment,
174
+ });
175
+ const delimiter = platform === 'win32' ? ';' : path.delimiter;
176
+ for (const directory of (environment.PATH ?? '')
177
+ .split(delimiter)
178
+ .filter(Boolean)) {
179
+ for (const name of source.executableNames ?? []) {
180
+ candidates.push(
181
+ pathApi.join(directory, platform === 'win32' ? `${name}.exe` : name)
182
+ );
153
183
  }
154
184
  }
155
- return roots;
185
+ return [...new Set(candidates)];
156
186
  }
157
187
 
158
188
  /** True when a browser stores one profile in the root itself (Opera-style). */