browser-commander 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.jscpd-baseline.json +1 -1
- package/CHANGELOG.md +32 -0
- package/README.md +7 -4
- package/package.json +4 -1
- 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 +610 -25
- package/src/browser/launcher.js +14 -0
- 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 +18 -1
- package/src/browser/safari-access.js +45 -0
- package/src/browser/safari-cookies.js +39 -36
- package/src/browser/safari-launch.js +42 -0
- package/src/browser/safari-profiles.js +83 -0
- package/src/browser/safari-support.js +98 -0
- package/src/browser/storage-state.js +28 -8
- package/src/browser/system-browser.js +31 -143
- package/src/browser/webdriver.js +64 -19
- package/src/cli/args.js +2 -0
- package/src/cli/commands.js +2 -0
- package/src/cli/high-level-methods.js +2 -0
- package/src/cli/sessions.js +5 -0
- package/src/core/webdriver-page.js +62 -0
- package/src/exports.js +6 -0
- package/src/traces/recorder.js +5 -0
- package/tests/e2e/safari.e2e.test.js +104 -0
- 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/browser/safari.test.js +192 -0
- package/tests/unit/browser/webdriver.test.js +48 -3
- package/tests/unit/cli/dispatcher.test.js +5 -1
- package/tests/unit/cli/main.test.js +5 -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/src/browser/launcher.js
CHANGED
|
@@ -17,6 +17,8 @@ import { resolveSystemBrowserExecutable } from './system-browser.js';
|
|
|
17
17
|
import { applyFingerprint } from '../fingerprint/apply.js';
|
|
18
18
|
import { attachDownloads } from '../downloads/attach.js';
|
|
19
19
|
import { launchWebDriver } from './webdriver.js';
|
|
20
|
+
import { launchSafari } from './safari-launch.js';
|
|
21
|
+
import { isSafariChannel } from './safari-support.js';
|
|
20
22
|
import {
|
|
21
23
|
loadStorageState,
|
|
22
24
|
restorePlaywrightStorageState,
|
|
@@ -300,6 +302,18 @@ export async function launchBrowserWithDependencies(
|
|
|
300
302
|
options = {},
|
|
301
303
|
dependencies = {}
|
|
302
304
|
) {
|
|
305
|
+
if (isSafariChannel(options.channel ?? options.browser)) {
|
|
306
|
+
validateLaunchMode({
|
|
307
|
+
engine: options.engine ?? 'playwright',
|
|
308
|
+
launch: options.launch ?? 'real',
|
|
309
|
+
attach: options.attach,
|
|
310
|
+
});
|
|
311
|
+
return await launchSafari(options, dependencies);
|
|
312
|
+
}
|
|
313
|
+
return await launchNonSafariBrowser(options, dependencies);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function launchNonSafariBrowser(options, dependencies) {
|
|
303
317
|
const {
|
|
304
318
|
engine = 'playwright',
|
|
305
319
|
launch = 'real',
|
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { matchesDomains } from './domains.js';
|
|
3
4
|
import { profileFileIfPresent } from './fs-utils.js';
|
|
4
5
|
|
|
5
6
|
import { openSqliteDatabase } from '../browser-cookie-database.js';
|
|
6
|
-
import { mapFirefoxCookieRows } from '../browser-cookies.js';
|
|
7
|
+
import { mapFirefoxCookieRows, readFirefoxRows } from '../browser-cookies.js';
|
|
7
8
|
import { readDatabaseSnapshot } from './sqlite-snapshot.js';
|
|
8
9
|
import { encryptChromiumValue } from './chromium-crypto.js';
|
|
9
10
|
import { firefoxBookmarksToChrome } from './firefox-bookmarks.js';
|
|
@@ -24,8 +25,8 @@ import {
|
|
|
24
25
|
* them over CDP.
|
|
25
26
|
* - **bookmarks:** read from `places.sqlite` (`moz_bookmarks` + `moz_places`)
|
|
26
27
|
* and converted to Chrome's `Bookmarks` JSON.
|
|
27
|
-
* - **history:**
|
|
28
|
-
*
|
|
28
|
+
* - **history:** translated from `moz_historyvisits` into Chrome's `History`,
|
|
29
|
+
* preserving individual visit dates and reporting untranslated metadata.
|
|
29
30
|
* - **passwords:** decrypted from `logins.json` with the NSS key in `key4.db`
|
|
30
31
|
* and re-encrypted into a Chrome `Login Data`. When a primary password is set
|
|
31
32
|
* and not supplied, they are reported as `primary-password-set`.
|
|
@@ -102,20 +103,11 @@ export async function readFirefoxCookies({ profileDir, domains }) {
|
|
|
102
103
|
return readDatabaseSnapshot({
|
|
103
104
|
sourcePath: cookiePath,
|
|
104
105
|
read: (db) => {
|
|
105
|
-
const rows = db
|
|
106
|
-
.prepare(
|
|
107
|
-
`SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite
|
|
108
|
-
FROM moz_cookies ORDER BY host, name, path`
|
|
109
|
-
)
|
|
110
|
-
.all();
|
|
106
|
+
const rows = readFirefoxRows(db);
|
|
111
107
|
// The installed-browser cookie reader owns the row → cookie mapping, so
|
|
112
108
|
// a migrated cookie and an imported one always have the same shape.
|
|
113
109
|
return mapFirefoxCookieRows(
|
|
114
|
-
rows.filter((row) =>
|
|
115
|
-
Array.isArray(domains) && domains.length > 0
|
|
116
|
-
? domains.some((domain) => (row.host ?? '').includes(domain))
|
|
117
|
-
: true
|
|
118
|
-
)
|
|
110
|
+
rows.filter((row) => matchesDomains(row.host ?? '', domains))
|
|
119
111
|
);
|
|
120
112
|
},
|
|
121
113
|
});
|
|
@@ -228,6 +220,7 @@ export async function migrateFirefoxPasswords({
|
|
|
228
220
|
targetKey,
|
|
229
221
|
targetPrefix = platform === 'win32' ? 'v10' : 'v11',
|
|
230
222
|
primaryPassword = Buffer.alloc(0),
|
|
223
|
+
domains,
|
|
231
224
|
}) {
|
|
232
225
|
const loginsPath = await profileFileIfPresent(profileDir, 'logins.json');
|
|
233
226
|
const key4Path = await profileFileIfPresent(profileDir, 'key4.db');
|
|
@@ -271,6 +264,9 @@ export async function migrateFirefoxPasswords({
|
|
|
271
264
|
const decrypted = [];
|
|
272
265
|
const skipped = [];
|
|
273
266
|
for (const login of logins) {
|
|
267
|
+
if (!matchesDomains(login.hostname ?? '', domains)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
274
270
|
try {
|
|
275
271
|
decrypted.push({
|
|
276
272
|
origin: login.hostname,
|
|
@@ -287,6 +283,33 @@ export async function migrateFirefoxPasswords({
|
|
|
287
283
|
}
|
|
288
284
|
}
|
|
289
285
|
|
|
286
|
+
const migrated = await writeChromiumPasswords({
|
|
287
|
+
entries: decrypted,
|
|
288
|
+
targetProfileDir,
|
|
289
|
+
platform,
|
|
290
|
+
targetKey,
|
|
291
|
+
targetPrefix,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const warnings = [];
|
|
295
|
+
if (migrated > 0) {
|
|
296
|
+
warnings.push({
|
|
297
|
+
type: 'passwords',
|
|
298
|
+
item: 'Login Data',
|
|
299
|
+
reason: 'reencrypted-for-chrome',
|
|
300
|
+
detail: `${migrated} Firefox logins were decrypted and re-encrypted into a Chrome Login Data; Chrome may re-key the store on first launch.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return { migrated, skipped, warnings };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function writeChromiumPasswords({
|
|
307
|
+
entries,
|
|
308
|
+
targetProfileDir,
|
|
309
|
+
platform,
|
|
310
|
+
targetKey,
|
|
311
|
+
targetPrefix,
|
|
312
|
+
}) {
|
|
290
313
|
await mkdir(targetProfileDir, { recursive: true });
|
|
291
314
|
const targetPath = path.join(targetProfileDir, 'Login Data');
|
|
292
315
|
const db = await openSqliteDatabase(targetPath);
|
|
@@ -301,7 +324,7 @@ export async function migrateFirefoxPasswords({
|
|
|
301
324
|
date_last_used, date_password_modified
|
|
302
325
|
) VALUES (?, ?, '', ?, '', ?, '', ?, 0, 0, 0, 0, 0, 0, 0)`
|
|
303
326
|
);
|
|
304
|
-
for (const entry of
|
|
327
|
+
for (const entry of entries) {
|
|
305
328
|
const encrypted = encryptChromiumValue({
|
|
306
329
|
plaintext: entry.password,
|
|
307
330
|
key: targetKey,
|
|
@@ -321,14 +344,5 @@ export async function migrateFirefoxPasswords({
|
|
|
321
344
|
db.close();
|
|
322
345
|
}
|
|
323
346
|
|
|
324
|
-
|
|
325
|
-
if (migrated > 0) {
|
|
326
|
-
warnings.push({
|
|
327
|
-
type: 'passwords',
|
|
328
|
-
item: 'Login Data',
|
|
329
|
-
reason: 'reencrypted-for-chrome',
|
|
330
|
-
detail: `${migrated} Firefox logins were decrypted and re-encrypted into a Chrome Login Data; Chrome may re-key the store on first launch.`,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
return { migrated, skipped, warnings };
|
|
347
|
+
return migrated;
|
|
334
348
|
}
|