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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { matchesDomains } from './domains.js';
|
|
2
|
+
|
|
3
|
+
/** Filter Login Data associations after pruning logins; never retain source keys. */
|
|
4
|
+
export async function migratePasswordMetadata({
|
|
5
|
+
db,
|
|
6
|
+
domains,
|
|
7
|
+
rewriteValue,
|
|
8
|
+
skipped,
|
|
9
|
+
warnings,
|
|
10
|
+
}) {
|
|
11
|
+
const tables = db
|
|
12
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
|
13
|
+
.all()
|
|
14
|
+
.map((row) => row.name);
|
|
15
|
+
for (const table of ['password_notes', 'insecure_credentials']) {
|
|
16
|
+
if (tables.includes(table)) {
|
|
17
|
+
db.exec(
|
|
18
|
+
`DELETE FROM ${table} WHERE NOT EXISTS (SELECT 1 FROM logins WHERE logins.id=${table}.parent_id)`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (tables.includes('password_notes')) {
|
|
23
|
+
const rows = db
|
|
24
|
+
.prepare(
|
|
25
|
+
'SELECT password_notes.rowid AS rowid,logins.origin_url,password_notes.value FROM password_notes JOIN logins ON logins.id=password_notes.parent_id'
|
|
26
|
+
)
|
|
27
|
+
.all();
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
const result = await rewriteValue(row.value, row.origin_url);
|
|
30
|
+
if (result.reason) {
|
|
31
|
+
db.prepare('DELETE FROM password_notes WHERE rowid=?').run(row.rowid);
|
|
32
|
+
skipped.push({
|
|
33
|
+
type: 'passwords',
|
|
34
|
+
item: `password_notes/${row.rowid}`,
|
|
35
|
+
...result,
|
|
36
|
+
});
|
|
37
|
+
} else {
|
|
38
|
+
db.prepare('UPDATE password_notes SET value=? WHERE rowid=?').run(
|
|
39
|
+
result.value,
|
|
40
|
+
row.rowid
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const syncTables = [
|
|
46
|
+
'sync_entities_metadata',
|
|
47
|
+
'sync_model_metadata',
|
|
48
|
+
'incoming_sharing_invitation_sync_entities_metadata',
|
|
49
|
+
'incoming_sharing_invitation_sync_model_metadata',
|
|
50
|
+
];
|
|
51
|
+
for (const table of syncTables) {
|
|
52
|
+
if (tables.includes(table)) {
|
|
53
|
+
resetMetadata(db, table, 'sync-metadata-reset', warnings);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!domains?.length) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (tables.includes('stats')) {
|
|
60
|
+
for (const row of db
|
|
61
|
+
.prepare('SELECT rowid AS rowid,origin_domain FROM stats')
|
|
62
|
+
.all()) {
|
|
63
|
+
if (!matchesDomains(row.origin_domain ?? '', domains)) {
|
|
64
|
+
db.prepare('DELETE FROM stats WHERE rowid=?').run(row.rowid);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const known = new Set([
|
|
69
|
+
'logins',
|
|
70
|
+
'meta',
|
|
71
|
+
'password_notes',
|
|
72
|
+
'insecure_credentials',
|
|
73
|
+
'stats',
|
|
74
|
+
...syncTables,
|
|
75
|
+
]);
|
|
76
|
+
for (const table of tables) {
|
|
77
|
+
if (!known.has(table) && !table.startsWith('sqlite_')) {
|
|
78
|
+
resetMetadata(db, table, 'unsupported-password-metadata', warnings);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resetMetadata(db, table, reason, warnings) {
|
|
84
|
+
// Only the identifier comes from the database; quote it rather than execute it.
|
|
85
|
+
const quoted = `"${table.replaceAll('"', '""')}"`;
|
|
86
|
+
const { changes } = db.prepare(`DELETE FROM ${quoted}`).run();
|
|
87
|
+
if (changes) {
|
|
88
|
+
warnings.push({
|
|
89
|
+
type: 'passwords',
|
|
90
|
+
item: table,
|
|
91
|
+
reason,
|
|
92
|
+
detail: `${changes} copied metadata rows removed`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -6,6 +6,8 @@ import { decryptChromiumCookie } from '../browser-cookie-crypto.js';
|
|
|
6
6
|
import { openSqliteDatabase } from '../browser-cookie-database.js';
|
|
7
7
|
import { withDatabaseSnapshot } from './sqlite-snapshot.js';
|
|
8
8
|
import { encryptChromiumValue } from './chromium-crypto.js';
|
|
9
|
+
import { matchesDomains } from './domains.js';
|
|
10
|
+
import { migratePasswordMetadata } from './password-metadata.js';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Saved-password migration.
|
|
@@ -49,6 +51,44 @@ function hostFromOrigin(originUrl) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
async function rewriteSecret(
|
|
55
|
+
value,
|
|
56
|
+
origin,
|
|
57
|
+
{ platform, resolveSourceKey, targetKey, targetPrefix }
|
|
58
|
+
) {
|
|
59
|
+
const encryptedValue = toBuffer(value);
|
|
60
|
+
if (!encryptedValue.length) {
|
|
61
|
+
return { value: encryptedValue };
|
|
62
|
+
}
|
|
63
|
+
const prefix = encryptedValue.subarray(0, 3).toString('ascii');
|
|
64
|
+
if (prefix === 'v20') {
|
|
65
|
+
return { reason: 'app-bound-v20' };
|
|
66
|
+
}
|
|
67
|
+
if (prefix !== 'v10' && prefix !== 'v11') {
|
|
68
|
+
return { reason: 'unsupported-encryption' };
|
|
69
|
+
}
|
|
70
|
+
let plaintext;
|
|
71
|
+
try {
|
|
72
|
+
plaintext = decryptChromiumCookie({
|
|
73
|
+
encryptedValue,
|
|
74
|
+
host: hostFromOrigin(origin),
|
|
75
|
+
databaseVersion: 0,
|
|
76
|
+
platform,
|
|
77
|
+
key: await resolveSourceKey(prefix),
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { reason: 'decrypt-failed', detail: error.message };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
value: encryptChromiumValue({
|
|
84
|
+
plaintext,
|
|
85
|
+
key: targetKey,
|
|
86
|
+
platform,
|
|
87
|
+
prefix: targetPrefix,
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* Migrate saved passwords into the target profile's `Login Data`.
|
|
54
94
|
*
|
|
@@ -69,6 +109,7 @@ export async function migratePasswords({
|
|
|
69
109
|
resolveSourceKey,
|
|
70
110
|
targetKey,
|
|
71
111
|
targetPrefix = platform === 'win32' ? 'v10' : 'v11',
|
|
112
|
+
domains,
|
|
72
113
|
}) {
|
|
73
114
|
const sourcePath = path.join(sourceProfileDir, 'Login Data');
|
|
74
115
|
if (!(await pathExists(sourcePath))) {
|
|
@@ -112,56 +153,52 @@ export async function migratePasswords({
|
|
|
112
153
|
const update = db.prepare(
|
|
113
154
|
'UPDATE logins SET password_value = ? WHERE rowid = ?'
|
|
114
155
|
);
|
|
156
|
+
const remove = db.prepare('DELETE FROM logins WHERE rowid = ?');
|
|
157
|
+
const skip = (row, reason, detail) => {
|
|
158
|
+
remove.run(row.rowid);
|
|
159
|
+
skipped.push({
|
|
160
|
+
type: 'passwords',
|
|
161
|
+
item: row.origin_url ?? '(unknown)',
|
|
162
|
+
reason,
|
|
163
|
+
...(detail ? { detail } : {}),
|
|
164
|
+
});
|
|
165
|
+
};
|
|
115
166
|
for (const row of rows) {
|
|
116
|
-
|
|
117
|
-
|
|
167
|
+
if (!matchesDomains(row.origin_url ?? '', domains)) {
|
|
168
|
+
remove.run(row.rowid);
|
|
118
169
|
continue;
|
|
119
170
|
}
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
skipped.push({
|
|
123
|
-
type: 'passwords',
|
|
124
|
-
item: row.origin_url ?? '(unknown)',
|
|
125
|
-
reason: 'app-bound-v20',
|
|
126
|
-
});
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
if (prefix !== 'v10' && prefix !== 'v11') {
|
|
130
|
-
skipped.push({
|
|
131
|
-
type: 'passwords',
|
|
132
|
-
item: row.origin_url ?? '(unknown)',
|
|
133
|
-
reason: 'unsupported-encryption',
|
|
134
|
-
});
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
let plaintext;
|
|
138
|
-
try {
|
|
139
|
-
const key = await resolveSourceKey(prefix);
|
|
140
|
-
plaintext = decryptChromiumCookie({
|
|
141
|
-
encryptedValue,
|
|
142
|
-
host: hostFromOrigin(row.origin_url),
|
|
143
|
-
databaseVersion: 0,
|
|
144
|
-
platform,
|
|
145
|
-
key,
|
|
146
|
-
});
|
|
147
|
-
} catch (error) {
|
|
148
|
-
skipped.push({
|
|
149
|
-
type: 'passwords',
|
|
150
|
-
item: row.origin_url ?? '(unknown)',
|
|
151
|
-
reason: 'decrypt-failed',
|
|
152
|
-
detail: error.message,
|
|
153
|
-
});
|
|
171
|
+
const encryptedValue = toBuffer(row.password_value);
|
|
172
|
+
if (encryptedValue.length === 0) {
|
|
154
173
|
continue;
|
|
155
174
|
}
|
|
156
|
-
const
|
|
157
|
-
plaintext,
|
|
158
|
-
key: targetKey,
|
|
175
|
+
const result = await rewriteSecret(encryptedValue, row.origin_url, {
|
|
159
176
|
platform,
|
|
160
|
-
|
|
177
|
+
resolveSourceKey,
|
|
178
|
+
targetKey,
|
|
179
|
+
targetPrefix,
|
|
161
180
|
});
|
|
162
|
-
|
|
181
|
+
if (result.reason) {
|
|
182
|
+
skip(row, result.reason, result.detail);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
update.run(result.value, row.rowid);
|
|
163
186
|
migrated += 1;
|
|
164
187
|
}
|
|
188
|
+
await migratePasswordMetadata({
|
|
189
|
+
db,
|
|
190
|
+
domains,
|
|
191
|
+
skipped,
|
|
192
|
+
warnings,
|
|
193
|
+
rewriteValue: (value, origin) =>
|
|
194
|
+
rewriteSecret(value, origin, {
|
|
195
|
+
platform,
|
|
196
|
+
resolveSourceKey,
|
|
197
|
+
targetKey,
|
|
198
|
+
targetPrefix,
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
db.exec('VACUUM');
|
|
165
202
|
} finally {
|
|
166
203
|
db.close();
|
|
167
204
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { findSafariFile } from '../safari-access.js';
|
|
3
|
+
import {
|
|
4
|
+
readSafariBookmarks,
|
|
5
|
+
readSafariHistory,
|
|
6
|
+
readSafariPasswords,
|
|
7
|
+
} from './safari.js';
|
|
8
|
+
import {
|
|
9
|
+
writeChromiumBookmarks,
|
|
10
|
+
writeChromiumHistory,
|
|
11
|
+
} from './chromium-writers.js';
|
|
12
|
+
import { writeChromiumPasswords } from './firefox.js';
|
|
13
|
+
|
|
14
|
+
function findStore(profileDir, name, legacyDir) {
|
|
15
|
+
return findSafariFile([
|
|
16
|
+
path.join(profileDir, 'Safari', name),
|
|
17
|
+
path.join(profileDir, name),
|
|
18
|
+
...(legacyDir ? [path.join(legacyDir, name)] : []),
|
|
19
|
+
]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function migrateSafariClass({
|
|
23
|
+
type,
|
|
24
|
+
profileDir,
|
|
25
|
+
targetProfileDir,
|
|
26
|
+
domains,
|
|
27
|
+
passwordCsv,
|
|
28
|
+
passwordKeys,
|
|
29
|
+
platform,
|
|
30
|
+
legacyDir,
|
|
31
|
+
}) {
|
|
32
|
+
const skipped = (reason, detail) => ({
|
|
33
|
+
migrated: 0,
|
|
34
|
+
skipped: [
|
|
35
|
+
{ type, item: profileDir, reason, ...(detail ? { detail } : {}) },
|
|
36
|
+
],
|
|
37
|
+
warnings: [],
|
|
38
|
+
});
|
|
39
|
+
if (type === 'preferences' || type === 'extensions') {
|
|
40
|
+
return skipped(
|
|
41
|
+
'safari-class-not-supported',
|
|
42
|
+
'Safari preferences and extensions do not use Chromium formats.'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (type === 'passwords') {
|
|
46
|
+
if (!passwordCsv) {
|
|
47
|
+
return skipped(
|
|
48
|
+
'safari-password-export-required',
|
|
49
|
+
'Export Passwords from Safari or the Passwords app to CSV, then supply passwordCsv (CLI: --password-csv).'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const entries = await readSafariPasswords(passwordCsv, domains);
|
|
53
|
+
if (!passwordKeys?.targetKey) {
|
|
54
|
+
return skipped(
|
|
55
|
+
'target-key-unavailable',
|
|
56
|
+
'Supply the dedicated target profile encryption key; no plaintext passwords are written.'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const migrated = await writeChromiumPasswords({
|
|
60
|
+
entries,
|
|
61
|
+
targetProfileDir,
|
|
62
|
+
platform,
|
|
63
|
+
...passwordKeys,
|
|
64
|
+
});
|
|
65
|
+
return { migrated, skipped: [], warnings: [] };
|
|
66
|
+
}
|
|
67
|
+
const filename = await findStore(
|
|
68
|
+
profileDir,
|
|
69
|
+
type === 'bookmarks' ? 'Bookmarks.plist' : 'History.db',
|
|
70
|
+
legacyDir
|
|
71
|
+
);
|
|
72
|
+
if (!filename) {
|
|
73
|
+
return skipped('source-missing');
|
|
74
|
+
}
|
|
75
|
+
if (type === 'bookmarks') {
|
|
76
|
+
const entries = await readSafariBookmarks(filename);
|
|
77
|
+
const hasReadingList = (nodes) =>
|
|
78
|
+
nodes.some(
|
|
79
|
+
(node) => node.readingList || hasReadingList(node.children ?? [])
|
|
80
|
+
);
|
|
81
|
+
const migrated = await writeChromiumBookmarks(targetProfileDir, entries);
|
|
82
|
+
const warnings = hasReadingList(entries)
|
|
83
|
+
? [
|
|
84
|
+
{
|
|
85
|
+
type,
|
|
86
|
+
item: filename,
|
|
87
|
+
reason: 'safari-reading-list-translated',
|
|
88
|
+
detail:
|
|
89
|
+
'Reading-list URLs become bookmarks; read status and preview metadata are not translated.',
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
: [];
|
|
93
|
+
return { migrated, skipped: [], warnings };
|
|
94
|
+
}
|
|
95
|
+
const migrated = await writeChromiumHistory(
|
|
96
|
+
targetProfileDir,
|
|
97
|
+
await readSafariHistory(filename, domains)
|
|
98
|
+
);
|
|
99
|
+
return { migrated, skipped: [], warnings: [] };
|
|
100
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { open, readFile } from 'node:fs/promises';
|
|
2
|
+
import * as bplist from 'bplist-parser';
|
|
3
|
+
import * as plist from 'plist';
|
|
4
|
+
import { parse } from 'csv-parse/sync';
|
|
5
|
+
import { matchesDomains } from './domains.js';
|
|
6
|
+
import { readDatabaseSnapshot } from './sqlite-snapshot.js';
|
|
7
|
+
|
|
8
|
+
import { withSafariAccess } from '../safari-access.js';
|
|
9
|
+
export { withSafariAccess } from '../safari-access.js';
|
|
10
|
+
|
|
11
|
+
export function readSafariPlist(filename) {
|
|
12
|
+
return withSafariAccess(filename, async () => {
|
|
13
|
+
const bytes = await readFile(filename);
|
|
14
|
+
return bytes.subarray(0, 8).toString() === 'bplist00'
|
|
15
|
+
? bplist.parseBuffer(bytes)[0]
|
|
16
|
+
: plist.parse(bytes.toString('utf8'));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Canonical bookmark tree, including nested reading-list entries. */
|
|
21
|
+
export async function readSafariBookmarks(filename) {
|
|
22
|
+
const document = await readSafariPlist(filename);
|
|
23
|
+
function convert(node, readingList = false) {
|
|
24
|
+
const isReadingList =
|
|
25
|
+
readingList ||
|
|
26
|
+
node.Title === 'com.apple.ReadingList' ||
|
|
27
|
+
Boolean(node.ReadingList);
|
|
28
|
+
if (node.WebBookmarkType === 'WebBookmarkTypeLeaf') {
|
|
29
|
+
if (!node.URLString) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
type: 'url',
|
|
34
|
+
name: node.URIDictionary?.title ?? node.Title ?? node.URLString,
|
|
35
|
+
url: node.URLString,
|
|
36
|
+
readingList: isReadingList,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
type: 'folder',
|
|
41
|
+
name: isReadingList ? 'Reading List' : (node.Title ?? ''),
|
|
42
|
+
children: (node.Children ?? [])
|
|
43
|
+
.map((child) => convert(child, isReadingList))
|
|
44
|
+
.filter(Boolean),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return (document.Children ?? []).map((node) => convert(node)).filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** All visits, in Unix microseconds; the source is read through online backup. */
|
|
51
|
+
export function readSafariHistory(filename, domains) {
|
|
52
|
+
return withSafariAccess(filename, async () => {
|
|
53
|
+
// Surface an OS access error before SQLite wraps it as SQLITE_CANTOPEN.
|
|
54
|
+
const file = await open(filename, 'r');
|
|
55
|
+
await file.close();
|
|
56
|
+
return readDatabaseSnapshot({
|
|
57
|
+
sourcePath: filename,
|
|
58
|
+
read: (db) =>
|
|
59
|
+
db
|
|
60
|
+
.prepare(
|
|
61
|
+
`SELECT i.url, v.title, v.visit_time FROM history_items i
|
|
62
|
+
JOIN history_visits v ON i.id = v.history_item ORDER BY v.visit_time, v.id`
|
|
63
|
+
)
|
|
64
|
+
.all()
|
|
65
|
+
.filter((row) => matchesDomains(row.url, domains))
|
|
66
|
+
.map((row) => ({
|
|
67
|
+
url: row.url,
|
|
68
|
+
title: row.title ?? '',
|
|
69
|
+
time: Math.round((row.visit_time + 978307200) * 1000000),
|
|
70
|
+
})),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Only an explicitly supplied Safari/Passwords export is read. */
|
|
76
|
+
export async function readSafariPasswords(filename, domains) {
|
|
77
|
+
const rows = parse(await readFile(filename), {
|
|
78
|
+
columns: (columns) => {
|
|
79
|
+
if (
|
|
80
|
+
!['URL', 'Username', 'Password'].every((column) =>
|
|
81
|
+
columns.includes(column)
|
|
82
|
+
)
|
|
83
|
+
) {
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
'Safari password CSV must have URL, Username and Password columns'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return columns;
|
|
89
|
+
},
|
|
90
|
+
bom: true,
|
|
91
|
+
skip_empty_lines: true,
|
|
92
|
+
});
|
|
93
|
+
return rows
|
|
94
|
+
.filter((row) => row.URL && matchesDomains(row.URL, domains))
|
|
95
|
+
.map((row) => {
|
|
96
|
+
if (
|
|
97
|
+
typeof row.Username !== 'string' ||
|
|
98
|
+
typeof row.Password !== 'string'
|
|
99
|
+
) {
|
|
100
|
+
throw new TypeError(
|
|
101
|
+
'Safari password CSV must have URL, Username and Password columns'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
origin: row.URL,
|
|
106
|
+
username: row.Username,
|
|
107
|
+
password: row.Password,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { pathExists } from './fs-utils.js';
|
|
@@ -11,49 +11,12 @@ import { openSqliteDatabase } from '../browser-cookie-database.js';
|
|
|
11
11
|
* Read a Chromium SQLite database (History, Top Sites, Login Data, Web Data)
|
|
12
12
|
* while the source browser is running, without ever writing to the source.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* 1. The SQLite Online Backup API (`better-sqlite3`'s `.backup()`), which
|
|
19
|
-
* copies a transactionally consistent snapshot even while the source is
|
|
20
|
-
* being written. This is the same mechanism `browser-cookie-database.js`
|
|
21
|
-
* relies on for a stable read, and SQLite documents it as the correct way
|
|
22
|
-
* to snapshot a live database
|
|
23
|
-
* (https://www.sqlite.org/backup.html).
|
|
24
|
-
* 2. When the source cannot be opened at all (a Windows exclusive lock), the
|
|
25
|
-
* file and its `-wal`/`-journal` sidecars are copied to a temporary
|
|
26
|
-
* directory and the copy is opened instead. Copying the sidecars keeps the
|
|
27
|
-
* committed-but-not-checkpointed pages, so the copy is consistent.
|
|
28
|
-
*
|
|
29
|
-
* Both paths open the source with `readOnly`, so nothing is written back.
|
|
14
|
+
* SQLite online backup includes committed WAL data without writing to the
|
|
15
|
+
* source. If a sharing/exclusive lock prevents backup, callers receive guidance
|
|
16
|
+
* to close the browser or supply a consistent snapshot. A sequential copy of a
|
|
17
|
+
* live database and its sidecars cannot guarantee consistency.
|
|
30
18
|
*/
|
|
31
19
|
|
|
32
|
-
const SQLITE_SIDECARS = ['-wal', '-shm', '-journal'];
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Copy a SQLite file and its sidecars into a temporary directory.
|
|
36
|
-
*
|
|
37
|
-
* @param {string} sourcePath
|
|
38
|
-
* @returns {Promise<{snapshotPath: string, cleanup: function(): Promise<void>}>}
|
|
39
|
-
*/
|
|
40
|
-
async function copyDatabaseFiles(sourcePath) {
|
|
41
|
-
const dir = await mkdtemp(path.join(os.tmpdir(), 'browser-commander-snap-'));
|
|
42
|
-
const base = path.basename(sourcePath);
|
|
43
|
-
const snapshotPath = path.join(dir, base);
|
|
44
|
-
await copyFile(sourcePath, snapshotPath);
|
|
45
|
-
for (const suffix of SQLITE_SIDECARS) {
|
|
46
|
-
const sidecar = `${sourcePath}${suffix}`;
|
|
47
|
-
if (await pathExists(sidecar)) {
|
|
48
|
-
await copyFile(sidecar, `${snapshotPath}${suffix}`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return {
|
|
52
|
-
snapshotPath,
|
|
53
|
-
cleanup: () => rm(dir, { recursive: true, force: true, maxRetries: 3 }),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
20
|
/**
|
|
58
21
|
* Produce a consistent, read-only snapshot copy of a live Chromium database in
|
|
59
22
|
* a temporary directory, then hand its path to a reader. The snapshot is always
|
|
@@ -74,7 +37,6 @@ export async function withDatabaseSnapshot({ sourcePath, read }) {
|
|
|
74
37
|
const cleanupDir = () =>
|
|
75
38
|
rm(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
76
39
|
|
|
77
|
-
let copyFallback;
|
|
78
40
|
try {
|
|
79
41
|
// The backup API needs to open the source. It opens read-only, so nothing
|
|
80
42
|
// is written to the user's real database.
|
|
@@ -83,31 +45,35 @@ export async function withDatabaseSnapshot({ sourcePath, read }) {
|
|
|
83
45
|
source = new BetterSqlite3(sourcePath, {
|
|
84
46
|
readonly: true,
|
|
85
47
|
fileMustExist: true,
|
|
48
|
+
timeout: 1000,
|
|
86
49
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
50
|
+
// Detect an exclusive lock before better-sqlite3's initial zero-page
|
|
51
|
+
// transfer can mistake SQLITE_BUSY for an empty completed backup.
|
|
52
|
+
source.pragma('schema_version');
|
|
53
|
+
let remaining = Infinity;
|
|
54
|
+
let lastProgress = Date.now();
|
|
55
|
+
await source.backup(snapshotPath, {
|
|
56
|
+
progress: ({ remainingPages }) => {
|
|
57
|
+
if (remainingPages < remaining) {
|
|
58
|
+
lastProgress = Date.now();
|
|
59
|
+
} else if (Date.now() - lastProgress >= 1000) {
|
|
60
|
+
throw new Error('SQLite backup made no progress');
|
|
61
|
+
}
|
|
62
|
+
remaining = remainingPages;
|
|
63
|
+
return 256;
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
} catch (cause) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Consistent SQLite snapshot unavailable for ${sourcePath}; close the source browser and retry, or supply a consistent read-only snapshot. ${cause.message}`,
|
|
69
|
+
{ cause }
|
|
70
|
+
);
|
|
71
|
+
} finally {
|
|
72
|
+
source?.close();
|
|
101
73
|
}
|
|
102
|
-
|
|
103
|
-
const readPath = copyFallback?.snapshotPath ?? snapshotPath;
|
|
104
|
-
return await read(readPath);
|
|
74
|
+
return await read(snapshotPath);
|
|
105
75
|
} finally {
|
|
106
|
-
|
|
107
|
-
await copyFallback.cleanup();
|
|
108
|
-
} else {
|
|
109
|
-
await cleanupDir();
|
|
110
|
-
}
|
|
76
|
+
await cleanupDir();
|
|
111
77
|
}
|
|
112
78
|
}
|
|
113
79
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { physicalPath } from '../browser-profile-files.js';
|
|
3
|
+
import { browserFamily } from '../browser-sources.js';
|
|
4
|
+
import { assertDedicatedUserDataDir } from '../system-browser.js';
|
|
5
|
+
import { validateDomains } from './domains.js';
|
|
6
|
+
|
|
7
|
+
export function validateMigrationOptions({
|
|
8
|
+
include,
|
|
9
|
+
domains,
|
|
10
|
+
targetBrowser,
|
|
11
|
+
to,
|
|
12
|
+
platform,
|
|
13
|
+
homeDir,
|
|
14
|
+
environment,
|
|
15
|
+
classes,
|
|
16
|
+
includePaymentCards = false,
|
|
17
|
+
}) {
|
|
18
|
+
if (typeof includePaymentCards !== 'boolean') {
|
|
19
|
+
throw new TypeError('includePaymentCards must be a boolean');
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
!Array.isArray(include) ||
|
|
23
|
+
include.some((type) => !classes.includes(type))
|
|
24
|
+
) {
|
|
25
|
+
throw new TypeError(
|
|
26
|
+
`include must contain supported data classes: ${classes.join(', ')}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
validateDomains(domains);
|
|
30
|
+
if (targetBrowser && browserFamily(targetBrowser) !== 'chromium') {
|
|
31
|
+
throw new TypeError(
|
|
32
|
+
`Migration target ${targetBrowser} does not yet have a supported target file writer`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
assertDedicatedUserDataDir(to, { platform, homeDir, environment });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateMigrationPaths(source, target) {
|
|
39
|
+
const sourcePath = physicalPath(source);
|
|
40
|
+
const targetPath = physicalPath(target);
|
|
41
|
+
const within = (parent, child) =>
|
|
42
|
+
child === parent || child.startsWith(`${parent}${path.sep}`);
|
|
43
|
+
if (within(sourcePath, targetPath) || within(targetPath, sourcePath)) {
|
|
44
|
+
throw new TypeError(
|
|
45
|
+
'Migration source and target directories must not overlap'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { findBrowserSource } from './browser-sources.js';
|
|
4
|
+
import { launchSafari } from './safari-launch.js';
|
|
5
|
+
import { isSafariChannel } from './safari-support.js';
|
|
3
6
|
|
|
4
7
|
import { startProcess } from '../utilities/subprocess.js';
|
|
5
8
|
import {
|
|
@@ -408,12 +411,19 @@ async function spawnOnFreePort({
|
|
|
408
411
|
/** Reject a launch request before anything touches the disk. */
|
|
409
412
|
function validateLaunchRequest({
|
|
410
413
|
argOptions,
|
|
414
|
+
channel,
|
|
411
415
|
userDataDir,
|
|
412
416
|
remoteDebuggingPort,
|
|
413
417
|
endpoint,
|
|
414
418
|
attach,
|
|
415
419
|
migrateFrom,
|
|
416
420
|
}) {
|
|
421
|
+
const source = findBrowserSource(channel);
|
|
422
|
+
if (source && source.family !== 'chromium') {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`${channel} does not support CDP; use its documented WebDriver setup when available`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
417
427
|
if (endpoint) {
|
|
418
428
|
throw new Error(
|
|
419
429
|
'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint'
|
|
@@ -507,13 +517,16 @@ async function runPreLaunchMigration({
|
|
|
507
517
|
if (!migrateFrom) {
|
|
508
518
|
return { migration: undefined, migratedCookies: [] };
|
|
509
519
|
}
|
|
510
|
-
const { include, domains, ...from } =
|
|
520
|
+
const { include, domains, passwordCsv, includePaymentCards, ...from } =
|
|
521
|
+
migrateFrom;
|
|
511
522
|
try {
|
|
512
523
|
const report = await migrate({
|
|
513
524
|
from,
|
|
514
525
|
to: path.join(userDataDir, 'Default'),
|
|
515
526
|
include,
|
|
516
527
|
domains,
|
|
528
|
+
passwordCsv,
|
|
529
|
+
includePaymentCards,
|
|
517
530
|
targetBrowser: channel,
|
|
518
531
|
});
|
|
519
532
|
const { cookies, ...migration } = report;
|
|
@@ -552,6 +565,9 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
552
565
|
options = {},
|
|
553
566
|
dependencies = {}
|
|
554
567
|
) {
|
|
568
|
+
if (isSafariChannel(options.channel ?? options.browser)) {
|
|
569
|
+
return launchSafari(options, dependencies);
|
|
570
|
+
}
|
|
555
571
|
const {
|
|
556
572
|
engine = 'playwright',
|
|
557
573
|
channel = 'chrome',
|
|
@@ -588,6 +604,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
588
604
|
};
|
|
589
605
|
validateLaunchRequest({
|
|
590
606
|
argOptions,
|
|
607
|
+
channel,
|
|
591
608
|
userDataDir: requestedUserDataDir,
|
|
592
609
|
remoteDebuggingPort,
|
|
593
610
|
endpoint: cdpEndpoint || wsEndpoint,
|