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.
@@ -2,28 +2,75 @@ 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';
13
+ import { findSafariCookieFile } from './safari-cookies.js';
12
14
 
13
- function platformPath(platform) {
14
- return platform === 'win32' ? path.win32 : path;
15
+ /**
16
+ * Keywords that select the operating-system default browser instead of a named
17
+ * one, so `browser: 'default'` (or `'auto'`) imports from whatever a person
18
+ * actually uses. Importing stays opt-in: callers pass this explicitly.
19
+ */
20
+ const DEFAULT_BROWSER_KEYWORDS = new Set(['default', 'auto']);
21
+
22
+ /** Whether `browser` asks for the system default rather than a named browser. */
23
+ export function isDefaultBrowserKeyword(browser) {
24
+ return (
25
+ typeof browser === 'string' &&
26
+ DEFAULT_BROWSER_KEYWORDS.has(browser.trim().toLowerCase())
27
+ );
15
28
  }
16
29
 
17
- export function normalizeCookieBrowser(browser) {
18
- const normalized = browser === 'msedge' ? 'edge' : browser;
19
- if (!SUPPORTED_COOKIE_BROWSERS.includes(normalized)) {
30
+ /**
31
+ * Resolve a requested browser to a canonical catalogue id, expanding the
32
+ * `default`/`auto` keywords to the system default browser. Named browsers are
33
+ * normalized through the catalogue as before.
34
+ */
35
+ export async function resolveSourceBrowser(
36
+ browser,
37
+ { platform = process.platform, environment = process.env, runCommand } = {}
38
+ ) {
39
+ if (!isDefaultBrowserKeyword(browser)) {
40
+ return normalizeCookieBrowser(browser);
41
+ }
42
+ const resolved = await resolveDefaultBrowser({
43
+ platform,
44
+ environment,
45
+ runCommand,
46
+ });
47
+ if (!resolved) {
20
48
  throw new Error(
21
- `Unsupported browser: ${browser}. Expected one of ${SUPPORTED_COOKIE_BROWSERS.join(', ')}`
49
+ 'Could not determine the system default browser; pass an explicit browser instead of "default".'
22
50
  );
23
51
  }
24
- return normalized;
52
+ return resolved;
53
+ }
54
+
55
+ /**
56
+ * Every browser profile discovery can read from. Driven by the shared
57
+ * `browser-sources.json` catalogue, so adding a browser there adds it here.
58
+ */
59
+ export const SUPPORTED_COOKIE_BROWSERS = BROWSER_IDS;
60
+
61
+ function platformPath(platform) {
62
+ return platform === 'win32' ? path.win32 : path;
63
+ }
64
+
65
+ /** Resolve a browser name (id or alias) to its canonical id. */
66
+ export function normalizeCookieBrowser(browser) {
67
+ return normalizeBrowserId(browser);
25
68
  }
26
69
 
70
+ /**
71
+ * The primary profile root a browser uses on a platform, or `undefined` when
72
+ * the browser does not run there.
73
+ */
27
74
  export function browserProfileRoot(
28
75
  browser,
29
76
  {
@@ -32,46 +79,12 @@ export function browserProfileRoot(
32
79
  environment = process.env,
33
80
  } = {}
34
81
  ) {
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];
82
+ const [root] = resolveBrowserRoots(browser, {
83
+ platform,
84
+ homeDir,
85
+ environment,
86
+ });
87
+ return root;
75
88
  }
76
89
 
77
90
  async function pathExists(filePath) {
@@ -100,7 +113,10 @@ function chromiumCookiePaths(profilePath, pathApi = path) {
100
113
 
101
114
  export async function findCookieDatabase(browser, profilePath, platform) {
102
115
  const pathApi = platformPath(platform ?? process.platform);
103
- if (normalizeCookieBrowser(browser) === 'firefox') {
116
+ if (browserFamily(browser) === 'safari') {
117
+ return findSafariCookieFile(profilePath);
118
+ }
119
+ if (browserFamily(browser) === 'firefox') {
104
120
  const candidate = pathApi.join(profilePath, 'cookies.sqlite');
105
121
  return (await pathExists(candidate)) ? candidate : null;
106
122
  }
@@ -112,11 +128,38 @@ export async function findCookieDatabase(browser, profilePath, platform) {
112
128
  return null;
113
129
  }
114
130
 
131
+ /** Default profile first, then by name. */
132
+ function sortProfiles(profiles) {
133
+ return profiles.sort(
134
+ (left, right) =>
135
+ Number(right.isDefault) - Number(left.isDefault) ||
136
+ left.name.localeCompare(right.name)
137
+ );
138
+ }
139
+
115
140
  async function listChromiumProfiles(browser, root, platform) {
116
141
  if (!(await pathExists(root))) {
117
142
  return [];
118
143
  }
119
144
  const pathApi = platformPath(platform);
145
+
146
+ // Opera-style browsers keep one profile in the root itself rather than in
147
+ // Default/Profile N subdirectories.
148
+ if (isSingleProfileBrowser(browser)) {
149
+ if (!(await findCookieDatabase(browser, root, platform))) {
150
+ return [];
151
+ }
152
+ return [
153
+ {
154
+ browser,
155
+ name: 'Default',
156
+ displayName: 'Default',
157
+ path: root,
158
+ isDefault: true,
159
+ },
160
+ ];
161
+ }
162
+
120
163
  const localState = await readJson(pathApi.join(root, 'Local State'));
121
164
  const infoCache = localState.profile?.info_cache ?? {};
122
165
  const names = new Set(Object.keys(infoCache));
@@ -150,11 +193,7 @@ async function listChromiumProfiles(browser, root, platform) {
150
193
  (names.size === 1 && name === 'Default'),
151
194
  });
152
195
  }
153
- return profiles.sort(
154
- (left, right) =>
155
- Number(right.isDefault) - Number(left.isDefault) ||
156
- left.name.localeCompare(right.name)
157
- );
196
+ return sortProfiles(profiles);
158
197
  }
159
198
 
160
199
  function parseIni(text) {
@@ -176,7 +215,7 @@ function parseIni(text) {
176
215
  return sections;
177
216
  }
178
217
 
179
- async function listFirefoxProfiles(root, platform) {
218
+ async function listFirefoxProfiles(browser, root, platform) {
180
219
  if (!(await pathExists(root))) {
181
220
  return [];
182
221
  }
@@ -215,23 +254,51 @@ async function listFirefoxProfiles(root, platform) {
215
254
  section.IsRelative === '0'
216
255
  ? section.Path
217
256
  : pathApi.resolve(relativeRoot, section.Path);
218
- if (!(await findCookieDatabase('firefox', profilePath, platform))) {
257
+ if (!(await findCookieDatabase(browser, profilePath, platform))) {
219
258
  continue;
220
259
  }
221
260
  const displayName = section.Name ?? pathApi.basename(profilePath);
222
261
  profiles.push({
223
- browser: 'firefox',
262
+ browser,
224
263
  name: displayName,
225
264
  displayName,
226
265
  path: profilePath,
227
266
  isDefault: section.Default === '1',
228
267
  });
229
268
  }
230
- return profiles.sort(
231
- (left, right) =>
232
- Number(right.isDefault) - Number(left.isDefault) ||
233
- left.name.localeCompare(right.name)
234
- );
269
+ return sortProfiles(profiles);
270
+ }
271
+
272
+ async function listProfilesForBrowser(browser, platform, homeDir, environment) {
273
+ const roots = resolveBrowserRoots(browser, {
274
+ platform,
275
+ homeDir,
276
+ environment,
277
+ });
278
+ const family = browserFamily(browser);
279
+ const profiles = [];
280
+ for (const root of roots) {
281
+ 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
+ ];
292
+ }
293
+ continue;
294
+ }
295
+ profiles.push(
296
+ ...(family === 'firefox'
297
+ ? await listFirefoxProfiles(browser, root, platform)
298
+ : await listChromiumProfiles(browser, root, platform))
299
+ );
300
+ }
301
+ return profiles;
235
302
  }
236
303
 
237
304
  /** Discover cookie-bearing profiles from installed browsers. */
@@ -240,28 +307,45 @@ export async function listBrowserProfiles({
240
307
  platform = process.platform,
241
308
  homeDir = os.homedir(),
242
309
  environment = process.env,
310
+ runCommand,
243
311
  } = {}) {
244
312
  const browsers = browser
245
- ? [normalizeCookieBrowser(browser)]
246
- : SUPPORTED_COOKIE_BROWSERS;
313
+ ? [
314
+ await resolveSourceBrowser(browser, {
315
+ platform,
316
+ environment,
317
+ runCommand,
318
+ }),
319
+ ]
320
+ : BROWSER_IDS;
247
321
  const profiles = [];
322
+ // Several Firefox channels (firefox, firefox-developer, firefox-nightly)
323
+ // share one profile root, so a catalogue-wide scan would otherwise report
324
+ // the same profile under each id. Keep the first (canonical) browser.
325
+ const seen = new Set();
248
326
  for (const candidate of browsers) {
249
- const root = browserProfileRoot(candidate, {
327
+ for (const profile of await listProfilesForBrowser(
328
+ candidate,
250
329
  platform,
251
330
  homeDir,
252
- environment,
253
- });
254
- profiles.push(
255
- ...(candidate === 'firefox'
256
- ? await listFirefoxProfiles(root, platform)
257
- : await listChromiumProfiles(candidate, root, platform))
258
- );
331
+ environment
332
+ )) {
333
+ if (seen.has(profile.path)) {
334
+ continue;
335
+ }
336
+ seen.add(profile.path);
337
+ profiles.push(profile);
338
+ }
259
339
  }
260
340
  return profiles;
261
341
  }
262
342
 
263
343
  export async function resolveBrowserProfile(options) {
264
- const browser = normalizeCookieBrowser(options.browser);
344
+ const browser = await resolveSourceBrowser(options.browser, {
345
+ platform: options.platform,
346
+ environment: options.environment,
347
+ runCommand: options.runCommand,
348
+ });
265
349
  const profiles = await listBrowserProfiles({ ...options, browser });
266
350
  const requested = options.profile;
267
351
  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
+ }