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.
Files changed (72) hide show
  1. package/.jscpd-baseline.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +7 -4
  4. package/package.json +4 -1
  5. package/src/browser/attach/snapshot.js +4 -28
  6. package/src/browser/browser-cookie-credentials.js +15 -8
  7. package/src/browser/browser-cookies.js +36 -30
  8. package/src/browser/browser-profile-files.js +31 -0
  9. package/src/browser/browser-profiles.js +11 -13
  10. package/src/browser/browser-sources.js +39 -9
  11. package/src/browser/browser-sources.json +610 -25
  12. package/src/browser/launcher.js +14 -0
  13. package/src/browser/migration/capabilities.json +117 -0
  14. package/src/browser/migration/chromium-history.sql +22 -0
  15. package/src/browser/migration/chromium-writers.js +85 -0
  16. package/src/browser/migration/cookies.js +4 -1
  17. package/src/browser/migration/data-classes.js +68 -0
  18. package/src/browser/migration/domains.js +32 -0
  19. package/src/browser/migration/firefox-history.js +106 -0
  20. package/src/browser/migration/firefox.js +39 -25
  21. package/src/browser/migration/history.js +161 -2
  22. package/src/browser/migration/index.js +229 -83
  23. package/src/browser/migration/os-crypt-keys.js +1 -5
  24. package/src/browser/migration/password-metadata.js +95 -0
  25. package/src/browser/migration/passwords.js +78 -41
  26. package/src/browser/migration/safari-import.js +100 -0
  27. package/src/browser/migration/safari.js +110 -0
  28. package/src/browser/migration/sqlite-snapshot.js +31 -65
  29. package/src/browser/migration/validation.js +48 -0
  30. package/src/browser/real-browser.js +18 -1
  31. package/src/browser/safari-access.js +45 -0
  32. package/src/browser/safari-cookies.js +39 -36
  33. package/src/browser/safari-launch.js +42 -0
  34. package/src/browser/safari-profiles.js +83 -0
  35. package/src/browser/safari-support.js +98 -0
  36. package/src/browser/storage-state.js +28 -8
  37. package/src/browser/system-browser.js +31 -143
  38. package/src/browser/webdriver.js +64 -19
  39. package/src/cli/args.js +2 -0
  40. package/src/cli/commands.js +2 -0
  41. package/src/cli/high-level-methods.js +2 -0
  42. package/src/cli/sessions.js +5 -0
  43. package/src/core/webdriver-page.js +62 -0
  44. package/src/exports.js +6 -0
  45. package/src/traces/recorder.js +5 -0
  46. package/tests/e2e/safari.e2e.test.js +104 -0
  47. package/tests/unit/browser/attach/snapshot.test.js +30 -0
  48. package/tests/unit/browser/browser-cookies.test.js +35 -0
  49. package/tests/unit/browser/catalogue-launch.test.js +93 -0
  50. package/tests/unit/browser/cookie-sources.test.js +92 -17
  51. package/tests/unit/browser/credential-catalogue.test.js +50 -0
  52. package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
  53. package/tests/unit/browser/migration/data-classes.test.js +89 -0
  54. package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
  55. package/tests/unit/browser/migration/firefox-history.test.js +130 -0
  56. package/tests/unit/browser/migration/history-metadata.test.js +74 -0
  57. package/tests/unit/browser/migration/index.test.js +16 -1
  58. package/tests/unit/browser/migration/opera-keys.test.js +22 -0
  59. package/tests/unit/browser/migration/safari-data.test.js +143 -0
  60. package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
  61. package/tests/unit/browser/migration/validation.test.js +63 -0
  62. package/tests/unit/browser/migration/yandex.test.js +84 -0
  63. package/tests/unit/browser/real-browser.test.js +19 -0
  64. package/tests/unit/browser/safari-cookies.test.js +26 -1
  65. package/tests/unit/browser/safari-profiles.test.js +111 -0
  66. package/tests/unit/browser/safari.test.js +192 -0
  67. package/tests/unit/browser/webdriver.test.js +48 -3
  68. package/tests/unit/cli/dispatcher.test.js +5 -1
  69. package/tests/unit/cli/main.test.js +5 -0
  70. package/tests/unit/scripts/browser-support.test.js +14 -7
  71. package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
  72. 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
- const encryptedValue = toBuffer(row.password_value);
117
- if (encryptedValue.length === 0) {
167
+ if (!matchesDomains(row.origin_url ?? '', domains)) {
168
+ remove.run(row.rowid);
118
169
  continue;
119
170
  }
120
- const prefix = encryptedValue.subarray(0, 3).toString('ascii');
121
- if (prefix === 'v20') {
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 reencrypted = encryptChromiumValue({
157
- plaintext,
158
- key: targetKey,
175
+ const result = await rewriteSecret(encryptedValue, row.origin_url, {
159
176
  platform,
160
- prefix: targetPrefix,
177
+ resolveSourceKey,
178
+ targetKey,
179
+ targetPrefix,
161
180
  });
162
- update.run(reencrypted, row.rowid);
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 { copyFile, mkdtemp, rm } from 'node:fs/promises';
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
- * Chrome keeps these databases open in WAL mode and, on Windows, holds a share
15
- * lock that stops another process from opening the file at all. Two techniques
16
- * cover both cases:
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
- } catch {
88
- source = null;
89
- }
90
- if (source) {
91
- try {
92
- await source.backup(snapshotPath);
93
- } finally {
94
- source.close();
95
- }
96
- } else {
97
- // The source is locked exclusively (Windows); fall back to copying the
98
- // file and its sidecars, then read that copy.
99
- await cleanupDir();
100
- copyFallback = await copyDatabaseFiles(sourcePath);
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
- if (copyFallback) {
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 } = migrateFrom;
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,