browser-commander 0.20.0 → 0.21.1
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.
- package/.jscpd.json +2 -1
- package/.prettierignore +1 -0
- package/CHANGELOG.md +159 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +21 -4
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +26 -19
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +205 -0
- package/src/browser/real-browser.js +545 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +300 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +199 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/traces/bundle.js +3 -2
- package/src/traces/observers.js +14 -5
- package/src/traces/recorder.js +3 -0
- package/src/traces/viewer.js +16 -8
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +26 -17
- package/tests/e2e/puppeteer.e2e.test.js +18 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
- package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
- package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
- package/tests/fixtures/traces/conformance/scenario.json +249 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
- package/tests/unit/browser/attach/snapshot.test.js +409 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +361 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/storage-state.test.js +2 -0
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +437 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/navigation-manager.test.js +11 -1
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/playwright-protocol-coverage.test.js +231 -0
- package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
- package/tests/unit/scripts/feature-parity.test.js +327 -0
- package/tests/unit/traces/conformance.test.js +92 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/traces/viewer.test.js +78 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { pathExists } from './fs-utils.js';
|
|
4
|
+
|
|
5
|
+
import { readBrowserCookies } from '../browser-cookies.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cookie migration.
|
|
9
|
+
*
|
|
10
|
+
* Cookies are read from the source profile with the existing
|
|
11
|
+
* {@link readBrowserCookies} (which handles the per-platform keystore and the
|
|
12
|
+
* v10/v11 decryption), optionally filtered by domain, and returned so the
|
|
13
|
+
* launcher can seed them into the dedicated profile over CDP with the existing
|
|
14
|
+
* `seedCookies` path. This module does not write anything into the target
|
|
15
|
+
* profile itself; the CDP seed is what a real, running browser accepts.
|
|
16
|
+
*
|
|
17
|
+
* ## Device Bound Session Credentials (DBSC)
|
|
18
|
+
*
|
|
19
|
+
* Google has shipped Device Bound Session Credentials: the session is bound to
|
|
20
|
+
* a private key held in the device's TPM or Secure Enclave, and the short
|
|
21
|
+
* "session token" cookies are rotated by the browser using that key. The key
|
|
22
|
+
* cannot leave the profile, so a copied Google session cannot be refreshed
|
|
23
|
+
* from another profile and expires quickly. Chrome's guidance is to report
|
|
24
|
+
* this rather than fail silently.
|
|
25
|
+
*
|
|
26
|
+
* References:
|
|
27
|
+
* - https://developer.chrome.com/docs/web-platform/device-bound-session-credentials
|
|
28
|
+
* - https://github.com/WICG/dbsc (the DBSC explainer)
|
|
29
|
+
* - https://www.helpnetsecurity.com/2026/04/10/google-chrome-device-bound-session-credentials/
|
|
30
|
+
*
|
|
31
|
+
* Detection is twofold:
|
|
32
|
+
*
|
|
33
|
+
* 1. By name+domain: the rotating bound cookies Chrome refreshes for a Google
|
|
34
|
+
* sign-in are `__Secure-1PSIDTS`, `__Secure-3PSIDTS` and `SIDTS` on
|
|
35
|
+
* `google.*`/`accounts.google.com` hosts. These are copied (so the rest of
|
|
36
|
+
* the session's cookies stay coherent) but reported in `skipped` with the
|
|
37
|
+
* reason `dbsc-bound`, because they will not survive rotation in the new
|
|
38
|
+
* profile.
|
|
39
|
+
* 2. By registration: if the source profile stores a DBSC registration
|
|
40
|
+
* database (`DeviceBoundSessions`, written under the profile's `Network`
|
|
41
|
+
* directory), a warning is added noting that any cookie covered by a bound
|
|
42
|
+
* session is subject to the same limitation. The registration itself is not
|
|
43
|
+
* copied because it is bound to the source device's key.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Rotating Google session-token cookies that DBSC binds to the device key. */
|
|
47
|
+
export const DBSC_BOUND_COOKIE_NAMES = Object.freeze([
|
|
48
|
+
'__Secure-1PSIDTS',
|
|
49
|
+
'__Secure-3PSIDTS',
|
|
50
|
+
'SIDTS',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** DBSC registration databases Chrome may write in the profile. */
|
|
54
|
+
const DBSC_REGISTRATION_FILES = [
|
|
55
|
+
'Network/DeviceBoundSessions',
|
|
56
|
+
'DeviceBoundSessions',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function isGoogleHost(domain) {
|
|
60
|
+
const host = (domain ?? '').replace(/^\./, '').toLowerCase();
|
|
61
|
+
return (
|
|
62
|
+
host === 'google.com' ||
|
|
63
|
+
host.endsWith('.google.com') ||
|
|
64
|
+
/(^|\.)google\.[a-z.]+$/.test(host)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decide whether a cookie is a DBSC-bound Google session cookie.
|
|
70
|
+
*
|
|
71
|
+
* @param {{name: string, domain: string}} cookie
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function isDbscBoundCookie(cookie) {
|
|
75
|
+
return (
|
|
76
|
+
isGoogleHost(cookie.domain) && DBSC_BOUND_COOKIE_NAMES.includes(cookie.name)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function findDbscRegistration(profileDir) {
|
|
81
|
+
for (const relative of DBSC_REGISTRATION_FILES) {
|
|
82
|
+
const candidate = path.join(profileDir, relative);
|
|
83
|
+
if (await pathExists(candidate)) {
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read cookies from the source browser, tag the DBSC-bound ones, and return the
|
|
92
|
+
* cookies to seed plus the migration report fragment.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} options
|
|
95
|
+
* @param {string} options.browser - chrome | edge | brave | chromium
|
|
96
|
+
* @param {string} [options.profile] - Source profile name
|
|
97
|
+
* @param {string} [options.sourceProfileDir] - Source profile directory (for DBSC detection)
|
|
98
|
+
* @param {string[]} [options.domains] - Optional domain filter (substring match)
|
|
99
|
+
* @param {Function} [options.readCookies=readBrowserCookies] - Injectable reader
|
|
100
|
+
* @param {Object} [options.readerOptions] - Extra options for the reader (platform, homeDir, ...)
|
|
101
|
+
* @returns {Promise<{cookies: Object[], migrated: number, skipped: Array, warnings: Array}>}
|
|
102
|
+
*/
|
|
103
|
+
export async function migrateCookies({
|
|
104
|
+
browser,
|
|
105
|
+
profile,
|
|
106
|
+
sourceProfileDir,
|
|
107
|
+
domains,
|
|
108
|
+
readCookies = readBrowserCookies,
|
|
109
|
+
readerOptions = {},
|
|
110
|
+
}) {
|
|
111
|
+
const domainFilters =
|
|
112
|
+
Array.isArray(domains) && domains.length > 0 ? domains : [null];
|
|
113
|
+
|
|
114
|
+
const seen = new Map();
|
|
115
|
+
for (const domainFilter of domainFilters) {
|
|
116
|
+
const batch = await readCookies({
|
|
117
|
+
browser,
|
|
118
|
+
profile,
|
|
119
|
+
domainFilter: domainFilter ?? undefined,
|
|
120
|
+
ignoreDecryptionErrors: true,
|
|
121
|
+
...readerOptions,
|
|
122
|
+
});
|
|
123
|
+
for (const cookie of batch) {
|
|
124
|
+
seen.set(
|
|
125
|
+
`${cookie.domain}\u0000${cookie.name}\u0000${cookie.path}`,
|
|
126
|
+
cookie
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const cookies = [...seen.values()];
|
|
131
|
+
|
|
132
|
+
const skipped = [];
|
|
133
|
+
for (const cookie of cookies) {
|
|
134
|
+
if (isDbscBoundCookie(cookie)) {
|
|
135
|
+
skipped.push({
|
|
136
|
+
type: 'cookies',
|
|
137
|
+
item: `${cookie.domain} ${cookie.name}`,
|
|
138
|
+
reason: 'dbsc-bound',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const warnings = [];
|
|
144
|
+
if (sourceProfileDir && (await findDbscRegistration(sourceProfileDir))) {
|
|
145
|
+
warnings.push({
|
|
146
|
+
type: 'cookies',
|
|
147
|
+
item: 'DeviceBoundSessions',
|
|
148
|
+
reason: 'dbsc-registration-present',
|
|
149
|
+
detail:
|
|
150
|
+
'The source profile has a Device Bound Session registration; cookies covered by it are bound to the source device key and will expire in the migrated profile.',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { cookies, migrated: cookies.length, skipped, warnings };
|
|
155
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathExists, readJsonIfPresent } from './fs-utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extension migration.
|
|
7
|
+
*
|
|
8
|
+
* A Chromium extension lives in two places in a profile:
|
|
9
|
+
*
|
|
10
|
+
* - its unpacked files under `Extensions/<id>/<version>/`, and
|
|
11
|
+
* - a bookkeeping entry under `extensions.settings.<id>` in `Secure
|
|
12
|
+
* Preferences` (which records the install location, version, granted
|
|
13
|
+
* permissions and enabled state).
|
|
14
|
+
*
|
|
15
|
+
* This module copies both: the extension's version directory and the
|
|
16
|
+
* `extensions.settings` entry.
|
|
17
|
+
*
|
|
18
|
+
* ## Secure Preferences MAC (honest limitation)
|
|
19
|
+
*
|
|
20
|
+
* `Secure Preferences` is protected by a per-entry HMAC ("MAC"). Chrome
|
|
21
|
+
* computes each MAC with a seed built from a hard-coded key baked into the
|
|
22
|
+
* browser binary combined with a per-profile/OS identifier (on Windows the seed
|
|
23
|
+
* is additionally tied to the machine SID via the `machine_id`). Because that
|
|
24
|
+
* seed differs for the dedicated target profile, a MAC copied from the source
|
|
25
|
+
* profile will not validate, and Chrome treats an entry with an invalid MAC as
|
|
26
|
+
* tampering: it disables (or silently drops) the extension on the next launch
|
|
27
|
+
* and shows the "settings were changed" reset bubble.
|
|
28
|
+
*
|
|
29
|
+
* We cannot forge a valid MAC without the browser's embedded key, so the copy
|
|
30
|
+
* is made on a best-effort basis and every migrated extension is reported with
|
|
31
|
+
* a warning explaining that the user will likely have to re-enable it (or
|
|
32
|
+
* re-install it from the Web Store, which restores a valid MAC). This is the
|
|
33
|
+
* honest, documented behaviour rather than a silent partial success.
|
|
34
|
+
*
|
|
35
|
+
* References:
|
|
36
|
+
* - https://chromium.googlesource.com/chromium/src/+/HEAD/services/preferences/tracked/README.md
|
|
37
|
+
* - https://www.chromium.org/developers/design-documents/preferences (tracked/protected preferences)
|
|
38
|
+
*
|
|
39
|
+
* ## Excluded extensions
|
|
40
|
+
*
|
|
41
|
+
* Policy-installed and component/built-in extensions are never copied: they are
|
|
42
|
+
* managed by enterprise policy or shipped with the browser, so copying their
|
|
43
|
+
* files into a user profile is both pointless (the browser re-adds them) and
|
|
44
|
+
* potentially conflicting. They are identified by the `location` value in the
|
|
45
|
+
* settings entry.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
// Chrome's `Manifest::Location` values. Component/built-in and policy-managed
|
|
49
|
+
// extensions are excluded from a migration.
|
|
50
|
+
const EXCLUDED_LOCATIONS = new Set([
|
|
51
|
+
5, // COMPONENT
|
|
52
|
+
7, // EXTERNAL_POLICY_DOWNLOAD
|
|
53
|
+
9, // EXTERNAL_POLICY
|
|
54
|
+
10, // EXTERNAL_COMPONENT
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const SECURE_PREFERENCES = 'Secure Preferences';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide which extension ids are eligible to migrate from a settings map.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} settings - `extensions.settings` from Secure Preferences
|
|
63
|
+
* @returns {{eligible: string[], excluded: Array<{id: string, reason: string}>}}
|
|
64
|
+
*/
|
|
65
|
+
export function selectMigratableExtensions(settings) {
|
|
66
|
+
const eligible = [];
|
|
67
|
+
const excluded = [];
|
|
68
|
+
for (const [id, entry] of Object.entries(settings ?? {})) {
|
|
69
|
+
const location = Number(entry?.location);
|
|
70
|
+
if (EXCLUDED_LOCATIONS.has(location)) {
|
|
71
|
+
excluded.push({ id, reason: 'policy-or-component-extension' });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (entry?.was_installed_by_default === true && !entry?.manifest) {
|
|
75
|
+
excluded.push({ id, reason: 'default-extension' });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
eligible.push(id);
|
|
79
|
+
}
|
|
80
|
+
return { eligible, excluded };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function copyExtensionFiles(
|
|
84
|
+
sourceExtensionsDir,
|
|
85
|
+
targetExtensionsDir,
|
|
86
|
+
id
|
|
87
|
+
) {
|
|
88
|
+
const sourceDir = path.join(sourceExtensionsDir, id);
|
|
89
|
+
if (!(await pathExists(sourceDir))) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
await mkdir(targetExtensionsDir, { recursive: true });
|
|
93
|
+
await cp(sourceDir, path.join(targetExtensionsDir, id), {
|
|
94
|
+
recursive: true,
|
|
95
|
+
force: true,
|
|
96
|
+
});
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Migrate extensions from a source profile into the target profile.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} options
|
|
104
|
+
* @param {string} options.sourceProfileDir
|
|
105
|
+
* @param {string} options.targetProfileDir
|
|
106
|
+
* @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
|
|
107
|
+
*/
|
|
108
|
+
export async function migrateExtensions({
|
|
109
|
+
sourceProfileDir,
|
|
110
|
+
targetProfileDir,
|
|
111
|
+
}) {
|
|
112
|
+
const skipped = [];
|
|
113
|
+
const warnings = [];
|
|
114
|
+
|
|
115
|
+
const sourceExtensionsDir = path.join(sourceProfileDir, 'Extensions');
|
|
116
|
+
if (!(await pathExists(sourceExtensionsDir))) {
|
|
117
|
+
return { migrated: 0, skipped: [], warnings: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const securePrefs = await readJsonIfPresent(
|
|
121
|
+
path.join(sourceProfileDir, SECURE_PREFERENCES)
|
|
122
|
+
);
|
|
123
|
+
const settings = securePrefs?.extensions?.settings ?? {};
|
|
124
|
+
|
|
125
|
+
// Fall back to the on-disk directory listing when there is no settings map
|
|
126
|
+
// (for example a snapshot copied without Secure Preferences).
|
|
127
|
+
let eligible;
|
|
128
|
+
let excluded;
|
|
129
|
+
if (Object.keys(settings).length > 0) {
|
|
130
|
+
({ eligible, excluded } = selectMigratableExtensions(settings));
|
|
131
|
+
} else {
|
|
132
|
+
const entries = await readdir(sourceExtensionsDir, { withFileTypes: true });
|
|
133
|
+
eligible = entries
|
|
134
|
+
.filter((entry) => entry.isDirectory() && entry.name !== 'Temp')
|
|
135
|
+
.map((entry) => entry.name);
|
|
136
|
+
excluded = [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const { id, reason } of excluded) {
|
|
140
|
+
skipped.push({ type: 'extensions', item: id, reason });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const targetExtensionsDir = path.join(targetProfileDir, 'Extensions');
|
|
144
|
+
const migratedSettings = {};
|
|
145
|
+
let migrated = 0;
|
|
146
|
+
for (const id of eligible) {
|
|
147
|
+
const copied = await copyExtensionFiles(
|
|
148
|
+
sourceExtensionsDir,
|
|
149
|
+
targetExtensionsDir,
|
|
150
|
+
id
|
|
151
|
+
);
|
|
152
|
+
if (!copied) {
|
|
153
|
+
skipped.push({ type: 'extensions', item: id, reason: 'files-missing' });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (settings[id]) {
|
|
157
|
+
migratedSettings[id] = settings[id];
|
|
158
|
+
}
|
|
159
|
+
migrated += 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (migrated > 0) {
|
|
163
|
+
// Best-effort: write the settings entries into the target Secure
|
|
164
|
+
// Preferences so Chrome knows about the extensions. The MAC will not
|
|
165
|
+
// validate for the target profile (see the module comment), so warn.
|
|
166
|
+
if (Object.keys(migratedSettings).length > 0) {
|
|
167
|
+
const targetSecurePath = path.join(targetProfileDir, SECURE_PREFERENCES);
|
|
168
|
+
const targetSecure = (await readJsonIfPresent(targetSecurePath)) ?? {};
|
|
169
|
+
targetSecure.extensions ??= {};
|
|
170
|
+
targetSecure.extensions.settings = {
|
|
171
|
+
...(targetSecure.extensions.settings ?? {}),
|
|
172
|
+
...migratedSettings,
|
|
173
|
+
};
|
|
174
|
+
await mkdir(targetProfileDir, { recursive: true });
|
|
175
|
+
await writeFile(targetSecurePath, JSON.stringify(targetSecure));
|
|
176
|
+
}
|
|
177
|
+
warnings.push({
|
|
178
|
+
type: 'extensions',
|
|
179
|
+
item: 'Secure Preferences MAC',
|
|
180
|
+
reason: 'mac-will-not-validate',
|
|
181
|
+
detail:
|
|
182
|
+
'Extension files and settings were copied, but Chrome computes a per-profile HMAC over Secure Preferences that cannot be reproduced for the target profile. Chrome will likely disable the migrated extensions on first launch; re-enable them or reinstall from the Web Store to restore a valid MAC.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { migrated, skipped, warnings };
|
|
187
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a Firefox `places.sqlite` bookmark tree into Chrome's `Bookmarks`
|
|
3
|
+
* JSON format.
|
|
4
|
+
*
|
|
5
|
+
* Firefox keeps bookmarks in `moz_bookmarks` (a tree of folders/rows, type 1 =
|
|
6
|
+
* bookmark, type 2 = folder) joined to `moz_places` for the URLs. Chrome keeps
|
|
7
|
+
* them in a JSON document with three roots (`bookmark_bar`, `other`,
|
|
8
|
+
* `synced`). The Firefox toolbar maps to Chrome's bookmarks bar; the Firefox
|
|
9
|
+
* menu and unsorted bookmarks map to Chrome's "other bookmarks".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Chrome stores timestamps as microseconds since 1601-01-01; a fixed, valid
|
|
13
|
+
// value is fine for imported bookmarks (Chrome only needs it to be parseable).
|
|
14
|
+
const CHROME_TIMESTAMP = '13300000000000000';
|
|
15
|
+
|
|
16
|
+
// Firefox root ids (stable across profiles).
|
|
17
|
+
const FIREFOX_ROOT = Object.freeze({
|
|
18
|
+
TOOLBAR: 'toolbar',
|
|
19
|
+
MENU: 'menu',
|
|
20
|
+
UNFILED: 'unfiled',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function buildChromeNode(node, idRef) {
|
|
24
|
+
if (node.type === 'url') {
|
|
25
|
+
idRef.value += 1;
|
|
26
|
+
return {
|
|
27
|
+
date_added: CHROME_TIMESTAMP,
|
|
28
|
+
id: String(idRef.value),
|
|
29
|
+
name: node.title ?? node.url ?? '',
|
|
30
|
+
type: 'url',
|
|
31
|
+
url: node.url,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
idRef.value += 1;
|
|
35
|
+
const id = String(idRef.value);
|
|
36
|
+
return {
|
|
37
|
+
children: (node.children ?? []).map((child) =>
|
|
38
|
+
buildChromeNode(child, idRef)
|
|
39
|
+
),
|
|
40
|
+
date_added: CHROME_TIMESTAMP,
|
|
41
|
+
date_modified: CHROME_TIMESTAMP,
|
|
42
|
+
id,
|
|
43
|
+
name: node.title ?? '',
|
|
44
|
+
type: 'folder',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the parent→children tree from flat `moz_bookmarks` rows.
|
|
50
|
+
*
|
|
51
|
+
* @param {Array<{id: number, parent: number, type: number, title: string, url: (string|null)}>} rows
|
|
52
|
+
* @returns {Map<number, Array>}
|
|
53
|
+
*/
|
|
54
|
+
function groupByParent(rows) {
|
|
55
|
+
const byParent = new Map();
|
|
56
|
+
for (const row of rows) {
|
|
57
|
+
if (!byParent.has(row.parent)) {
|
|
58
|
+
byParent.set(row.parent, []);
|
|
59
|
+
}
|
|
60
|
+
byParent.get(row.parent).push(row);
|
|
61
|
+
}
|
|
62
|
+
return byParent;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collectChildren(byParent, parentId) {
|
|
66
|
+
const rows = byParent.get(parentId) ?? [];
|
|
67
|
+
return rows.map((row) => {
|
|
68
|
+
if (row.type === 2) {
|
|
69
|
+
return {
|
|
70
|
+
type: 'folder',
|
|
71
|
+
title: row.title,
|
|
72
|
+
children: collectChildren(byParent, row.id),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { type: 'url', title: row.title, url: row.url };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Convert flat `moz_bookmarks` rows (joined with URLs) to a Chrome Bookmarks
|
|
81
|
+
* document.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} options
|
|
84
|
+
* @param {Array} options.rows - Rows with {id, parent, type, title, url, root}
|
|
85
|
+
* @returns {{document: Object, count: number}}
|
|
86
|
+
*/
|
|
87
|
+
export function firefoxBookmarksToChrome({ rows }) {
|
|
88
|
+
const byParent = groupByParent(rows);
|
|
89
|
+
const rootIdByName = {};
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
if (row.root && FIREFOX_ROOT[row.root.toUpperCase()]) {
|
|
92
|
+
rootIdByName[row.root] = row.id;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const idRef = { value: 0 };
|
|
97
|
+
const makeRoot = (name, firefoxRootKey, extraRootKeys = []) => {
|
|
98
|
+
const children = [];
|
|
99
|
+
for (const key of [firefoxRootKey, ...extraRootKeys]) {
|
|
100
|
+
const rootId = rootIdByName[key];
|
|
101
|
+
if (rootId !== undefined && rootId !== null) {
|
|
102
|
+
for (const child of collectChildren(byParent, rootId)) {
|
|
103
|
+
children.push(buildChromeNode(child, idRef));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
idRef.value += 1;
|
|
108
|
+
return {
|
|
109
|
+
children,
|
|
110
|
+
date_added: CHROME_TIMESTAMP,
|
|
111
|
+
date_modified: CHROME_TIMESTAMP,
|
|
112
|
+
id: String(idRef.value),
|
|
113
|
+
name,
|
|
114
|
+
type: 'folder',
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const bookmarkBar = makeRoot('Bookmarks bar', FIREFOX_ROOT.TOOLBAR);
|
|
119
|
+
const other = makeRoot('Other bookmarks', FIREFOX_ROOT.MENU, [
|
|
120
|
+
FIREFOX_ROOT.UNFILED,
|
|
121
|
+
]);
|
|
122
|
+
const synced = makeRoot('Mobile bookmarks', '__none__');
|
|
123
|
+
|
|
124
|
+
const countUrls = (node) => {
|
|
125
|
+
if (node.type === 'url') {
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
return (node.children ?? []).reduce((sum, c) => sum + countUrls(c), 0);
|
|
129
|
+
};
|
|
130
|
+
const count = countUrls(bookmarkBar) + countUrls(other) + countUrls(synced);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
document: {
|
|
134
|
+
checksum: '',
|
|
135
|
+
roots: {
|
|
136
|
+
bookmark_bar: bookmarkBar,
|
|
137
|
+
other,
|
|
138
|
+
synced,
|
|
139
|
+
},
|
|
140
|
+
version: 1,
|
|
141
|
+
},
|
|
142
|
+
count,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny DER (ASN.1) reader, just enough to walk the structures NSS writes into
|
|
3
|
+
* Firefox's `key4.db` and the base64 blobs in `logins.json`. It is deliberately
|
|
4
|
+
* minimal: it understands the tags those structures use (SEQUENCE, OCTET
|
|
5
|
+
* STRING, INTEGER, OBJECT IDENTIFIER) and returns a small tree, so the NSS
|
|
6
|
+
* decryption code can read fields by position without a full ASN.1 library
|
|
7
|
+
* (there is no DER dependency in this project).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DER_TAG = Object.freeze({
|
|
11
|
+
INTEGER: 0x02,
|
|
12
|
+
OCTET_STRING: 0x04,
|
|
13
|
+
NULL: 0x05,
|
|
14
|
+
OID: 0x06,
|
|
15
|
+
SEQUENCE: 0x30,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decode one DER element at `offset`.
|
|
20
|
+
*
|
|
21
|
+
* @param {Buffer} buffer
|
|
22
|
+
* @param {number} [offset=0]
|
|
23
|
+
* @returns {{tag: number, length: number, header: number, contentStart: number, end: number, content: Buffer, children: (Array|null)}}
|
|
24
|
+
*/
|
|
25
|
+
export function decodeDerElement(buffer, offset = 0) {
|
|
26
|
+
const tag = buffer[offset];
|
|
27
|
+
let cursor = offset + 1;
|
|
28
|
+
let length = buffer[cursor];
|
|
29
|
+
cursor += 1;
|
|
30
|
+
if (length & 0x80) {
|
|
31
|
+
const lengthBytes = length & 0x7f;
|
|
32
|
+
length = 0;
|
|
33
|
+
for (let i = 0; i < lengthBytes; i += 1) {
|
|
34
|
+
length = length * 256 + buffer[cursor];
|
|
35
|
+
cursor += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const contentStart = cursor;
|
|
39
|
+
const end = contentStart + length;
|
|
40
|
+
const content = buffer.subarray(contentStart, end);
|
|
41
|
+
const constructed = (tag & 0x20) !== 0;
|
|
42
|
+
return {
|
|
43
|
+
tag,
|
|
44
|
+
length,
|
|
45
|
+
header: contentStart - offset,
|
|
46
|
+
contentStart,
|
|
47
|
+
end,
|
|
48
|
+
content,
|
|
49
|
+
children: constructed ? decodeDerSequence(content) : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decode every element in a constructed value's content.
|
|
55
|
+
*
|
|
56
|
+
* @param {Buffer} content
|
|
57
|
+
* @returns {Array}
|
|
58
|
+
*/
|
|
59
|
+
export function decodeDerSequence(content) {
|
|
60
|
+
const elements = [];
|
|
61
|
+
let offset = 0;
|
|
62
|
+
while (offset < content.length) {
|
|
63
|
+
const element = decodeDerElement(content, offset);
|
|
64
|
+
elements.push(element);
|
|
65
|
+
offset = element.end;
|
|
66
|
+
}
|
|
67
|
+
return elements;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Format an OBJECT IDENTIFIER element's bytes as a dotted string. */
|
|
71
|
+
export function oidToString(content) {
|
|
72
|
+
const first = Math.floor(content[0] / 40);
|
|
73
|
+
const second = content[0] % 40;
|
|
74
|
+
const parts = [first, second];
|
|
75
|
+
let value = 0;
|
|
76
|
+
for (let i = 1; i < content.length; i += 1) {
|
|
77
|
+
value = value * 128 + (content[i] & 0x7f);
|
|
78
|
+
if ((content[i] & 0x80) === 0) {
|
|
79
|
+
parts.push(value);
|
|
80
|
+
value = 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return parts.join('.');
|
|
84
|
+
}
|