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
|
@@ -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,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { findBrowserSource } from './browser-sources.js';
|
|
3
4
|
|
|
4
5
|
import { startProcess } from '../utilities/subprocess.js';
|
|
5
6
|
import {
|
|
@@ -259,6 +260,10 @@ async function waitForExit(browserProcess, timeout) {
|
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
async function requestBrowserClose({ engine, browser, originalClose }) {
|
|
263
|
+
if (engine === 'selenium') {
|
|
264
|
+
await originalClose();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
262
267
|
if (engine === 'puppeteer') {
|
|
263
268
|
// A connected Puppeteer Browser sends Browser.close and waits for it.
|
|
264
269
|
await originalClose.call(browser);
|
|
@@ -278,8 +283,9 @@ function createCloser({
|
|
|
278
283
|
userDataDir,
|
|
279
284
|
temporaryProfile,
|
|
280
285
|
closeTimeout,
|
|
286
|
+
closeConnection,
|
|
281
287
|
}) {
|
|
282
|
-
const originalClose = browser?.close;
|
|
288
|
+
const originalClose = closeConnection ?? browser?.close;
|
|
283
289
|
let closing;
|
|
284
290
|
const close = () => {
|
|
285
291
|
closing ??= (async () => {
|
|
@@ -298,7 +304,7 @@ function createCloser({
|
|
|
298
304
|
})();
|
|
299
305
|
return closing;
|
|
300
306
|
};
|
|
301
|
-
if (browser && originalClose) {
|
|
307
|
+
if (browser && originalClose && engine !== 'selenium') {
|
|
302
308
|
// `browser.close()` means "close the browser" for every caller, so it
|
|
303
309
|
// shuts the spawned process down and deletes a temporary profile too.
|
|
304
310
|
browser.close = close;
|
|
@@ -403,12 +409,19 @@ async function spawnOnFreePort({
|
|
|
403
409
|
/** Reject a launch request before anything touches the disk. */
|
|
404
410
|
function validateLaunchRequest({
|
|
405
411
|
argOptions,
|
|
412
|
+
channel,
|
|
406
413
|
userDataDir,
|
|
407
414
|
remoteDebuggingPort,
|
|
408
415
|
endpoint,
|
|
409
416
|
attach,
|
|
410
417
|
migrateFrom,
|
|
411
418
|
}) {
|
|
419
|
+
const source = findBrowserSource(channel);
|
|
420
|
+
if (source && source.family !== 'chromium') {
|
|
421
|
+
throw new Error(
|
|
422
|
+
`${channel} does not support CDP; use its documented WebDriver setup when available`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
412
425
|
if (endpoint) {
|
|
413
426
|
throw new Error(
|
|
414
427
|
'launchAndConnectRealBrowser creates its own endpoint; use connectBrowser to attach to an existing endpoint'
|
|
@@ -502,13 +515,16 @@ async function runPreLaunchMigration({
|
|
|
502
515
|
if (!migrateFrom) {
|
|
503
516
|
return { migration: undefined, migratedCookies: [] };
|
|
504
517
|
}
|
|
505
|
-
const { include, domains, ...from } =
|
|
518
|
+
const { include, domains, passwordCsv, includePaymentCards, ...from } =
|
|
519
|
+
migrateFrom;
|
|
506
520
|
try {
|
|
507
521
|
const report = await migrate({
|
|
508
522
|
from,
|
|
509
523
|
to: path.join(userDataDir, 'Default'),
|
|
510
524
|
include,
|
|
511
525
|
domains,
|
|
526
|
+
passwordCsv,
|
|
527
|
+
includePaymentCards,
|
|
512
528
|
targetBrowser: channel,
|
|
513
529
|
});
|
|
514
530
|
const { cookies, ...migration } = report;
|
|
@@ -583,6 +599,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
583
599
|
};
|
|
584
600
|
validateLaunchRequest({
|
|
585
601
|
argOptions,
|
|
602
|
+
channel,
|
|
586
603
|
userDataDir: requestedUserDataDir,
|
|
587
604
|
remoteDebuggingPort,
|
|
588
605
|
endpoint: cdpEndpoint || wsEndpoint,
|
|
@@ -670,6 +687,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
670
687
|
userDataDir,
|
|
671
688
|
temporaryProfile,
|
|
672
689
|
closeTimeout,
|
|
690
|
+
closeConnection: engine === 'selenium' ? connection.close : undefined,
|
|
673
691
|
});
|
|
674
692
|
return {
|
|
675
693
|
...connection,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/** A protected path remains a source, so reads can explain Full Disk Access. */
|
|
4
|
+
export async function findSafariFile(candidates, statFile = stat) {
|
|
5
|
+
for (const candidate of candidates) {
|
|
6
|
+
try {
|
|
7
|
+
if ((await statFile(candidate)).isFile()) {
|
|
8
|
+
return candidate;
|
|
9
|
+
}
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (['EPERM', 'EACCES'].includes(error.code)) {
|
|
12
|
+
return candidate;
|
|
13
|
+
}
|
|
14
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Enrich protection failures for every Safari store, preserving the cause. */
|
|
23
|
+
export async function withSafariAccess(
|
|
24
|
+
filename,
|
|
25
|
+
read,
|
|
26
|
+
environment = process.env
|
|
27
|
+
) {
|
|
28
|
+
try {
|
|
29
|
+
return await read();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!['EPERM', 'EACCES'].includes(error.code)) {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
const app =
|
|
35
|
+
environment.__CFBundleIdentifier ||
|
|
36
|
+
environment.TERM_PROGRAM ||
|
|
37
|
+
process.execPath;
|
|
38
|
+
const diagnostic = new Error(
|
|
39
|
+
`Safari access denied (${error.code}) at ${filename}. Grant Full Disk Access to ${app}, the app running Browser Commander, then retry: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
|
|
40
|
+
{ cause: error }
|
|
41
|
+
);
|
|
42
|
+
diagnostic.code = error.code;
|
|
43
|
+
throw diagnostic;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFile as readFileBytes, stat } from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { findSafariFile, withSafariAccess } from './safari-access.js';
|
|
7
8
|
import { TextDecoder } from 'node:util';
|
|
9
|
+
import { matchesDomains } from './migration/domains.js';
|
|
8
10
|
|
|
9
11
|
const APPLE_EPOCH = 978_307_200;
|
|
10
12
|
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
@@ -121,7 +123,7 @@ export function countSafariCookies(data, domains) {
|
|
|
121
123
|
const host = string(record, 16).toLowerCase();
|
|
122
124
|
total += 1;
|
|
123
125
|
for (const domain of Object.keys(byDomain ?? {})) {
|
|
124
|
-
if (host
|
|
126
|
+
if (matchesDomains(host, [domain])) {
|
|
125
127
|
byDomain[domain] += 1;
|
|
126
128
|
}
|
|
127
129
|
}
|
|
@@ -130,44 +132,45 @@ export function countSafariCookies(data, domains) {
|
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
/** A protected path is still a source: preserve it for an actionable read error. */
|
|
133
|
-
export
|
|
134
|
-
|
|
135
|
-
path.
|
|
136
|
-
path.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
135
|
+
export function findSafariCookieFile(profileDir, statFile = stat) {
|
|
136
|
+
const named =
|
|
137
|
+
path.basename(path.dirname(profileDir)) === 'Profiles' &&
|
|
138
|
+
path.basename(path.dirname(path.dirname(profileDir))) === 'Safari';
|
|
139
|
+
const root = named
|
|
140
|
+
? path.dirname(path.dirname(path.dirname(profileDir)))
|
|
141
|
+
: profileDir;
|
|
142
|
+
const modern = named
|
|
143
|
+
? [
|
|
144
|
+
path.join(
|
|
145
|
+
root,
|
|
146
|
+
'WebKit/WebsiteDataStore',
|
|
147
|
+
path.basename(profileDir).toLowerCase(),
|
|
148
|
+
'Cookies/Cookies.binarycookies'
|
|
149
|
+
),
|
|
150
|
+
]
|
|
151
|
+
: [
|
|
152
|
+
path.join(
|
|
153
|
+
root,
|
|
154
|
+
'WebKit/WebsiteData/Default/Cookies/Cookies.binarycookies'
|
|
155
|
+
),
|
|
156
|
+
path.join(
|
|
157
|
+
root,
|
|
158
|
+
'WebKit/WebsiteDataStore/Default/Cookies/Cookies.binarycookies'
|
|
159
|
+
),
|
|
160
|
+
];
|
|
161
|
+
return findSafariFile(
|
|
162
|
+
[
|
|
163
|
+
...modern,
|
|
164
|
+
path.join(profileDir, 'Cookies', 'Cookies.binarycookies'),
|
|
165
|
+
path.join(profileDir, 'Cookies.binarycookies'),
|
|
166
|
+
],
|
|
167
|
+
statFile
|
|
168
|
+
);
|
|
152
169
|
}
|
|
153
170
|
|
|
154
|
-
export
|
|
171
|
+
export function readSafariCookieFile(
|
|
155
172
|
filePath,
|
|
156
173
|
{ environment = process.env, readFile = readFileBytes } = {}
|
|
157
174
|
) {
|
|
158
|
-
|
|
159
|
-
return await readFile(filePath);
|
|
160
|
-
} catch (error) {
|
|
161
|
-
if (!['EPERM', 'EACCES'].includes(error.code)) {
|
|
162
|
-
throw error;
|
|
163
|
-
}
|
|
164
|
-
const app =
|
|
165
|
-
environment.__CFBundleIdentifier ||
|
|
166
|
-
environment.TERM_PROGRAM ||
|
|
167
|
-
process.execPath;
|
|
168
|
-
throw new Error(
|
|
169
|
-
`Safari cookie access denied (${error.code}) at ${filePath}. Grant Full Disk Access to ${app}, the app running Browser Commander, then retry: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
|
|
170
|
-
{ cause: error }
|
|
171
|
-
);
|
|
172
|
-
}
|
|
175
|
+
return withSafariAccess(filePath, () => readFile(filePath), environment);
|
|
173
176
|
}
|