1/**
2 * Importable browser sources.
3 *
4 * Chromium-family browsers keep cookies in an
5 * encrypted SQLite database whose key lives in an OS credential store; Firefox
6 * keeps them in plain SQLite with no key at all, so it needs no keychain and
7 * works the same on every platform. Safari uses binary cookie files, with
8 * separate WebKit data stores for named profiles.
9 *
10 * Each entry pins its own paths and credential-store coordinates rather than
11 * deriving them, because the forks do not agree. macOS uses service/account
12 * pairs, while Linux Chromium uses a custom libsecret schema keyed by an
13 * `application` attribute. The user-data directory also differs per fork and
14 * per platform.
15 *
16 * @module BrowserImportSources
17 */
18import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
19import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
20import * as HostProcess from "@t3tools/shared/HostProcess";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Path from "effect/Path";
24import * as PlatformError from "effect/PlatformError";
25import * as Schema from "effect/Schema";
26import * as Stream from "effect/Stream";
27import { ChildProcess, ChildProcessSpawner } from "effect/process";
28import * as SqlClient from "effect/sql/SqlClient";
29
30export type BrowserImportEngine = "chromium" | "firefox" | "safari";
31
32/**
33 * Directory roots a definition builds its paths from. Passed in rather than
34 * read from `process`, so source resolution stays testable for platforms the
35 * host is not currently running.
36 */
37export interface BrowserImportPathContext {
38 readonly path: Path.Path;
39 readonly platform: NodeJS.Platform;
40 readonly home: string;
41 /** `%APPDATA%` on Windows; unused elsewhere. */
42 readonly appData: string | undefined;
43 /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
44 readonly localAppData: string | undefined;
45}
46
47export interface BrowserImportSourceDefinition {
48 readonly id: BrowserImportSourceId;
49 readonly name: string;
50 readonly engine: BrowserImportEngine;
51 /** Platforms the definition has paths for. */
52 readonly platforms: ReadonlyArray<NodeJS.Platform>;
53 readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
54 /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
55 readonly keychainService?: string;
56 readonly keychainAccount?: string;
57 /** Chromium's `application` attribute in the Linux libsecret schema. */
58 readonly linuxSecretApplication?: string;
59}
60
61const macApplicationSupport = (
62 context: BrowserImportPathContext,
63 ...segments: ReadonlyArray<string>
64) => context.path.join(context.home, "Library", "Application Support", ...segments);
65
66/**
67 * One Chromium fork. The leaves differ per fork; omitting a platform's
68 * segments marks the fork as unavailable there. Most Windows Chromium builds
69 * use App-Bound Encryption, but forks can retain the older DPAPI-backed store.
70 */
71const chromiumSource = (input: {
72 readonly id: BrowserImportSourceId;
73 readonly name: string;
74 readonly keychainService: string;
75 readonly keychainAccount: string;
76 readonly macSegments: ReadonlyArray<string>;
77 readonly linuxSegments?: ReadonlyArray<string>;
78 readonly linuxSecretApplication?: string;
79 readonly windowsSegments?: ReadonlyArray<string>;
80}): BrowserImportSourceDefinition => ({
81 id: input.id,
82 name: input.name,
83 engine: "chromium",
84 platforms: [
85 "darwin" as NodeJS.Platform,
86 ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
87 ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),
88 ],
89 keychainService: input.keychainService,
90 keychainAccount: input.keychainAccount,
91 ...(input.linuxSecretApplication === undefined
92 ? {}
93 : { linuxSecretApplication: input.linuxSecretApplication }),
94 userDataDirectory: (context) => {
95 if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
96 if (context.platform === "win32") {
97 return input.windowsSegments && context.localAppData
98 ? context.path.join(context.localAppData, ...input.windowsSegments)
99 : undefined;
100 }
101 return input.linuxSegments
102 ? context.path.join(context.home, ".config", ...input.linuxSegments)
103 : undefined;
104 },
105});
106
107export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
108 // No Chromium fork is importable on Windows: since Chrome 127 their cookies
109 // are encrypted to the browser's own identity (App-Bound Encryption), so no
110 // other process can read them. macOS and Linux keep working, so only the
111 // Windows segments are omitted.
112 chromiumSource({
113 id: "chrome",
114 name: "Chrome",
115 keychainService: "Chrome Safe Storage",
116 keychainAccount: "Chrome",
117 macSegments: ["Google", "Chrome"],
118 linuxSegments: ["google-chrome"],
119 linuxSecretApplication: "chrome",
120 }),
121 chromiumSource({
122 id: "edge",
123 name: "Microsoft Edge",
124 keychainService: "Microsoft Edge Safe Storage",
125 keychainAccount: "Microsoft Edge",
126 macSegments: ["Microsoft Edge"],
127 linuxSegments: ["microsoft-edge"],
128 linuxSecretApplication: "msedge",
129 }),
130 chromiumSource({
131 id: "brave",
132 name: "Brave",
133 keychainService: "Brave Safe Storage",
134 keychainAccount: "Brave",
135 macSegments: ["BraveSoftware", "Brave-Browser"],
136 linuxSegments: ["BraveSoftware", "Brave-Browser"],
137 linuxSecretApplication: "brave",
138 }),
139 chromiumSource({
140 id: "vivaldi",
141 name: "Vivaldi",
142 keychainService: "Vivaldi Safe Storage",
143 keychainAccount: "Vivaldi",
144 macSegments: ["Vivaldi"],
145 linuxSegments: ["vivaldi"],
146 linuxSecretApplication: "vivaldi",
147 }),
148 chromiumSource({
149 id: "opera",
150 name: "Opera",
151 keychainService: "Opera Safe Storage",
152 keychainAccount: "Opera",
153 macSegments: ["com.operasoftware.Opera"],
154 linuxSegments: ["opera"],
155 linuxSecretApplication: "opera",
156 }),
157 // Arc has no Linux build.
158 chromiumSource({
159 id: "arc",
160 name: "Arc",
161 keychainService: "Arc Safe Storage",
162 keychainAccount: "Arc",
163 macSegments: ["Arc", "User Data"],
164 }),
165 chromiumSource({
166 id: "helium",
167 name: "Helium",
168 keychainService: "Helium Storage Key",
169 keychainAccount: "Helium",
170 macSegments: ["net.imput.helium"],
171 linuxSegments: ["net.imput.helium"],
172 windowsSegments: ["imput", "Helium", "User Data"],
173 // Helium retains Chromium's libsecret application name on Linux.
174 linuxSecretApplication: "chromium",
175 }),
176 {
177 // The default jar lives here; named profiles use WebKit data stores
178 // alongside this directory inside the same app container.
179 id: "safari",
180 name: "Safari",
181 engine: "safari",
182 platforms: ["darwin"],
183 userDataDirectory: (context) =>
184 context.platform === "darwin"
185 ? context.path.join(
186 context.home,
187 "Library",
188 "Containers",
189 "com.apple.Safari",
190 "Data",
191 "Library",
192 "Cookies",
193 )
194 : undefined,
195 },
196 {
197 id: "firefox",
198 name: "Firefox",
199 engine: "firefox",
200 platforms: ["darwin", "win32", "linux"],
201 userDataDirectory: (context) => {
202 if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");
203 if (context.platform === "win32") {
204 return context.appData
205 ? context.path.join(context.appData, "Mozilla", "Firefox")
206 : undefined;
207 }
208 return context.path.join(context.home, ".mozilla", "firefox");
209 },
210 },
211];
212
213/**
214 * Where a profile's cookie database may live, most current first. Chromium 96
215 * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a
216 * pre-96 install or a leftover from before the move. Importing the leftover
217 * while sessions live in `Network/` would snapshot a stale or empty database,
218 * and a fresh install with only `Network/Cookies` would read as not installed.
219 * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini`
220 * may already be absolute.
221 *
222 * Chrome 96+ moved network-related files (including Cookies) into a `Network`
223 * subdirectory for sandboxing. The candidate list includes both locations so
224 * callers tolerate fresh and legacy installs alike.
225 */
226export const cookieDatabaseCandidatePaths = (
227 definition: BrowserImportSourceDefinition,
228 context: BrowserImportPathContext,
229 profileDirectory: string,
230): ReadonlyArray<string> => {
231 const root = definition.userDataDirectory(context);
232 if (root === undefined) return [];
233 const profilePath = context.path.isAbsolute(profileDirectory)
234 ? profileDirectory
235 : context.path.join(root, profileDirectory);
236 if (definition.engine === "firefox") {
237 return [context.path.join(profilePath, "cookies.sqlite")];
238 }
239 if (definition.engine === "safari") {
240 return [context.path.join(profilePath, "Cookies.binarycookies")];
241 }
242 // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade
243 // leaves the legacy file behind, so prefer the current one and fall back.
244 return [
245 context.path.join(profilePath, "Network", "Cookies"),
246 context.path.join(profilePath, "Cookies"),
247 ];
248};
249
250/** The first candidate that is a regular file, or undefined when none is. */
251export const resolveCookieDatabase = Effect.fnUntraced(function* (
252 definition: BrowserImportSourceDefinition,
253 context: BrowserImportPathContext,
254 profileDirectory: string,
255) {
256 for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) {
257 if (yield* databaseFileExists(candidate)) return candidate;
258 }
259 return undefined;
260});
261
262/**
263 * Firefox records its profiles in `profiles.ini`. `Install*` sections point at
264 * a default profile but do not describe one, so only `[ProfileN]` blocks
265 * count.
266 */
267export function parseFirefoxProfiles(
268 ini: string,
269 path: Path.Path,
270 root: string,
271): ReadonlyArray<BrowserImportSourceProfile> {
272 const profiles: BrowserImportSourceProfile[] = [];
273 let current: { name?: string; path?: string; isRelative?: string } | null = null;
274
275 const flush = () => {
276 if (current?.path) {
277 const candidate = current.path;
278 const isRelative = current.isRelative === undefined || current.isRelative === "1";
279 const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative);
280 if (!validIsRelative || candidate.includes("\u0000")) {
281 current = null;
282 return;
283 }
284
285 let directory: string | undefined;
286 if (isRelative) {
287 if (!path.isAbsolute(candidate)) {
288 const resolved = path.resolve(root, candidate);
289 const relative = path.relative(root, resolved);
290 const escapesRoot =
291 relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
292 if (!escapesRoot) directory = path.normalize(candidate);
293 }
294 } else if (path.isAbsolute(candidate)) {
295 // Firefox supports profiles on arbitrary custom roots when
296 // IsRelative=0. Do not constrain them to the standard Firefox root.
297 directory = path.normalize(candidate);
298 }
299
300 if (directory !== undefined) {
301 profiles.push({ directory, name: current.name?.trim() || directory });
302 }
303 }
304 current = null;
305 };
306
307 for (const rawLine of ini.split(/\r?\n/)) {
308 const line = rawLine.trim();
309 if (line.startsWith("[")) {
310 flush();
311 current = /^\[Profile\d+\]$/i.test(line) ? {} : null;
312 continue;
313 }
314 if (!current) continue;
315 const separator = line.indexOf("=");
316 if (separator === -1) continue;
317 const key = line.slice(0, separator).trim().toLowerCase();
318 const value = line.slice(separator + 1).trim();
319 if (key === "name") current.name = value;
320 if (key === "path") current.path = value;
321 if (key === "isrelative") current.isRelative = value;
322 }
323 flush();
324 return profiles;
325}
326
327/**
328 * Resolves the roots the registry builds its paths from, from the ambient
329 * process. Tests build a context directly instead.
330 */
331export const sourcePathContext = Effect.gen(function* () {
332 const path = yield* Path.Path;
333 const platform = yield* HostProcess.Platform;
334 const environment = yield* HostProcess.Environment;
335 return {
336 path,
337 platform,
338 home: environment.HOME ?? environment.USERPROFILE ?? "",
339 appData: environment.APPDATA,
340 localAppData: environment.LOCALAPPDATA,
341 } satisfies BrowserImportPathContext;
342});
343
344/** Shape of the slice of Chromium's `Local State` that names its profiles. */
345const LocalState = Schema.Struct({
346 profile: Schema.optional(
347 Schema.Struct({
348 info_cache: Schema.optional(
349 Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
350 ),
351 }),
352 ),
353});
354const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
355
356/** A single plain path segment: no separators, no `.`/`..`, not empty. */
357const isSafeProfileDirectory = (directory: string): boolean =>
358 directory.length > 0 &&
359 directory !== "." &&
360 directory !== ".." &&
361 !/[\\/]/.test(directory) &&
362 !directory.includes("\u0000");
363
364const CookieCountRow = Schema.Struct({ count: Schema.Number });
365const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));
366
367/**
368 * How many importable cookies a profile holds, counted without decrypting
369 * anything. Firefox containers use identities Electron cannot represent, so
370 * its count uses the same default-container predicate as the reader. Best
371 * effort: a locked, missing or unexpected database yields `undefined` rather
372 * than failing the listing.
373 */
374const countProfileCookies = Effect.fnUntraced(function* (
375 definition: BrowserImportSourceDefinition,
376 context: BrowserImportPathContext,
377 directory: string,
378): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> {
379 const database = yield* resolveCookieDatabase(definition, context, directory);
380 if (database === undefined) return undefined;
381 return yield* Effect.gen(function* () {
382 const sql = yield* SqlClient.SqlClient;
383 const rows =
384 definition.engine === "firefox"
385 ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''`
386 : yield* sql`select count(*) as count from cookies`;
387 const [row] = yield* decodeCookieCount(rows);
388 return row?.count;
389 }).pipe(
390 Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })),
391 Effect.orElseSucceed(() => undefined),
392 );
393});
394
395const withCookieCounts = (
396 definition: BrowserImportSourceDefinition,
397 context: BrowserImportPathContext,
398 profiles: ReadonlyArray<BrowserImportSourceProfile>,
399) =>
400 Effect.forEach(profiles, (profile) =>
401 countProfileCookies(definition, context, profile.directory).pipe(
402 Effect.map((cookieCount) =>
403 cookieCount === undefined ? profile : { ...profile, cookieCount },
404 ),
405 ),
406 );
407
408const SafariProfileRows = Schema.Array(
409 Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }),
410);
411const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows);
412const isSafariProfileUuid = (value: string) =>
413 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
414
415const listSafariProfiles = Effect.fnUntraced(function* (
416 context: BrowserImportPathContext,
417 root: string,
418) {
419 const fileSystem = yield* FileSystem.FileSystem;
420 const library = context.path.dirname(root);
421 const metadata = context.path.join(library, "Safari", "SafariTabs.db");
422 const declared = yield* Effect.gen(function* () {
423 const sql = yield* SqlClient.SqlClient;
424 return yield* decodeSafariProfiles(
425 yield* sql`
426 select title, external_uuid from bookmarks
427 where parent = 0 and type = 1 and subtype = 2 and deleted = 0
428 order by order_index
429 `,
430 );
431 }).pipe(
432 Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })),
433 Effect.orElseSucceed(() => []),
434 );
435 const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile");
436 const profiles: Array<BrowserImportSourceProfile> = [
437 {
438 directory: ".",
439 name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari",
440 },
441 ];
442 const stores = context.path.join(library, "WebKit", "WebsiteDataStore");
443 const profileDirectory = (uuid: string) =>
444 context.path.join(stores, uuid.toLowerCase(), "Cookies");
445 for (const profile of declared) {
446 if (!isSafariProfileUuid(profile.external_uuid)) continue;
447 profiles.push({
448 directory: profileDirectory(profile.external_uuid),
449 name: profile.title?.trim() || profile.external_uuid,
450 });
451 }
452 // If Safari's metadata is unavailable, recover stores that have cookies.
453 // With readable metadata, avoid resurrecting deleted profiles left on disk.
454 if (declared.length === 0) {
455 const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => []));
456 for (const entry of entries.filter(isSafariProfileUuid).sort()) {
457 const directory = context.path.join(stores, entry, "Cookies");
458 if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) {
459 profiles.push({ directory, name: entry });
460 }
461 }
462 }
463 return profiles;
464});
465
466/**
467 * Profiles the source browser knows about.
468 *
469 * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When
470 * that metadata is missing, unreadable or malformed, the directories that
471 * actually hold a cookie database are scanned instead. Assuming a single
472 * `Default` would report a browser whose cookies live in `Profile 1` as having
473 * nothing to import — and it is then left out of the menu entirely.
474 */
475const listSourceProfilesInDirectory = Effect.fnUntraced(function* (
476 definition: BrowserImportSourceDefinition,
477 context: BrowserImportPathContext,
478): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
479 const fileSystem = yield* FileSystem.FileSystem;
480 const root = definition.userDataDirectory(context);
481 if (root === undefined) return [];
482
483 if (definition.engine === "safari") {
484 return yield* listSafariProfiles(context, root);
485 }
486
487 if (definition.engine === "firefox") {
488 const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
489 Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),
490 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
491 );
492 // `profiles.ini` also lists profiles the installer created but the user
493 // never launched, which hold no cookie database and nothing to import.
494 // Only keep the ones a database proves exist, like the directory scans
495 // below do. When none of the declared profiles has one, fall through to
496 // the scan rather than returning empty: `profiles.ini` can list stale or
497 // never-launched profiles while the cookies live in one it does not
498 // mention, and an empty answer here hides the browser entirely.
499 if (declared.length > 0) {
500 const found = yield* Effect.forEach(declared, (profile) =>
501 Effect.forEach(
502 cookieDatabaseCandidatePaths(definition, context, profile.directory),
503 (candidate) => databaseFileExists(candidate),
504 ).pipe(Effect.map((results) => (results.some(Boolean) ? profile : undefined))),
505 );
506 const withDatabase = found.filter((profile) => profile !== undefined);
507 if (withDatabase.length > 0) {
508 return yield* withCookieCounts(definition, context, withDatabase);
509 }
510 }
511
512 // No usable `profiles.ini`, so fall back to scanning the directory the
513 // profiles actually live in, keeping only the ones a cookie database
514 // proves were launched.
515 const fallbackDirectory =
516 context.platform === "linux" ? root : context.path.join(root, "Profiles");
517 const scanned = yield* fileSystem
518 .readDirectory(fallbackDirectory)
519 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
520 const found = yield* Effect.forEach(scanned, (entry) => {
521 const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry);
522 return resolveCookieDatabase(definition, context, directory).pipe(
523 Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })),
524 );
525 });
526 return yield* withCookieCounts(
527 definition,
528 context,
529 found.filter((profile) => profile !== undefined),
530 );
531 }
532
533 const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe(
534 Effect.flatMap(decodeLocalState),
535 Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
536 // The keys are directory names from the browser's own metadata file, which
537 // anything running as the user can write. Anything but a single plain
538 // segment is dropped: `..` or a path separator would otherwise be handed
539 // to `cookieDatabasePath` and read a database outside the user-data
540 // directory.
541 Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))),
542 Effect.map((entries) =>
543 entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })),
544 ),
545 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
546 );
547 if (declared.length > 0) return yield* withCookieCounts(definition, context, declared);
548
549 // `Local State` is missing, unreadable or malformed. Scanning for directories
550 // that hold a cookie database finds the profiles anyway.
551 const entries = yield* fileSystem
552 .readDirectory(root)
553 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
554 const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) =>
555 resolveCookieDatabase(definition, context, directory).pipe(
556 Effect.map((database) =>
557 database === undefined ? undefined : { directory, name: directory },
558 ),
559 ),
560 );
561 return yield* withCookieCounts(
562 definition,
563 context,
564 found.filter((profile) => profile !== undefined),
565 );
566});
567
568/**
569 * Include Firefox's Snap home alongside its native home. Snap profiles use
570 * absolute directories so cookie reads and lock checks keep pointing at the
571 * installation they came from, even when both installs use the same name.
572 */
573export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
574 definition: BrowserImportSourceDefinition,
575 context: BrowserImportPathContext,
576): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
577 if (definition.engine !== "firefox" || context.platform !== "linux") {
578 return yield* listSourceProfilesInDirectory(definition, context);
579 }
580
581 const root = definition.userDataDirectory(context);
582 if (root === undefined) return [];
583 const roots = [
584 root,
585 context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"),
586 ];
587 const profiles = new Map<string, BrowserImportSourceProfile>();
588 for (const directory of roots) {
589 const found = yield* listSourceProfilesInDirectory(
590 { ...definition, userDataDirectory: () => directory },
591 context,
592 );
593 for (const profile of found) {
594 const absolute = context.path.resolve(directory, profile.directory);
595 if (!profiles.has(absolute)) {
596 profiles.set(absolute, directory === root ? profile : { ...profile, directory: absolute });
597 }
598 }
599 }
600 return [...profiles.values()];
601});
602
603/**
604 * Whether a cookie database candidate is a regular file. Presence alone is
605 * not enough: a directory at the path would list as an importable profile and
606 * then fail the SQLite open, so anything but a file is treated as absent.
607 */
608const databaseFileExists = Effect.fnUntraced(function* (path: string) {
609 const fileSystem = yield* FileSystem.FileSystem;
610 return yield* fileSystem.stat(path).pipe(
611 Effect.map((info) => info.type === "File"),
612 Effect.orElseSucceed(() => false),
613 );
614});
615
616type ProcessLivenessProbe = (pid: number) => Effect.Effect<boolean>;
617
618export const chromiumProcessIsAlive = (
619 pid: number,
620 signalProcess: (pid: number, signal: 0) => unknown = process.kill.bind(process),
621) =>
622 Effect.sync(() => {
623 try {
624 // Signal 0 performs a read-only existence/permission check.
625 signalProcess(pid, 0);
626 return true;
627 } catch (cause) {
628 // Only ESRCH positively proves the process is gone. Permission errors
629 // and unknown failures stay conservative so an active browser is never
630 // mistaken for a stale lock.
631 return !(
632 typeof cause === "object" &&
633 cause !== null &&
634 "code" in cause &&
635 cause.code === "ESRCH"
636 );
637 }
638 });
639
640const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid);
641
642/** Whether a Chromium `<host>-<pid>` lock target may still name its owner. */
643export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
644 target: string,
645 currentHost: string,
646 isProcessAlive: ProcessLivenessProbe,
647) {
648 const separator = target.lastIndexOf("-");
649 if (separator <= 0) return true;
650 const host = target.slice(0, separator);
651 const pidText = target.slice(separator + 1);
652 if (!/^\d+$/.test(pidText)) return true;
653 const pid = Number(pidText);
654 if (!Number.isSafeInteger(pid) || pid <= 0) return true;
655 // A PID is meaningful only on this host. A foreign hostname can come from a
656 // shared home directory, and cannot safely be declared stale from here.
657 if (host !== currentHost) return true;
658 return yield* isProcessAlive(pid);
659});
660
661/** Windows sharing and lock violations are translated by libuv to `Busy`. */
662export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean =>
663 error.reason._tag === "Busy";
664
665/**
666 * Whether a Windows `parent.lock` is actually held by a running process. It
667 * is opened with no sharing, so it persists on disk after the process exits
668 * and `stat` always succeeds; only trying to open it for write reveals an
669 * active holder, which surfaces as `Busy`.
670 */
671const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {
672 // Permission failures are distinct: they do not prove a browser owns the
673 // lock, so they must not hide the source as running.
674 const fileSystem = yield* FileSystem.FileSystem;
675 return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe(
676 Effect.as(false),
677 Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)),
678 Effect.orElseSucceed(() => false),
679 Effect.scoped,
680 );
681});
682
683type WindowsLockProbe = (path: string) => Effect.Effect<boolean, never, FileSystem.FileSystem>;
684
685/**
686 * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The
687 * live cookie database is opened without sharing instead, so probing each
688 * profile's current jar is the reliable running signal there.
689 */
690export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* (
691 definition: BrowserImportSourceDefinition,
692 context: BrowserImportPathContext,
693 lockIsHeld: WindowsLockProbe = windowsLockIsHeld,
694) {
695 const profiles = yield* listSourceProfiles(definition, context);
696 const held = yield* Effect.forEach(profiles, (profile) =>
697 resolveCookieDatabase(definition, context, profile.directory).pipe(
698 Effect.flatMap((database) =>
699 database === undefined ? Effect.succeed(false) : lockIsHeld(database),
700 ),
701 ),
702 );
703 return held.some(Boolean);
704});
705
706/**
707 * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
708 * live owner. Firefox writes this symlink beside the profile while it runs and
709 * unlinks it on a clean exit, so a dangling one is either live or a crash.
710 */
711export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* (
712 target: string,
713 localAddresses: ReadonlySet<string>,
714 isProcessAlive: ProcessLivenessProbe,
715) {
716 const separator = target.lastIndexOf(":");
717 if (separator < 0) return true;
718 // The owner half is whatever Firefox's resolver returned for the machine's
719 // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN
720 // address — so a pid is only meaningful when that address is one of ours.
721 // A shared (NFS) profile locked from another machine names a foreign
722 // address whose pid cannot be probed here, nor could a reused local pid
723 // vouch for it, so it stays conservatively held.
724 const owner = target.slice(0, separator);
725 if (!localAddresses.has(owner)) return true;
726 // A `+` marks an fcntl-holding owner; the pid follows either way.
727 const pidText = target.slice(separator + 1).replace(/^\+/, "");
728 if (!/^\d+$/.test(pidText)) return true;
729 const pid = Number(pidText);
730 if (!Number.isSafeInteger(pid) || pid <= 0) return true;
731 return yield* isProcessAlive(pid);
732});
733
734/**
735 * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3`
736 * is named absolutely first so a Dock-launched app with launchd's bare `PATH`
737 * still finds it without depending on the login-shell PATH merge; Linux
738 * distributions carry python3 on the default path.
739 */
740const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const;
741
742/**
743 * The probe prints exactly one of these. Anything else means the script never
744 * ran — most importantly Apple's `/usr/bin/python3` shim, which on a Mac
745 * without the Command Line Tools exits non-zero after printing an install
746 * prompt, without ever reaching our code.
747 */
748const FCNTL_PROBE_SCRIPT =
749 "import fcntl,os,sys\n" +
750 "fd=os.open(sys.argv[1],os.O_WRONLY)\n" +
751 "try:\n" +
752 " fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" +
753 "except BlockingIOError:\n" +
754 " print('held')\n" +
755 "else:\n" +
756 " print('free')";
757
758/**
759 * Whether another process holds an fcntl write lock on `path`.
760 *
761 * Firefox's `.parentlock` is an empty file whose only signal is the kernel
762 * lock, and Node exposes no fcntl, so a throwaway interpreter tries a
763 * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never
764 * acquired for real: on success the child exits and the kernel drops it.
765 *
766 * The answer is trusted only when the script itself spoke. A verdict of
767 * `held` or `free` on stdout is the probe's own, and stands. Anything else —
768 * no interpreter on any candidate path, or one that refused to run the script
769 * (Apple's shim without the developer tools) — is the probe being unavailable,
770 * not evidence about the lock. That case falls back to "not held" rather than
771 * "held": reporting every profile as locked forever would block Firefox import
772 * outright on such machines, and the SQLite snapshot already copes with a
773 * live database's WAL, as it does for every other engine.
774 */
775export const posixLockIsHeld = Effect.fnUntraced(function* (
776 path: string,
777 interpreters: ReadonlyArray<string> = FCNTL_PROBE_INTERPRETERS,
778) {
779 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
780 const environment = yield* HostProcess.Environment;
781 for (const interpreter of interpreters) {
782 const verdict = yield* Effect.scoped(
783 Effect.gen(function* () {
784 const handle = yield* spawner.spawn(
785 ChildProcess.make(interpreter, ["-c", FCNTL_PROBE_SCRIPT, path], {
786 stdin: "ignore",
787 env: environment,
788 }),
789 );
790 const [stdout] = yield* Effect.all(
791 [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
792 { concurrency: "unbounded" },
793 );
794 return stdout.trim();
795 }),
796 ).pipe(Effect.orElseSucceed(() => ""));
797 if (verdict === "held") return true;
798 if (verdict === "free") return false;
799 }
800 return false;
801});
802
803/**
804 * Whether Firefox holds a profile.
805 *
806 * Firefox leaves two kinds of lock behind, and they mean different things.
807 * On Linux the `lock` symlink (target `<ip>:+<pid>`) is removed on a clean
808 * exit, so its presence is evidence — provided the pid it names is alive. But
809 * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files
810 * held with fcntl or a Windows handle and are *deliberately left on disk*
811 * after exit, as a last-used marker; treating them as proof of a running
812 * browser blocks every import after Firefox has been used once. On POSIX the
813 * fcntl lock itself is the truth, and macOS in particular writes nothing else
814 * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On
815 * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`.
816 */
817const firefoxProfileIsHeld = Effect.fnUntraced(function* (
818 directory: string,
819 context: BrowserImportPathContext,
820 // Resolved once by the caller: it involves a DNS lookup of the hostname and
821 // is the same for every profile.
822 localAddresses: ReadonlySet<string>,
823) {
824 const fileSystem = yield* FileSystem.FileSystem;
825 if (context.platform === "win32") {
826 return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock"));
827 }
828 // Linux additionally writes the `lock` symlink; a live pid there settles it
829 // without spawning anything.
830 const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe(
831 Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)),
832 Effect.orElseSucceed(() => false),
833 );
834 if (symlinkHeld) return true;
835 const parentLock = context.path.join(directory, ".parentlock");
836 const present = yield* fileSystem.stat(parentLock).pipe(
837 Effect.map((info) => info.type === "File"),
838 Effect.orElseSucceed(() => false),
839 );
840 if (!present) return false;
841 return yield* posixLockIsHeld(parentLock);
842});
843
844/** Whether the browser is running, which leaves its cookie DB mid-write. */
845export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
846 definition: BrowserImportSourceDefinition,
847 context: BrowserImportPathContext,
848): Effect.fn.Return<
849 boolean,
850 never,
851 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
852> {
853 const fileSystem = yield* FileSystem.FileSystem;
854 const root = definition.userDataDirectory(context);
855 if (root === undefined) return false;
856 // Probe the source's own lock state rather than scanning the process table.
857 // Safari keeps no lock and writes its jar atomically, so a running instance
858 // is not a hazard there.
859 //
860 // Chromium exposes its lock through the cookie jar on Windows and through a
861 // user-data SingletonLock on POSIX. Firefox keeps its locks inside each
862 // profile under three names across platforms (`lock` on macOS and Linux,
863 // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's
864 // at the root finds nothing and reports a running browser as importable.
865 if (definition.engine === "safari") return false;
866 if (definition.engine !== "firefox") {
867 if (context.platform === "win32") {
868 return yield* windowsChromiumCookiesAreHeld(definition, context);
869 }
870 const currentHost = yield* HostProcess.Hostname;
871 const lock = context.path.join(root, "SingletonLock");
872 return yield* fileSystem.readLink(lock).pipe(
873 Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),
874 Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),
875 );
876 }
877
878 const profiles = yield* listSourceProfiles(definition, context);
879 // Only the Linux `lock` symlink names an address, so Windows skips the lookup.
880 const localAddresses: ReadonlySet<string> =
881 context.platform === "win32" ? new Set() : yield* yield* HostProcess.Addresses;
882 const found = yield* Effect.forEach(profiles, (profile) => {
883 const directory = context.path.isAbsolute(profile.directory)
884 ? profile.directory
885 : context.path.join(root, profile.directory);
886 return firefoxProfileIsHeld(directory, context, localAddresses);
887 });
888 return found.some(Boolean);
889});
890
891/**
892 * Whether the source has cookies to import.
893 *
894 * Keyed off the cookie database rather than the user-data directory, because
895 * that directory is not evidence the browser exists: installers for native
896 * messaging hosts create an empty one for every Chromium fork they know about,
897 * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as
898 * present. The database is the thing an import actually needs, so its absence
899 * is the honest answer either way.
900 *
901 * Existence is checked without opening the file, which matters for Safari: TCC
902 * permits `stat` on the jar inside its container but refuses a read, so this
903 * still sees it and the user gets the Full Disk Access prompt rather than
904 * having Safari disappear.
905 */
906export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* (
907 definition: BrowserImportSourceDefinition,
908 context: BrowserImportPathContext,
909): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
910 const profiles = yield* listSourceProfiles(definition, context);
911 const found = yield* Effect.forEach(profiles, (profile) =>
912 resolveCookieDatabase(definition, context, profile.directory).pipe(
913 Effect.map((database) => database !== undefined),
914 ),
915 );
916 return found.some(Boolean);
917});