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.
@@ -2,28 +2,74 @@ import { access, readdir, readFile } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
5
- export const SUPPORTED_COOKIE_BROWSERS = [
6
- 'chrome',
7
- 'edge',
8
- 'brave',
9
- 'chromium',
10
- 'firefox',
11
- ];
5
+ import {
6
+ BROWSER_IDS,
7
+ browserFamily,
8
+ isSingleProfileBrowser,
9
+ normalizeBrowserId,
10
+ resolveBrowserRoots,
11
+ } from './browser-sources.js';
12
+ import { resolveDefaultBrowser } from './default-browser.js';
12
13
 
13
- function platformPath(platform) {
14
- return platform === 'win32' ? path.win32 : path;
14
+ /**
15
+ * Keywords that select the operating-system default browser instead of a named
16
+ * one, so `browser: 'default'` (or `'auto'`) imports from whatever a person
17
+ * actually uses. Importing stays opt-in: callers pass this explicitly.
18
+ */
19
+ const DEFAULT_BROWSER_KEYWORDS = new Set(['default', 'auto']);
20
+
21
+ /** Whether `browser` asks for the system default rather than a named browser. */
22
+ export function isDefaultBrowserKeyword(browser) {
23
+ return (
24
+ typeof browser === 'string' &&
25
+ DEFAULT_BROWSER_KEYWORDS.has(browser.trim().toLowerCase())
26
+ );
15
27
  }
16
28
 
17
- export function normalizeCookieBrowser(browser) {
18
- const normalized = browser === 'msedge' ? 'edge' : browser;
19
- if (!SUPPORTED_COOKIE_BROWSERS.includes(normalized)) {
29
+ /**
30
+ * Resolve a requested browser to a canonical catalogue id, expanding the
31
+ * `default`/`auto` keywords to the system default browser. Named browsers are
32
+ * normalized through the catalogue as before.
33
+ */
34
+ export async function resolveSourceBrowser(
35
+ browser,
36
+ { platform = process.platform, environment = process.env, runCommand } = {}
37
+ ) {
38
+ if (!isDefaultBrowserKeyword(browser)) {
39
+ return normalizeCookieBrowser(browser);
40
+ }
41
+ const resolved = await resolveDefaultBrowser({
42
+ platform,
43
+ environment,
44
+ runCommand,
45
+ });
46
+ if (!resolved) {
20
47
  throw new Error(
21
- `Unsupported browser: ${browser}. Expected one of ${SUPPORTED_COOKIE_BROWSERS.join(', ')}`
48
+ 'Could not determine the system default browser; pass an explicit browser instead of "default".'
22
49
  );
23
50
  }
24
- return normalized;
51
+ return resolved;
52
+ }
53
+
54
+ /**
55
+ * Every browser profile discovery can read from. Driven by the shared
56
+ * `browser-sources.json` catalogue, so adding a browser there adds it here.
57
+ */
58
+ export const SUPPORTED_COOKIE_BROWSERS = BROWSER_IDS;
59
+
60
+ function platformPath(platform) {
61
+ return platform === 'win32' ? path.win32 : path;
62
+ }
63
+
64
+ /** Resolve a browser name (id or alias) to its canonical id. */
65
+ export function normalizeCookieBrowser(browser) {
66
+ return normalizeBrowserId(browser);
25
67
  }
26
68
 
69
+ /**
70
+ * The primary profile root a browser uses on a platform, or `undefined` when
71
+ * the browser does not run there.
72
+ */
27
73
  export function browserProfileRoot(
28
74
  browser,
29
75
  {
@@ -32,46 +78,12 @@ export function browserProfileRoot(
32
78
  environment = process.env,
33
79
  } = {}
34
80
  ) {
35
- const normalizedBrowser = normalizeCookieBrowser(browser);
36
- const pathApi = platformPath(platform);
37
- if (platform === 'darwin') {
38
- const support = pathApi.join(homeDir, 'Library', 'Application Support');
39
- const roots = {
40
- brave: pathApi.join(support, 'BraveSoftware', 'Brave-Browser'),
41
- chrome: pathApi.join(support, 'Google', 'Chrome'),
42
- chromium: pathApi.join(support, 'Chromium'),
43
- edge: pathApi.join(support, 'Microsoft Edge'),
44
- firefox: pathApi.join(support, 'Firefox'),
45
- };
46
- return roots[normalizedBrowser];
47
- }
48
- if (platform === 'win32') {
49
- const localAppData =
50
- environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local');
51
- const roamingAppData =
52
- environment.APPDATA ?? pathApi.join(homeDir, 'AppData', 'Roaming');
53
- const roots = {
54
- brave: pathApi.join(
55
- localAppData,
56
- 'BraveSoftware',
57
- 'Brave-Browser',
58
- 'User Data'
59
- ),
60
- chrome: pathApi.join(localAppData, 'Google', 'Chrome', 'User Data'),
61
- chromium: pathApi.join(localAppData, 'Chromium', 'User Data'),
62
- edge: pathApi.join(localAppData, 'Microsoft', 'Edge', 'User Data'),
63
- firefox: pathApi.join(roamingAppData, 'Mozilla', 'Firefox'),
64
- };
65
- return roots[normalizedBrowser];
66
- }
67
- const roots = {
68
- brave: pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'),
69
- chrome: pathApi.join(homeDir, '.config', 'google-chrome'),
70
- chromium: pathApi.join(homeDir, '.config', 'chromium'),
71
- edge: pathApi.join(homeDir, '.config', 'microsoft-edge'),
72
- firefox: pathApi.join(homeDir, '.mozilla', 'firefox'),
73
- };
74
- return roots[normalizedBrowser];
81
+ const [root] = resolveBrowserRoots(browser, {
82
+ platform,
83
+ homeDir,
84
+ environment,
85
+ });
86
+ return root;
75
87
  }
76
88
 
77
89
  async function pathExists(filePath) {
@@ -100,7 +112,7 @@ function chromiumCookiePaths(profilePath, pathApi = path) {
100
112
 
101
113
  export async function findCookieDatabase(browser, profilePath, platform) {
102
114
  const pathApi = platformPath(platform ?? process.platform);
103
- if (normalizeCookieBrowser(browser) === 'firefox') {
115
+ if (browserFamily(browser) === 'firefox') {
104
116
  const candidate = pathApi.join(profilePath, 'cookies.sqlite');
105
117
  return (await pathExists(candidate)) ? candidate : null;
106
118
  }
@@ -112,11 +124,38 @@ export async function findCookieDatabase(browser, profilePath, platform) {
112
124
  return null;
113
125
  }
114
126
 
127
+ /** Default profile first, then by name. */
128
+ function sortProfiles(profiles) {
129
+ return profiles.sort(
130
+ (left, right) =>
131
+ Number(right.isDefault) - Number(left.isDefault) ||
132
+ left.name.localeCompare(right.name)
133
+ );
134
+ }
135
+
115
136
  async function listChromiumProfiles(browser, root, platform) {
116
137
  if (!(await pathExists(root))) {
117
138
  return [];
118
139
  }
119
140
  const pathApi = platformPath(platform);
141
+
142
+ // Opera-style browsers keep one profile in the root itself rather than in
143
+ // Default/Profile N subdirectories.
144
+ if (isSingleProfileBrowser(browser)) {
145
+ if (!(await findCookieDatabase(browser, root, platform))) {
146
+ return [];
147
+ }
148
+ return [
149
+ {
150
+ browser,
151
+ name: 'Default',
152
+ displayName: 'Default',
153
+ path: root,
154
+ isDefault: true,
155
+ },
156
+ ];
157
+ }
158
+
120
159
  const localState = await readJson(pathApi.join(root, 'Local State'));
121
160
  const infoCache = localState.profile?.info_cache ?? {};
122
161
  const names = new Set(Object.keys(infoCache));
@@ -150,11 +189,7 @@ async function listChromiumProfiles(browser, root, platform) {
150
189
  (names.size === 1 && name === 'Default'),
151
190
  });
152
191
  }
153
- return profiles.sort(
154
- (left, right) =>
155
- Number(right.isDefault) - Number(left.isDefault) ||
156
- left.name.localeCompare(right.name)
157
- );
192
+ return sortProfiles(profiles);
158
193
  }
159
194
 
160
195
  function parseIni(text) {
@@ -176,7 +211,7 @@ function parseIni(text) {
176
211
  return sections;
177
212
  }
178
213
 
179
- async function listFirefoxProfiles(root, platform) {
214
+ async function listFirefoxProfiles(browser, root, platform) {
180
215
  if (!(await pathExists(root))) {
181
216
  return [];
182
217
  }
@@ -215,23 +250,37 @@ async function listFirefoxProfiles(root, platform) {
215
250
  section.IsRelative === '0'
216
251
  ? section.Path
217
252
  : pathApi.resolve(relativeRoot, section.Path);
218
- if (!(await findCookieDatabase('firefox', profilePath, platform))) {
253
+ if (!(await findCookieDatabase(browser, profilePath, platform))) {
219
254
  continue;
220
255
  }
221
256
  const displayName = section.Name ?? pathApi.basename(profilePath);
222
257
  profiles.push({
223
- browser: 'firefox',
258
+ browser,
224
259
  name: displayName,
225
260
  displayName,
226
261
  path: profilePath,
227
262
  isDefault: section.Default === '1',
228
263
  });
229
264
  }
230
- return profiles.sort(
231
- (left, right) =>
232
- Number(right.isDefault) - Number(left.isDefault) ||
233
- left.name.localeCompare(right.name)
234
- );
265
+ return sortProfiles(profiles);
266
+ }
267
+
268
+ async function listProfilesForBrowser(browser, platform, homeDir, environment) {
269
+ const roots = resolveBrowserRoots(browser, {
270
+ platform,
271
+ homeDir,
272
+ environment,
273
+ });
274
+ const family = browserFamily(browser);
275
+ const profiles = [];
276
+ for (const root of roots) {
277
+ profiles.push(
278
+ ...(family === 'firefox'
279
+ ? await listFirefoxProfiles(browser, root, platform)
280
+ : await listChromiumProfiles(browser, root, platform))
281
+ );
282
+ }
283
+ return profiles;
235
284
  }
236
285
 
237
286
  /** Discover cookie-bearing profiles from installed browsers. */
@@ -240,28 +289,45 @@ export async function listBrowserProfiles({
240
289
  platform = process.platform,
241
290
  homeDir = os.homedir(),
242
291
  environment = process.env,
292
+ runCommand,
243
293
  } = {}) {
244
294
  const browsers = browser
245
- ? [normalizeCookieBrowser(browser)]
246
- : SUPPORTED_COOKIE_BROWSERS;
295
+ ? [
296
+ await resolveSourceBrowser(browser, {
297
+ platform,
298
+ environment,
299
+ runCommand,
300
+ }),
301
+ ]
302
+ : BROWSER_IDS;
247
303
  const profiles = [];
304
+ // Several Firefox channels (firefox, firefox-developer, firefox-nightly)
305
+ // share one profile root, so a catalogue-wide scan would otherwise report
306
+ // the same profile under each id. Keep the first (canonical) browser.
307
+ const seen = new Set();
248
308
  for (const candidate of browsers) {
249
- const root = browserProfileRoot(candidate, {
309
+ for (const profile of await listProfilesForBrowser(
310
+ candidate,
250
311
  platform,
251
312
  homeDir,
252
- environment,
253
- });
254
- profiles.push(
255
- ...(candidate === 'firefox'
256
- ? await listFirefoxProfiles(root, platform)
257
- : await listChromiumProfiles(candidate, root, platform))
258
- );
313
+ environment
314
+ )) {
315
+ if (seen.has(profile.path)) {
316
+ continue;
317
+ }
318
+ seen.add(profile.path);
319
+ profiles.push(profile);
320
+ }
259
321
  }
260
322
  return profiles;
261
323
  }
262
324
 
263
325
  export async function resolveBrowserProfile(options) {
264
- const browser = normalizeCookieBrowser(options.browser);
326
+ const browser = await resolveSourceBrowser(options.browser, {
327
+ platform: options.platform,
328
+ environment: options.environment,
329
+ runCommand: options.runCommand,
330
+ });
265
331
  const profiles = await listBrowserProfiles({ ...options, browser });
266
332
  const requested = options.profile;
267
333
  const selected = requested
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Catalogue of installed browsers Browser Commander can import from (#114).
3
+ *
4
+ * The data lives in `browser-sources.json`, a shared asset Python and Rust
5
+ * duplicate byte-for-byte (checked by
6
+ * `scripts/check-shared-fingerprint-assets.sh`). This module turns that data
7
+ * into the lookups the rest of the browser code needs: canonical ids with
8
+ * their aliases, the per-platform profile roots, the Chromium Safe Storage
9
+ * identity, and the operating-system identifiers that mark a browser as the
10
+ * system default.
11
+ *
12
+ * Keeping it data-driven is what lets a single JSON edit add Opera, Vivaldi,
13
+ * Arc, a Firefox fork, or a Chrome channel to all three implementations at
14
+ * once, rather than touching hand-written per-platform maps in each language.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ const registry = JSON.parse(
21
+ readFileSync(new URL('./browser-sources.json', import.meta.url), 'utf8')
22
+ );
23
+
24
+ /**
25
+ * Every known browser, in catalogue order.
26
+ *
27
+ * @type {ReadonlyArray<object>}
28
+ */
29
+ export const BROWSER_SOURCES = Object.freeze(
30
+ registry.browsers.map((browser) => Object.freeze(browser))
31
+ );
32
+
33
+ /** Canonical ids, in catalogue order. */
34
+ export const BROWSER_IDS = Object.freeze(
35
+ BROWSER_SOURCES.map((browser) => browser.id)
36
+ );
37
+
38
+ const BY_NAME = new Map();
39
+ for (const browser of BROWSER_SOURCES) {
40
+ BY_NAME.set(browser.id, browser);
41
+ for (const alias of browser.aliases ?? []) {
42
+ BY_NAME.set(alias, browser);
43
+ }
44
+ }
45
+
46
+ function platformPath(platform) {
47
+ return platform === 'win32' ? path.win32 : path;
48
+ }
49
+
50
+ /**
51
+ * Resolve a name (canonical id or alias) to its catalogue entry.
52
+ *
53
+ * @param {string} name
54
+ * @returns {object|undefined}
55
+ */
56
+ export function findBrowserSource(name) {
57
+ if (typeof name !== 'string') {
58
+ return undefined;
59
+ }
60
+ return BY_NAME.get(name) ?? BY_NAME.get(name.toLowerCase());
61
+ }
62
+
63
+ /**
64
+ * Resolve a name to its canonical id, throwing a readable error otherwise.
65
+ *
66
+ * @param {string} name
67
+ * @returns {string}
68
+ */
69
+ export function normalizeBrowserId(name) {
70
+ return normalizeBrowserSource(name).id;
71
+ }
72
+
73
+ /** The family ('chromium' or 'firefox') of a browser name. */
74
+ export function browserFamily(name) {
75
+ return normalizeBrowserSource(name).family;
76
+ }
77
+
78
+ function normalizeBrowserSource(name) {
79
+ const source = findBrowserSource(name);
80
+ if (!source) {
81
+ throw new Error(
82
+ `Unsupported browser: ${name}. Expected one of ${BROWSER_IDS.join(', ')}`
83
+ );
84
+ }
85
+ return source;
86
+ }
87
+
88
+ function templateVariables(platform, homeDir, environment) {
89
+ const pathApi = platformPath(platform);
90
+ if (platform === 'darwin') {
91
+ return {
92
+ home: homeDir,
93
+ appSupport: pathApi.join(homeDir, 'Library', 'Application Support'),
94
+ };
95
+ }
96
+ if (platform === 'win32') {
97
+ return {
98
+ home: homeDir,
99
+ localAppData:
100
+ environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local'),
101
+ appData:
102
+ environment.APPDATA ?? pathApi.join(homeDir, 'AppData', 'Roaming'),
103
+ };
104
+ }
105
+ return {
106
+ home: homeDir,
107
+ config: environment.XDG_CONFIG_HOME ?? pathApi.join(homeDir, '.config'),
108
+ };
109
+ }
110
+
111
+ function expandTemplate(template, variables, pathApi) {
112
+ const match = /^\{(\w+)\}(.*)$/.exec(template);
113
+ if (!match) {
114
+ return template;
115
+ }
116
+ const base = variables[match[1]];
117
+ if (base === undefined) {
118
+ return undefined;
119
+ }
120
+ const rest = match[2].split('/').filter(Boolean);
121
+ return pathApi.join(base, ...rest);
122
+ }
123
+
124
+ /**
125
+ * The absolute profile roots a browser uses on a platform. Returns an empty
126
+ * array when the browser does not run on that platform (for example Chrome
127
+ * Canary on Linux).
128
+ *
129
+ * @param {string} name
130
+ * @param {Object} [options]
131
+ * @param {string} [options.platform=process.platform]
132
+ * @param {string} [options.homeDir=os.homedir()]
133
+ * @param {Object} [options.environment=process.env]
134
+ * @returns {string[]}
135
+ */
136
+ export function resolveBrowserRoots(
137
+ name,
138
+ {
139
+ platform = process.platform,
140
+ homeDir = os.homedir(),
141
+ environment = process.env,
142
+ } = {}
143
+ ) {
144
+ const source = normalizeBrowserSource(name);
145
+ const templates = source.roots?.[platform] ?? [];
146
+ 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);
153
+ }
154
+ }
155
+ return roots;
156
+ }
157
+
158
+ /** True when a browser stores one profile in the root itself (Opera-style). */
159
+ export function isSingleProfileBrowser(name) {
160
+ return normalizeBrowserSource(name).singleProfile === true;
161
+ }
162
+
163
+ /**
164
+ * The Chromium Safe Storage identity for a browser, or undefined for a
165
+ * Firefox-family browser (which does not use OSCrypt).
166
+ *
167
+ * @param {string} name
168
+ * @returns {{service: string, application: string, folder: string}|undefined}
169
+ */
170
+ export function safeStorageIdentity(name) {
171
+ return normalizeBrowserSource(name).safeStorage;
172
+ }
173
+
174
+ /**
175
+ * The operating-system identifiers that mark a browser as the system default:
176
+ * macOS bundle ids, Linux `.desktop` file names, or Windows ProgIds.
177
+ *
178
+ * @param {string} name
179
+ * @param {string} platform
180
+ * @returns {string[]}
181
+ */
182
+ export function defaultBrowserIdentifiers(name, platform) {
183
+ return normalizeBrowserSource(name).default?.[platform] ?? [];
184
+ }