browser-commander 0.23.0 → 0.25.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.
- package/.jscpd-baseline.json +48 -30
- package/CHANGELOG.md +32 -0
- package/README.md +19 -7
- package/eslint.config.js +1 -1
- package/examples/react-test-app/package-lock.json +890 -0
- package/examples/react-test-app/package.json +4 -4
- package/package.json +20 -17
- package/src/browser/attach/snapshot.js +4 -28
- package/src/browser/browser-cookie-credentials.js +15 -8
- package/src/browser/browser-cookies.js +36 -30
- package/src/browser/browser-profile-files.js +31 -0
- package/src/browser/browser-profiles.js +11 -13
- package/src/browser/browser-sources.js +39 -9
- package/src/browser/browser-sources.json +600 -23
- package/src/browser/connector.js +46 -4
- package/src/browser/launcher.js +26 -1
- package/src/browser/migration/capabilities.json +117 -0
- package/src/browser/migration/chromium-history.sql +22 -0
- package/src/browser/migration/chromium-writers.js +85 -0
- package/src/browser/migration/cookies.js +4 -1
- package/src/browser/migration/data-classes.js +68 -0
- package/src/browser/migration/domains.js +32 -0
- package/src/browser/migration/firefox-history.js +106 -0
- package/src/browser/migration/firefox.js +39 -25
- package/src/browser/migration/history.js +161 -2
- package/src/browser/migration/index.js +229 -83
- package/src/browser/migration/os-crypt-keys.js +1 -5
- package/src/browser/migration/password-metadata.js +95 -0
- package/src/browser/migration/passwords.js +78 -41
- package/src/browser/migration/safari-import.js +100 -0
- package/src/browser/migration/safari.js +110 -0
- package/src/browser/migration/sqlite-snapshot.js +31 -65
- package/src/browser/migration/validation.js +48 -0
- package/src/browser/real-browser.js +21 -3
- package/src/browser/safari-access.js +45 -0
- package/src/browser/safari-cookies.js +39 -36
- package/src/browser/safari-profiles.js +83 -0
- package/src/browser/storage-state.js +27 -0
- package/src/browser/system-browser.js +31 -143
- package/src/browser/webdriver.js +18 -2
- package/src/cli/args.js +7 -0
- package/src/cli/commands.js +12 -6
- package/src/cli/handle-methods.js +19 -2
- package/src/cli/high-level-methods.js +22 -3
- package/src/cli/modules.js +5 -1
- package/src/cli/sessions.js +14 -2
- package/src/tests/index.js +1 -0
- package/tests/e2e/engine-matrix.e2e.test.js +107 -0
- package/tests/e2e/playwright.e2e.test.js +11 -17
- package/tests/e2e/puppeteer.e2e.test.js +13 -17
- package/tests/unit/browser/attach/snapshot.test.js +30 -0
- package/tests/unit/browser/browser-cookies.test.js +35 -0
- package/tests/unit/browser/catalogue-launch.test.js +93 -0
- package/tests/unit/browser/cookie-sources.test.js +92 -17
- package/tests/unit/browser/credential-catalogue.test.js +50 -0
- package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
- package/tests/unit/browser/migration/data-classes.test.js +89 -0
- package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
- package/tests/unit/browser/migration/firefox-history.test.js +130 -0
- package/tests/unit/browser/migration/history-metadata.test.js +74 -0
- package/tests/unit/browser/migration/index.test.js +16 -1
- package/tests/unit/browser/migration/opera-keys.test.js +22 -0
- package/tests/unit/browser/migration/safari-data.test.js +143 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
- package/tests/unit/browser/migration/validation.test.js +63 -0
- package/tests/unit/browser/migration/yandex.test.js +84 -0
- package/tests/unit/browser/real-browser.test.js +19 -0
- package/tests/unit/browser/safari-cookies.test.js +26 -1
- package/tests/unit/browser/safari-profiles.test.js +111 -0
- package/tests/unit/cli/dispatcher.test.js +6 -2
- package/tests/unit/cli/main.test.js +5 -0
- package/tests/unit/cli/selenium-parity.test.js +186 -0
- package/tests/unit/scripts/browser-support.test.js +14 -7
- package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
- package/tests/unit/scripts/migration-support.test.js +31 -0
- package/tests/unit/tests/index.test.js +2 -1
- package/tests/unit/traces/links.test.js +2 -2
package/src/browser/connector.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { attachDownloads } from '../downloads/attach.js';
|
|
2
|
+
import { connectWebDriver, launchWebDriver } from './webdriver.js';
|
|
2
3
|
import {
|
|
3
4
|
loadStorageState,
|
|
4
5
|
restorePlaywrightStorageState,
|
|
5
6
|
restorePuppeteerStorageState,
|
|
7
|
+
restoreWebDriverStorageState,
|
|
6
8
|
} from './storage-state.js';
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -11,9 +13,9 @@ import {
|
|
|
11
13
|
* @param {string} engine
|
|
12
14
|
*/
|
|
13
15
|
export function assertSupportedEngine(engine) {
|
|
14
|
-
if (!['playwright', 'puppeteer'].includes(engine)) {
|
|
16
|
+
if (!['playwright', 'puppeteer', 'selenium'].includes(engine)) {
|
|
15
17
|
throw new Error(
|
|
16
|
-
`Invalid engine: ${engine}. Expected 'playwright' or '
|
|
18
|
+
`Invalid engine: ${engine}. Expected 'playwright', 'puppeteer' or 'selenium'`
|
|
17
19
|
);
|
|
18
20
|
}
|
|
19
21
|
}
|
|
@@ -28,6 +30,28 @@ function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) {
|
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
async function connectSelenium(options, dependencies) {
|
|
34
|
+
if (Boolean(options.serverUrl) === Boolean(options.cdpEndpoint)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'selenium requires exactly one of serverUrl or cdpEndpoint'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (options.wsEndpoint) {
|
|
40
|
+
throw new Error('selenium requires an HTTP cdpEndpoint or serverUrl');
|
|
41
|
+
}
|
|
42
|
+
const connected = options.serverUrl
|
|
43
|
+
? await (dependencies.connectWebDriver ?? connectWebDriver)(options)
|
|
44
|
+
: await (dependencies.launchWebDriver ?? launchWebDriver)({
|
|
45
|
+
...options,
|
|
46
|
+
debuggerAddress: new URL(options.cdpEndpoint).host,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
...connected,
|
|
50
|
+
browser: connected.driver,
|
|
51
|
+
disconnect: connected.close,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
function addDefinedOptions(options, values) {
|
|
32
56
|
for (const [key, value] of Object.entries(values)) {
|
|
33
57
|
if (value !== undefined) {
|
|
@@ -135,10 +159,12 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
|
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
/**
|
|
138
|
-
* Attach
|
|
162
|
+
* Attach over CDP or create a native session on a WebDriver server.
|
|
139
163
|
*
|
|
140
164
|
* @param {Object} options - Connection options
|
|
141
|
-
* @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
|
|
165
|
+
* @param {'playwright'|'puppeteer'|'selenium'} [options.engine='playwright'] - Automation engine
|
|
166
|
+
* @param {string} [options.serverUrl] - Selenium Grid or WebDriver server URL
|
|
167
|
+
* @param {Object} [options.capabilities] - Native WebDriver capabilities
|
|
142
168
|
* @param {string} [options.cdpEndpoint] - HTTP DevTools endpoint, such as http://127.0.0.1:9222
|
|
143
169
|
* @param {string} [options.wsEndpoint] - DevTools browser WebSocket endpoint
|
|
144
170
|
* @param {number} [options.slowMo] - Delay engine operations by this many milliseconds
|
|
@@ -171,6 +197,22 @@ export async function connectBrowserWithDependencies(
|
|
|
171
197
|
verbose: false,
|
|
172
198
|
...options,
|
|
173
199
|
};
|
|
200
|
+
if (normalizedOptions.engine === 'selenium') {
|
|
201
|
+
assertSupportedEngine(normalizedOptions.engine);
|
|
202
|
+
const storageState = await prepareStorageState(normalizedOptions);
|
|
203
|
+
const result = await connectSelenium(normalizedOptions, dependencies);
|
|
204
|
+
try {
|
|
205
|
+
await restoreWebDriverStorageState({ page: result.page, storageState });
|
|
206
|
+
const downloads = await attachDownloads({
|
|
207
|
+
...normalizedOptions,
|
|
208
|
+
...result,
|
|
209
|
+
});
|
|
210
|
+
return { ...result, downloads };
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await result.close();
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
174
216
|
validateConnectionOptions(normalizedOptions);
|
|
175
217
|
|
|
176
218
|
const storageState = await prepareStorageState(normalizedOptions);
|
package/src/browser/launcher.js
CHANGED
|
@@ -16,10 +16,12 @@ import { resolveRestrictions } from './restrictions.js';
|
|
|
16
16
|
import { resolveSystemBrowserExecutable } from './system-browser.js';
|
|
17
17
|
import { applyFingerprint } from '../fingerprint/apply.js';
|
|
18
18
|
import { attachDownloads } from '../downloads/attach.js';
|
|
19
|
+
import { launchWebDriver } from './webdriver.js';
|
|
19
20
|
import {
|
|
20
21
|
loadStorageState,
|
|
21
22
|
restorePlaywrightStorageState,
|
|
22
23
|
restorePuppeteerStorageState,
|
|
24
|
+
restoreWebDriverStorageState,
|
|
23
25
|
} from './storage-state.js';
|
|
24
26
|
|
|
25
27
|
/** How `launchBrowser` starts the browser (issue #103). */
|
|
@@ -131,6 +133,13 @@ async function launchReal(options, dependencies) {
|
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
async function launchWithEngine(options, dependencies) {
|
|
136
|
+
if (options.engine === 'selenium') {
|
|
137
|
+
const launched = await (dependencies.launchWebDriver ?? launchWebDriver)({
|
|
138
|
+
...options,
|
|
139
|
+
args: [...options.args, ...options.extraArgs],
|
|
140
|
+
});
|
|
141
|
+
return { ...launched, browser: launched.driver };
|
|
142
|
+
}
|
|
134
143
|
const {
|
|
135
144
|
engine,
|
|
136
145
|
userDataDir: requestedUserDataDir,
|
|
@@ -261,7 +270,7 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
|
|
|
261
270
|
* limitations.json (`engine-launch-switches`).
|
|
262
271
|
*
|
|
263
272
|
* @param {Object} options - Configuration options
|
|
264
|
-
* @param {string} [options.engine='playwright'] - Browser automation engine: 'playwright' or '
|
|
273
|
+
* @param {string} [options.engine='playwright'] - Browser automation engine: 'playwright', 'puppeteer' or 'selenium'
|
|
265
274
|
* @param {'real'|'engine'} [options.launch='real'] - Who starts the browser: Browser Commander (a hand-started command line) or the automation engine
|
|
266
275
|
* @param {string} [options.userDataDir] - Persistent profile directory. When omitted a fresh temporary profile is created and deleted on close.
|
|
267
276
|
* @param {boolean} [options.headless=false] - Run in headless mode
|
|
@@ -309,6 +318,16 @@ export async function launchBrowserWithDependencies(
|
|
|
309
318
|
...rest
|
|
310
319
|
} = options;
|
|
311
320
|
validateLaunchMode({ engine, launch, attach: rest.attach });
|
|
321
|
+
if (engine === 'selenium') {
|
|
322
|
+
if (colorScheme !== undefined || fingerprint !== undefined || downloads) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
'selenium does not support CDP emulation, fingerprint overrides or managed downloads (webdriver-no-cdp-emulation)'
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (launch === 'real' && rest.browser === 'firefox') {
|
|
328
|
+
throw new Error("selenium Firefox requires launch: 'engine'");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
312
331
|
// Validate arguments before anything is started or written to disk.
|
|
313
332
|
resolveChromeArgs({ args, extraArgs, ignoreDefaultArgs, restrictions });
|
|
314
333
|
const resolvedStorageState = await loadStorageState(storageState);
|
|
@@ -341,6 +360,12 @@ export async function launchBrowserWithDependencies(
|
|
|
341
360
|
}
|
|
342
361
|
|
|
343
362
|
try {
|
|
363
|
+
if (engine === 'selenium' && launch === 'engine') {
|
|
364
|
+
await restoreWebDriverStorageState({
|
|
365
|
+
page,
|
|
366
|
+
storageState: resolvedStorageState,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
344
369
|
// A Playwright-launched context applies colorScheme itself; an attached
|
|
345
370
|
// browser and Puppeteer need page-level emulation.
|
|
346
371
|
const emulateColorScheme =
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"classes": [
|
|
4
|
+
"cookies",
|
|
5
|
+
"bookmarks",
|
|
6
|
+
"history",
|
|
7
|
+
"passwords",
|
|
8
|
+
"preferences",
|
|
9
|
+
"extensions",
|
|
10
|
+
"localStorage",
|
|
11
|
+
"indexedDB",
|
|
12
|
+
"sessionStorage",
|
|
13
|
+
"autofill",
|
|
14
|
+
"paymentCards",
|
|
15
|
+
"searchEngines",
|
|
16
|
+
"siteSettings",
|
|
17
|
+
"openTabs",
|
|
18
|
+
"downloads",
|
|
19
|
+
"readingList",
|
|
20
|
+
"clientCertificates",
|
|
21
|
+
"passkeys"
|
|
22
|
+
],
|
|
23
|
+
"sourceFamilies": ["chromium", "firefox", "safari"],
|
|
24
|
+
"targetFamilies": ["chromium", "firefox", "webkit"],
|
|
25
|
+
"imports": {
|
|
26
|
+
"chromium": {
|
|
27
|
+
"chromium": [
|
|
28
|
+
"seed",
|
|
29
|
+
"copy",
|
|
30
|
+
"snapshot",
|
|
31
|
+
"rekey",
|
|
32
|
+
"copy",
|
|
33
|
+
"copy",
|
|
34
|
+
"unsupported",
|
|
35
|
+
"unsupported",
|
|
36
|
+
"unsupported",
|
|
37
|
+
"unsupported",
|
|
38
|
+
"consent/unsupported",
|
|
39
|
+
"unsupported",
|
|
40
|
+
"unsupported",
|
|
41
|
+
"unsupported",
|
|
42
|
+
"unsupported",
|
|
43
|
+
"unsupported",
|
|
44
|
+
"unsupported",
|
|
45
|
+
"non-exportable"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"firefox": {
|
|
49
|
+
"chromium": [
|
|
50
|
+
"seed",
|
|
51
|
+
"translate",
|
|
52
|
+
"translate",
|
|
53
|
+
"NSS/rekey",
|
|
54
|
+
"unsupported",
|
|
55
|
+
"unsupported",
|
|
56
|
+
"unsupported",
|
|
57
|
+
"unsupported",
|
|
58
|
+
"unsupported",
|
|
59
|
+
"unsupported",
|
|
60
|
+
"consent/unsupported",
|
|
61
|
+
"unsupported",
|
|
62
|
+
"unsupported",
|
|
63
|
+
"unsupported",
|
|
64
|
+
"unsupported",
|
|
65
|
+
"unsupported",
|
|
66
|
+
"unsupported",
|
|
67
|
+
"non-exportable"
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
"safari": {
|
|
71
|
+
"chromium": [
|
|
72
|
+
"seed",
|
|
73
|
+
"translate",
|
|
74
|
+
"translate",
|
|
75
|
+
"CSV/rekey",
|
|
76
|
+
"unsupported",
|
|
77
|
+
"unsupported",
|
|
78
|
+
"unsupported",
|
|
79
|
+
"unsupported",
|
|
80
|
+
"unsupported",
|
|
81
|
+
"unsupported",
|
|
82
|
+
"consent/unsupported",
|
|
83
|
+
"unsupported",
|
|
84
|
+
"unsupported",
|
|
85
|
+
"unsupported",
|
|
86
|
+
"unsupported",
|
|
87
|
+
"unsupported",
|
|
88
|
+
"unsupported",
|
|
89
|
+
"non-exportable"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"wholeProfileMigration": {
|
|
94
|
+
"chromium": "not implemented",
|
|
95
|
+
"firefox": "not implemented",
|
|
96
|
+
"webkit": "not implemented"
|
|
97
|
+
},
|
|
98
|
+
"pendingClasses": [
|
|
99
|
+
"localStorage",
|
|
100
|
+
"indexedDB",
|
|
101
|
+
"sessionStorage",
|
|
102
|
+
"autofill",
|
|
103
|
+
"paymentCards",
|
|
104
|
+
"searchEngines",
|
|
105
|
+
"siteSettings",
|
|
106
|
+
"openTabs",
|
|
107
|
+
"downloads",
|
|
108
|
+
"readingList",
|
|
109
|
+
"clientCertificates"
|
|
110
|
+
],
|
|
111
|
+
"installedBrowserLaunch": {
|
|
112
|
+
"chromium": "CDP",
|
|
113
|
+
"firefox": "explicit WebDriver setup",
|
|
114
|
+
"safari": "explicit safaridriver setup",
|
|
115
|
+
"detection": "unsupported"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Core tables match Chromium History schema 70 (compatible version 16).
|
|
2
|
+
-- Chromium initializes remaining ancillary tables when it opens the profile.
|
|
3
|
+
CREATE TABLE meta (key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
|
|
4
|
+
INSERT INTO meta VALUES ('version', '70'), ('last_compatible_version', '16');
|
|
5
|
+
CREATE TABLE urls (
|
|
6
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, url LONGVARCHAR, title LONGVARCHAR,
|
|
7
|
+
visit_count INTEGER DEFAULT 0 NOT NULL, typed_count INTEGER DEFAULT 0 NOT NULL,
|
|
8
|
+
last_visit_time INTEGER DEFAULT 0 NOT NULL, hidden INTEGER DEFAULT 0 NOT NULL
|
|
9
|
+
);
|
|
10
|
+
CREATE TABLE visits (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, url INTEGER NOT NULL,
|
|
12
|
+
visit_time INTEGER NOT NULL, from_visit INTEGER DEFAULT 0,
|
|
13
|
+
external_referrer_url TEXT, transition INTEGER DEFAULT 0 NOT NULL,
|
|
14
|
+
segment_id INTEGER DEFAULT 0, visit_duration INTEGER DEFAULT 0 NOT NULL,
|
|
15
|
+
incremented_omnibox_typed_score BOOLEAN DEFAULT FALSE NOT NULL,
|
|
16
|
+
opener_visit INTEGER DEFAULT 0, originator_cache_guid TEXT DEFAULT '',
|
|
17
|
+
originator_visit_id INTEGER DEFAULT 0, originator_from_visit INTEGER DEFAULT 0,
|
|
18
|
+
originator_opener_visit INTEGER DEFAULT 0, is_known_to_sync BOOLEAN DEFAULT FALSE NOT NULL,
|
|
19
|
+
consider_for_ntp_most_visited BOOLEAN DEFAULT FALSE NOT NULL,
|
|
20
|
+
visited_link_id INTEGER DEFAULT 0 NOT NULL, app_id TEXT
|
|
21
|
+
);
|
|
22
|
+
CREATE TABLE visit_source (id INTEGER PRIMARY KEY, source INTEGER NOT NULL);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { openSqliteDatabase } from '../browser-cookie-database.js';
|
|
4
|
+
|
|
5
|
+
export async function writeChromiumBookmarks(targetProfileDir, entries) {
|
|
6
|
+
let id = 3;
|
|
7
|
+
let count = 0;
|
|
8
|
+
function convert(node) {
|
|
9
|
+
const result = {
|
|
10
|
+
id: String(++id),
|
|
11
|
+
date_added: '13300000000000000',
|
|
12
|
+
name: node.name,
|
|
13
|
+
type: node.type,
|
|
14
|
+
};
|
|
15
|
+
if (node.type === 'url') {
|
|
16
|
+
count += 1;
|
|
17
|
+
result.url = node.url;
|
|
18
|
+
} else {
|
|
19
|
+
result.date_modified = result.date_added;
|
|
20
|
+
result.children = (node.children ?? []).map(convert);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
const roots = Object.fromEntries(
|
|
25
|
+
['bookmark_bar', 'other', 'synced'].map((name, index) => [
|
|
26
|
+
name,
|
|
27
|
+
{
|
|
28
|
+
id: String(index + 1),
|
|
29
|
+
name,
|
|
30
|
+
type: 'folder',
|
|
31
|
+
date_added: '13300000000000000',
|
|
32
|
+
date_modified: '13300000000000000',
|
|
33
|
+
children: name === 'other' ? entries.map(convert) : [],
|
|
34
|
+
},
|
|
35
|
+
])
|
|
36
|
+
);
|
|
37
|
+
await mkdir(targetProfileDir, { recursive: true });
|
|
38
|
+
await writeFile(
|
|
39
|
+
path.join(targetProfileDir, 'Bookmarks'),
|
|
40
|
+
JSON.stringify({ version: 1, roots })
|
|
41
|
+
);
|
|
42
|
+
return count;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function writeChromiumHistory(targetProfileDir, entries) {
|
|
46
|
+
await mkdir(targetProfileDir, { recursive: true });
|
|
47
|
+
const db = await openSqliteDatabase(path.join(targetProfileDir, 'History'));
|
|
48
|
+
try {
|
|
49
|
+
db.exec(
|
|
50
|
+
await readFile(new URL('./chromium-history.sql', import.meta.url), 'utf8')
|
|
51
|
+
);
|
|
52
|
+
const urls = new Map();
|
|
53
|
+
const insertUrl = db.prepare(
|
|
54
|
+
'INSERT INTO urls (url,title,visit_count,last_visit_time) VALUES (?,?,0,0)'
|
|
55
|
+
);
|
|
56
|
+
const updateUrl = db.prepare(
|
|
57
|
+
'UPDATE urls SET title=?, visit_count=visit_count+1,last_visit_time=max(last_visit_time,?) WHERE id=?'
|
|
58
|
+
);
|
|
59
|
+
const insertVisit = db.prepare(
|
|
60
|
+
'INSERT INTO visits (url,visit_time) VALUES (?,?)'
|
|
61
|
+
);
|
|
62
|
+
db.exec('BEGIN');
|
|
63
|
+
try {
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!urls.has(entry.url)) {
|
|
66
|
+
urls.set(
|
|
67
|
+
entry.url,
|
|
68
|
+
insertUrl.run(entry.url, entry.title).lastInsertRowid
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const id = urls.get(entry.url);
|
|
72
|
+
const time = BigInt(entry.time) + 11644473600000000n;
|
|
73
|
+
updateUrl.run(entry.title, time, id);
|
|
74
|
+
insertVisit.run(id, time);
|
|
75
|
+
}
|
|
76
|
+
db.exec('COMMIT');
|
|
77
|
+
} catch (error) {
|
|
78
|
+
db.exec('ROLLBACK');
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
db.close();
|
|
83
|
+
}
|
|
84
|
+
return entries.length;
|
|
85
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
import { pathExists } from './fs-utils.js';
|
|
4
|
+
import { matchesDomains } from './domains.js';
|
|
4
5
|
|
|
5
6
|
import { readBrowserCookies } from '../browser-cookies.js';
|
|
6
7
|
|
|
@@ -130,7 +131,9 @@ export async function migrateCookies({
|
|
|
130
131
|
);
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
|
-
const cookies = [...seen.values()]
|
|
134
|
+
const cookies = [...seen.values()].filter((cookie) =>
|
|
135
|
+
matchesDomains(cookie.domain, domains)
|
|
136
|
+
);
|
|
134
137
|
|
|
135
138
|
const skipped = [];
|
|
136
139
|
for (const cookie of cookies) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Explicit reports for selected classes without a native target writer. */
|
|
2
|
+
export const ADDITIONAL_DATA_CLASSES = Object.freeze([
|
|
3
|
+
'localStorage',
|
|
4
|
+
'indexedDB',
|
|
5
|
+
'sessionStorage',
|
|
6
|
+
'autofill',
|
|
7
|
+
'paymentCards',
|
|
8
|
+
'searchEngines',
|
|
9
|
+
'siteSettings',
|
|
10
|
+
'openTabs',
|
|
11
|
+
'downloads',
|
|
12
|
+
'readingList',
|
|
13
|
+
'clientCertificates',
|
|
14
|
+
'passkeys',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function reportAdditionalClasses({
|
|
18
|
+
selected,
|
|
19
|
+
profileDir,
|
|
20
|
+
includePaymentCards,
|
|
21
|
+
}) {
|
|
22
|
+
return ADDITIONAL_DATA_CLASSES.filter((type) => selected.has(type)).flatMap(
|
|
23
|
+
(type) => reportAdditionalClass({ type, profileDir, includePaymentCards })
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function reportAdditionalClass({
|
|
28
|
+
type,
|
|
29
|
+
profileDir,
|
|
30
|
+
includePaymentCards,
|
|
31
|
+
}) {
|
|
32
|
+
const entry = (item, reason, detail) => ({ type, item, reason, detail });
|
|
33
|
+
if (type === 'passkeys') {
|
|
34
|
+
return ['iCloud Keychain', 'Google Password Manager', 'Windows Hello'].map(
|
|
35
|
+
(provider) =>
|
|
36
|
+
entry(
|
|
37
|
+
provider,
|
|
38
|
+
'passkey-not-exportable',
|
|
39
|
+
`${provider} private keys cannot be exported by copying browser profile files. Provider-authorized transfer may be available separately. Sign in once with the platform passkey in a dedicated persistent profile and retain its session.`
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (type === 'paymentCards' && !includePaymentCards) {
|
|
44
|
+
return [
|
|
45
|
+
entry(
|
|
46
|
+
profileDir,
|
|
47
|
+
'payment-card-consent-required',
|
|
48
|
+
'Payment cards require includePaymentCards=true (Python/Rust: include_payment_cards; CLI: --include-payment-cards). Selecting the class alone does not grant consent.'
|
|
49
|
+
),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
if (type === 'clientCertificates') {
|
|
53
|
+
return [
|
|
54
|
+
entry(
|
|
55
|
+
profileDir,
|
|
56
|
+
'client-certificates-os-export-required',
|
|
57
|
+
'Client certificate private keys require an authorized export from the OS or NSS store. Hardware-backed or non-exportable keys cannot be copied from browser profile files.'
|
|
58
|
+
),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
return [
|
|
62
|
+
entry(
|
|
63
|
+
profileDir,
|
|
64
|
+
'data-class-not-supported',
|
|
65
|
+
`A native source reader and compatible target writer for ${type} are not implemented; no data was copied.`
|
|
66
|
+
),
|
|
67
|
+
];
|
|
68
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Site filters match a host or its subdomains, never an unrelated suffix. */
|
|
2
|
+
export function matchesDomains(value, domains) {
|
|
3
|
+
if (!domains?.length) {
|
|
4
|
+
return true;
|
|
5
|
+
}
|
|
6
|
+
let host;
|
|
7
|
+
try {
|
|
8
|
+
host = value.includes('://') ? new URL(value).hostname : value;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
host = host.replace(/^\./, '').toLowerCase().replace(/\.$/, '');
|
|
13
|
+
return domains.some((domain) => {
|
|
14
|
+
const filter = domain.replace(/^\./, '').toLowerCase().replace(/\.$/, '');
|
|
15
|
+
return host === filter || host.endsWith(`.${filter}`);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function validateDomains(domains) {
|
|
20
|
+
if (domains === undefined) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (
|
|
24
|
+
!Array.isArray(domains) ||
|
|
25
|
+
domains.some(
|
|
26
|
+
(domain) =>
|
|
27
|
+
typeof domain !== 'string' || !domain || /[\s/:]/u.test(domain)
|
|
28
|
+
)
|
|
29
|
+
) {
|
|
30
|
+
throw new TypeError('domains must be an array of host names');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { matchesDomains } from './domains.js';
|
|
2
|
+
import { profileFileIfPresent } from './fs-utils.js';
|
|
3
|
+
import { readDatabaseSnapshot } from './sqlite-snapshot.js';
|
|
4
|
+
import { writeChromiumHistory } from './chromium-writers.js';
|
|
5
|
+
import { preserveIntegerPrecision } from '../browser-cookie-database.js';
|
|
6
|
+
|
|
7
|
+
/** Translate individual Firefox visits using a consistent read-only snapshot. */
|
|
8
|
+
export async function migrateFirefoxHistory({
|
|
9
|
+
profileDir,
|
|
10
|
+
targetProfileDir,
|
|
11
|
+
domains,
|
|
12
|
+
}) {
|
|
13
|
+
const sourcePath = await profileFileIfPresent(profileDir, 'places.sqlite');
|
|
14
|
+
const entry = (reason, detail, item = 'places.sqlite') => ({
|
|
15
|
+
type: 'history',
|
|
16
|
+
item,
|
|
17
|
+
reason,
|
|
18
|
+
detail,
|
|
19
|
+
});
|
|
20
|
+
if (!sourcePath) {
|
|
21
|
+
return { migrated: 0, skipped: [entry('source-missing')], warnings: [] };
|
|
22
|
+
}
|
|
23
|
+
const rows = await readDatabaseSnapshot({
|
|
24
|
+
sourcePath,
|
|
25
|
+
read: (db) => {
|
|
26
|
+
for (const [table, required] of [
|
|
27
|
+
['moz_places', ['id', 'url', 'title']],
|
|
28
|
+
['moz_historyvisits', ['id', 'place_id', 'visit_date']],
|
|
29
|
+
]) {
|
|
30
|
+
const columns = new Set(
|
|
31
|
+
db
|
|
32
|
+
.prepare(`PRAGMA table_info(${table})`)
|
|
33
|
+
.all()
|
|
34
|
+
.map(({ name }) => name)
|
|
35
|
+
);
|
|
36
|
+
if (!required.every((name) => columns.has(name))) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return preserveIntegerPrecision(
|
|
41
|
+
db.prepare(
|
|
42
|
+
'SELECT p.url,p.title,v.id,v.visit_date AS time FROM moz_historyvisits v JOIN moz_places p ON v.place_id=p.id ORDER BY v.visit_date,v.id'
|
|
43
|
+
)
|
|
44
|
+
).all();
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
if (rows === null) {
|
|
48
|
+
return {
|
|
49
|
+
migrated: 0,
|
|
50
|
+
skipped: [
|
|
51
|
+
entry(
|
|
52
|
+
'source-format-unsupported',
|
|
53
|
+
'Firefox history requires moz_places and moz_historyvisits with URL, title and integer visit dates; no target was written.'
|
|
54
|
+
),
|
|
55
|
+
],
|
|
56
|
+
warnings: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const skipped = [];
|
|
60
|
+
const visits = [];
|
|
61
|
+
for (const row of rows) {
|
|
62
|
+
if (typeof row.url !== 'string' || !row.url) {
|
|
63
|
+
skipped.push(
|
|
64
|
+
entry(
|
|
65
|
+
'invalid-history-url',
|
|
66
|
+
'The visit URL is not a nonempty string.',
|
|
67
|
+
`places.sqlite visit ${row.id}`
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!matchesDomains(row.url, domains)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
typeof row.time !== 'bigint' ||
|
|
77
|
+
row.time < -11644473600000000n ||
|
|
78
|
+
row.time > 9211727563254775807n
|
|
79
|
+
) {
|
|
80
|
+
skipped.push(
|
|
81
|
+
entry(
|
|
82
|
+
'invalid-history-timestamp',
|
|
83
|
+
'The visit date is not an integer representable in the target history format.',
|
|
84
|
+
`places.sqlite visit ${row.id}`
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
visits.push({ url: row.url, title: row.title ?? '', time: row.time });
|
|
90
|
+
}
|
|
91
|
+
const migrated = visits.length
|
|
92
|
+
? await writeChromiumHistory(targetProfileDir, visits)
|
|
93
|
+
: 0;
|
|
94
|
+
return {
|
|
95
|
+
migrated,
|
|
96
|
+
skipped,
|
|
97
|
+
warnings: migrated
|
|
98
|
+
? [
|
|
99
|
+
entry(
|
|
100
|
+
'firefox-history-metadata-not-translated',
|
|
101
|
+
'URL/title and visit dates were translated; Firefox transition types, referring visits and sync metadata were not copied.'
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
: [],
|
|
105
|
+
};
|
|
106
|
+
}
|