/** * Importable browser sources. * * Chromium-family browsers keep cookies in an * encrypted SQLite database whose key lives in an OS credential store; Firefox * keeps them in plain SQLite with no key at all, so it needs no keychain and * works the same on every platform. Safari uses binary cookie files, with * separate WebKit data stores for named profiles. * * Each entry pins its own paths and credential-store coordinates rather than * deriving them, because the forks do not agree. macOS uses service/account * pairs, while Linux Chromium uses a custom libsecret schema keyed by an * `application` attribute. The user-data directory also differs per fork and * per platform. * * @module BrowserImportSources */import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";import { HostProcessEnvironment, HostProcessAddresses, HostProcessHostname, HostProcessPlatform,} from "@t3tools/shared/hostProcess";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";import * as SqlClient from "effect/unstable/sql/SqlClient"; export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than * read from `process`, so source resolution stays testable for platforms the * host is not currently running. */export interface BrowserImportPathContext { readonly path: Path.Path; readonly platform: NodeJS.Platform; readonly home: string; /** `%APPDATA%` on Windows; unused elsewhere. */ readonly appData: string | undefined; /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */ readonly localAppData: string | undefined;} export interface BrowserImportSourceDefinition { readonly id: BrowserImportSourceId; readonly name: string; readonly engine: BrowserImportEngine; /** Platforms the definition has paths for. */ readonly platforms: ReadonlyArray<NodeJS.Platform>; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ readonly keychainService?: string; readonly keychainAccount?: string; /** Chromium's `application` attribute in the Linux libsecret schema. */ readonly linuxSecretApplication?: string;} const macApplicationSupport = ( context: BrowserImportPathContext, ...segments: ReadonlyArray<string>) => context.path.join(context.home, "Library", "Application Support", ...segments); /** * One Chromium fork. The leaves differ per fork; omitting a platform's * segments marks the fork as unavailable there. Most Windows Chromium builds * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. */const chromiumSource = (input: { readonly id: BrowserImportSourceId; readonly name: string; readonly keychainService: string; readonly keychainAccount: string; readonly macSegments: ReadonlyArray<string>; readonly linuxSegments?: ReadonlyArray<string>; readonly linuxSecretApplication?: string; readonly windowsSegments?: ReadonlyArray<string>;}): BrowserImportSourceDefinition => ({ id: input.id, name: input.name, engine: "chromium", platforms: [ "darwin" as NodeJS.Platform, ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), ], keychainService: input.keychainService, keychainAccount: input.keychainAccount, ...(input.linuxSecretApplication === undefined ? {} : { linuxSecretApplication: input.linuxSecretApplication }), userDataDirectory: (context) => { if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); if (context.platform === "win32") { return input.windowsSegments && context.localAppData ? context.path.join(context.localAppData, ...input.windowsSegments) : undefined; } return input.linuxSegments ? context.path.join(context.home, ".config", ...input.linuxSegments) : undefined; },}); export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [ // No Chromium fork is importable on Windows: since Chrome 127 their cookies // are encrypted to the browser's own identity (App-Bound Encryption), so no // other process can read them. macOS and Linux keep working, so only the // Windows segments are omitted. chromiumSource({ id: "chrome", name: "Chrome", keychainService: "Chrome Safe Storage", keychainAccount: "Chrome", macSegments: ["Google", "Chrome"], linuxSegments: ["google-chrome"], linuxSecretApplication: "chrome", }), chromiumSource({ id: "edge", name: "Microsoft Edge", keychainService: "Microsoft Edge Safe Storage", keychainAccount: "Microsoft Edge", macSegments: ["Microsoft Edge"], linuxSegments: ["microsoft-edge"], linuxSecretApplication: "msedge", }), chromiumSource({ id: "brave", name: "Brave", keychainService: "Brave Safe Storage", keychainAccount: "Brave", macSegments: ["BraveSoftware", "Brave-Browser"], linuxSegments: ["BraveSoftware", "Brave-Browser"], linuxSecretApplication: "brave", }), chromiumSource({ id: "vivaldi", name: "Vivaldi", keychainService: "Vivaldi Safe Storage", keychainAccount: "Vivaldi", macSegments: ["Vivaldi"], linuxSegments: ["vivaldi"], linuxSecretApplication: "vivaldi", }), chromiumSource({ id: "opera", name: "Opera", keychainService: "Opera Safe Storage", keychainAccount: "Opera", macSegments: ["com.operasoftware.Opera"], linuxSegments: ["opera"], linuxSecretApplication: "opera", }), // Arc has no Linux build. chromiumSource({ id: "arc", name: "Arc", keychainService: "Arc Safe Storage", keychainAccount: "Arc", macSegments: ["Arc", "User Data"], }), chromiumSource({ id: "helium", name: "Helium", keychainService: "Helium Storage Key", keychainAccount: "Helium", macSegments: ["net.imput.helium"], linuxSegments: ["net.imput.helium"], windowsSegments: ["imput", "Helium", "User Data"], // Helium retains Chromium's libsecret application name on Linux. linuxSecretApplication: "chromium", }), { // The default jar lives here; named profiles use WebKit data stores // alongside this directory inside the same app container. id: "safari", name: "Safari", engine: "safari", platforms: ["darwin"], userDataDirectory: (context) => context.platform === "darwin" ? context.path.join( context.home, "Library", "Containers", "com.apple.Safari", "Data", "Library", "Cookies", ) : undefined, }, { id: "firefox", name: "Firefox", engine: "firefox", platforms: ["darwin", "win32", "linux"], userDataDirectory: (context) => { if (context.platform === "darwin") return macApplicationSupport(context, "Firefox"); if (context.platform === "win32") { return context.appData ? context.path.join(context.appData, "Mozilla", "Firefox") : undefined; } return context.path.join(context.home, ".mozilla", "firefox"); }, },]; /** * Where a profile's cookie database may live, most current first. Chromium 96 * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a * pre-96 install or a leftover from before the move. Importing the leftover * while sessions live in `Network/` would snapshot a stale or empty database, * and a fresh install with only `Network/Cookies` would read as not installed. * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` * may already be absolute. * * Chrome 96+ moved network-related files (including Cookies) into a `Network` * subdirectory for sandboxing. The candidate list includes both locations so * callers tolerate fresh and legacy installs alike. */export const cookieDatabaseCandidatePaths = ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, profileDirectory: string,): ReadonlyArray<string> => { const root = definition.userDataDirectory(context); if (root === undefined) return []; const profilePath = context.path.isAbsolute(profileDirectory) ? profileDirectory : context.path.join(root, profileDirectory); if (definition.engine === "firefox") { return [context.path.join(profilePath, "cookies.sqlite")]; } if (definition.engine === "safari") { return [context.path.join(profilePath, "Cookies.binarycookies")]; } // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade // leaves the legacy file behind, so prefer the current one and fall back. return [ context.path.join(profilePath, "Network", "Cookies"), context.path.join(profilePath, "Cookies"), ];}; /** The first candidate that is a regular file, or undefined when none is. */export const resolveCookieDatabase = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, profileDirectory: string,) { for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) { if (yield* databaseFileExists(candidate)) return candidate; } return undefined;}); /** * Firefox records its profiles in `profiles.ini`. `Install*` sections point at * a default profile but do not describe one, so only `[ProfileN]` blocks * count. */export function parseFirefoxProfiles( ini: string, path: Path.Path, root: string,): ReadonlyArray<BrowserImportSourceProfile> { const profiles: BrowserImportSourceProfile[] = []; let current: { name?: string; path?: string; isRelative?: string } | null = null; const flush = () => { if (current?.path) { const candidate = current.path; const isRelative = current.isRelative === undefined || current.isRelative === "1"; const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative); if (!validIsRelative || candidate.includes("\u0000")) { current = null; return; } let directory: string | undefined; if (isRelative) { if (!path.isAbsolute(candidate)) { const resolved = path.resolve(root, candidate); const relative = path.relative(root, resolved); const escapesRoot = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); if (!escapesRoot) directory = path.normalize(candidate); } } else if (path.isAbsolute(candidate)) { // Firefox supports profiles on arbitrary custom roots when // IsRelative=0. Do not constrain them to the standard Firefox root. directory = path.normalize(candidate); } if (directory !== undefined) { profiles.push({ directory, name: current.name?.trim() || directory }); } } current = null; }; for (const rawLine of ini.split(/\r?\n/)) { const line = rawLine.trim(); if (line.startsWith("[")) { flush(); current = /^\[Profile\d+\]$/i.test(line) ? {} : null; continue; } if (!current) continue; const separator = line.indexOf("="); if (separator === -1) continue; const key = line.slice(0, separator).trim().toLowerCase(); const value = line.slice(separator + 1).trim(); if (key === "name") current.name = value; if (key === "path") current.path = value; if (key === "isrelative") current.isRelative = value; } flush(); return profiles;} /** * Resolves the roots the registry builds its paths from, from the ambient * process. Tests build a context directly instead. */export const sourcePathContext = Effect.gen(function* () { const path = yield* Path.Path; const platform = yield* HostProcessPlatform; const environment = yield* HostProcessEnvironment; return { path, platform, home: environment.HOME ?? environment.USERPROFILE ?? "", appData: environment.APPDATA, localAppData: environment.LOCALAPPDATA, } satisfies BrowserImportPathContext;}); /** Shape of the slice of Chromium's `Local State` that names its profiles. */const LocalState = Schema.Struct({ profile: Schema.optional( Schema.Struct({ info_cache: Schema.optional( Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })), ), }), ),});const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); /** A single plain path segment: no separators, no `.`/`..`, not empty. */const isSafeProfileDirectory = (directory: string): boolean => directory.length > 0 && directory !== "." && directory !== ".." && !/[\\/]/.test(directory) && !directory.includes("\u0000"); const CookieCountRow = Schema.Struct({ count: Schema.Number });const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); /** * How many importable cookies a profile holds, counted without decrypting * anything. Firefox containers use identities Electron cannot represent, so * its count uses the same default-container predicate as the reader. Best * effort: a locked, missing or unexpected database yields `undefined` rather * than failing the listing. */const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, directory: string,): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> { const database = yield* resolveCookieDatabase(definition, context, directory); if (database === undefined) return undefined; return yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const rows = definition.engine === "firefox" ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''` : yield* sql`select count(*) as count from cookies`; const [row] = yield* decodeCookieCount(rows); return row?.count; }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })), Effect.orElseSucceed(() => undefined), );}); const withCookieCounts = ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, profiles: ReadonlyArray<BrowserImportSourceProfile>,) => Effect.forEach(profiles, (profile) => countProfileCookies(definition, context, profile.directory).pipe( Effect.map((cookieCount) => cookieCount === undefined ? profile : { ...profile, cookieCount }, ), ), ); const SafariProfileRows = Schema.Array( Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }),);const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows);const isSafariProfileUuid = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); const listSafariProfiles = Effect.fnUntraced(function* ( context: BrowserImportPathContext, root: string,) { const fileSystem = yield* FileSystem.FileSystem; const library = context.path.dirname(root); const metadata = context.path.join(library, "Safari", "SafariTabs.db"); const declared = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; return yield* decodeSafariProfiles( yield* sql` select title, external_uuid from bookmarks where parent = 0 and type = 1 and subtype = 2 and deleted = 0 order by order_index `, ); }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })), Effect.orElseSucceed(() => []), ); const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile"); const profiles: Array<BrowserImportSourceProfile> = [ { directory: ".", name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari", }, ]; const stores = context.path.join(library, "WebKit", "WebsiteDataStore"); const profileDirectory = (uuid: string) => context.path.join(stores, uuid.toLowerCase(), "Cookies"); for (const profile of declared) { if (!isSafariProfileUuid(profile.external_uuid)) continue; profiles.push({ directory: profileDirectory(profile.external_uuid), name: profile.title?.trim() || profile.external_uuid, }); } // If Safari's metadata is unavailable, recover stores that have cookies. // With readable metadata, avoid resurrecting deleted profiles left on disk. if (declared.length === 0) { const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries.filter(isSafariProfileUuid).sort()) { const directory = context.path.join(stores, entry, "Cookies"); if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) { profiles.push({ directory, name: entry }); } } } return profiles;}); /** * Profiles the source browser knows about. * * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When * that metadata is missing, unreadable or malformed, the directories that * actually hold a cookie database are scanned instead. Assuming a single * `Default` would report a browser whose cookies live in `Profile 1` as having * nothing to import — and it is then left out of the menu entirely. */const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext,): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> { const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return []; if (definition.engine === "safari") { return yield* listSafariProfiles(context, root); } if (definition.engine === "firefox") { const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>), ); // `profiles.ini` also lists profiles the installer created but the user // never launched, which hold no cookie database and nothing to import. // Only keep the ones a database proves exist, like the directory scans // below do. When none of the declared profiles has one, fall through toLines 1–500 of 922. Load more
/** * Importable browser sources. * * Chromium-family browsers keep cookies in an * encrypted SQLite database whose key lives in an OS credential store; Firefox * keeps them in plain SQLite with no key at all, so it needs no keychain and * works the same on every platform. Safari uses binary cookie files, with * separate WebKit data stores for named profiles. * * Each entry pins its own paths and credential-store coordinates rather than * deriving them, because the forks do not agree. macOS uses service/account * pairs, while Linux Chromium uses a custom libsecret schema keyed by an * `application` attribute. The user-data directory also differs per fork and * per platform. * * @module BrowserImportSources */ import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { HostProcessEnvironment, HostProcessAddresses, HostProcessHostname, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than * read from `process`, so source resolution stays testable for platforms the * host is not currently running. */ export interface BrowserImportPathContext { readonly path: Path.Path; readonly platform: NodeJS.Platform; readonly home: string; /** `%APPDATA%` on Windows; unused elsewhere. */ readonly appData: string | undefined; /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */ readonly localAppData: string | undefined; } export interface BrowserImportSourceDefinition { readonly id: BrowserImportSourceId; readonly name: string; readonly engine: BrowserImportEngine; /** Platforms the definition has paths for. */ readonly platforms: ReadonlyArray<NodeJS.Platform>; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ readonly keychainService?: string; readonly keychainAccount?: string; /** Chromium's `application` attribute in the Linux libsecret schema. */ readonly linuxSecretApplication?: string; } const macApplicationSupport = ( context: BrowserImportPathContext, ...segments: ReadonlyArray<string> ) => context.path.join(context.home, "Library", "Application Support", ...segments); /** * One Chromium fork. The leaves differ per fork; omitting a platform's * segments marks the fork as unavailable there. Most Windows Chromium builds * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. */ const chromiumSource = (input: { readonly id: BrowserImportSourceId; readonly name: string; readonly keychainService: string; readonly keychainAccount: string; readonly macSegments: ReadonlyArray<string>; readonly linuxSegments?: ReadonlyArray<string>; readonly linuxSecretApplication?: string; readonly windowsSegments?: ReadonlyArray<string>; }): BrowserImportSourceDefinition => ({ id: input.id, name: input.name, engine: "chromium", platforms: [ "darwin" as NodeJS.Platform, ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), ], keychainService: input.keychainService, keychainAccount: input.keychainAccount, ...(input.linuxSecretApplication === undefined ? {} : { linuxSecretApplication: input.linuxSecretApplication }), userDataDirectory: (context) => { if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); if (context.platform === "win32") { return input.windowsSegments && context.localAppData ? context.path.join(context.localAppData, ...input.windowsSegments) : undefined; } return input.linuxSegments ? context.path.join(context.home, ".config", ...input.linuxSegments) : undefined; }, }); export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [ // No Chromium fork is importable on Windows: since Chrome 127 their cookies // are encrypted to the browser's own identity (App-Bound Encryption), so no // other process can read them. macOS and Linux keep working, so only the // Windows segments are omitted. chromiumSource({ id: "chrome", name: "Chrome", keychainService: "Chrome Safe Storage", keychainAccount: "Chrome", macSegments: ["Google", "Chrome"], linuxSegments: ["google-chrome"], linuxSecretApplication: "chrome", }), chromiumSource({ id: "edge", name: "Microsoft Edge", keychainService: "Microsoft Edge Safe Storage", keychainAccount: "Microsoft Edge", macSegments: ["Microsoft Edge"], linuxSegments: ["microsoft-edge"], linuxSecretApplication: "msedge", }), chromiumSource({ id: "brave", name: "Brave", keychainService: "Brave Safe Storage", keychainAccount: "Brave", macSegments: ["BraveSoftware", "Brave-Browser"], linuxSegments: ["BraveSoftware", "Brave-Browser"], linuxSecretApplication: "brave", }), chromiumSource({ id: "vivaldi", name: "Vivaldi", keychainService: "Vivaldi Safe Storage", keychainAccount: "Vivaldi", macSegments: ["Vivaldi"], linuxSegments: ["vivaldi"], linuxSecretApplication: "vivaldi", }), chromiumSource({ id: "opera", name: "Opera", keychainService: "Opera Safe Storage", keychainAccount: "Opera", macSegments: ["com.operasoftware.Opera"], linuxSegments: ["opera"], linuxSecretApplication: "opera", }), // Arc has no Linux build. chromiumSource({ id: "arc", name: "Arc", keychainService: "Arc Safe Storage", keychainAccount: "Arc", macSegments: ["Arc", "User Data"], }), chromiumSource({ id: "helium", name: "Helium", keychainService: "Helium Storage Key", keychainAccount: "Helium", macSegments: ["net.imput.helium"], linuxSegments: ["net.imput.helium"], windowsSegments: ["imput", "Helium", "User Data"], // Helium retains Chromium's libsecret application name on Linux. linuxSecretApplication: "chromium", }), { // The default jar lives here; named profiles use WebKit data stores // alongside this directory inside the same app container. id: "safari", name: "Safari", engine: "safari", platforms: ["darwin"], userDataDirectory: (context) => context.platform === "darwin" ? context.path.join( context.home, "Library", "Containers", "com.apple.Safari", "Data", "Library", "Cookies", ) : undefined, }, { id: "firefox", name: "Firefox", engine: "firefox", platforms: ["darwin", "win32", "linux"], userDataDirectory: (context) => { if (context.platform === "darwin") return macApplicationSupport(context, "Firefox"); if (context.platform === "win32") { return context.appData ? context.path.join(context.appData, "Mozilla", "Firefox") : undefined; } return context.path.join(context.home, ".mozilla", "firefox"); }, }, ]; /** * Where a profile's cookie database may live, most current first. Chromium 96 * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a * pre-96 install or a leftover from before the move. Importing the leftover * while sessions live in `Network/` would snapshot a stale or empty database, * and a fresh install with only `Network/Cookies` would read as not installed. * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` * may already be absolute. * * Chrome 96+ moved network-related files (including Cookies) into a `Network` * subdirectory for sandboxing. The candidate list includes both locations so * callers tolerate fresh and legacy installs alike. */ export const cookieDatabaseCandidatePaths = ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, profileDirectory: string, ): ReadonlyArray<string> => { const root = definition.userDataDirectory(context); if (root === undefined) return []; const profilePath = context.path.isAbsolute(profileDirectory) ? profileDirectory : context.path.join(root, profileDirectory); if (definition.engine === "firefox") { return [context.path.join(profilePath, "cookies.sqlite")]; } if (definition.engine === "safari") { return [context.path.join(profilePath, "Cookies.binarycookies")]; } // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade // leaves the legacy file behind, so prefer the current one and fall back. return [ context.path.join(profilePath, "Network", "Cookies"), context.path.join(profilePath, "Cookies"), ]; }; /** The first candidate that is a regular file, or undefined when none is. */ export const resolveCookieDatabase = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, profileDirectory: string, ) { for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) { if (yield* databaseFileExists(candidate)) return candidate; } return undefined; }); /** * Firefox records its profiles in `profiles.ini`. `Install*` sections point at * a default profile but do not describe one, so only `[ProfileN]` blocks * count. */ export function parseFirefoxProfiles( ini: string, path: Path.Path, root: string, ): ReadonlyArray<BrowserImportSourceProfile> { const profiles: BrowserImportSourceProfile[] = []; let current: { name?: string; path?: string; isRelative?: string } | null = null; const flush = () => { if (current?.path) { const candidate = current.path; const isRelative = current.isRelative === undefined || current.isRelative === "1"; const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative); if (!validIsRelative || candidate.includes("\u0000")) { current = null; return; } let directory: string | undefined; if (isRelative) { if (!path.isAbsolute(candidate)) { const resolved = path.resolve(root, candidate); const relative = path.relative(root, resolved); const escapesRoot = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); if (!escapesRoot) directory = path.normalize(candidate); } } else if (path.isAbsolute(candidate)) { // Firefox supports profiles on arbitrary custom roots when // IsRelative=0. Do not constrain them to the standard Firefox root. directory = path.normalize(candidate); } if (directory !== undefined) { profiles.push({ directory, name: current.name?.trim() || directory }); } } current = null; }; for (const rawLine of ini.split(/\r?\n/)) { const line = rawLine.trim(); if (line.startsWith("[")) { flush(); current = /^\[Profile\d+\]$/i.test(line) ? {} : null; continue; } if (!current) continue; const separator = line.indexOf("="); if (separator === -1) continue; const key = line.slice(0, separator).trim().toLowerCase(); const value = line.slice(separator + 1).trim(); if (key === "name") current.name = value; if (key === "path") current.path = value; if (key === "isrelative") current.isRelative = value; } flush(); return profiles; } /** * Resolves the roots the registry builds its paths from, from the ambient * process. Tests build a context directly instead. */ export const sourcePathContext = Effect.gen(function* () { const path = yield* Path.Path; const platform = yield* HostProcessPlatform; const environment = yield* HostProcessEnvironment; return { path, platform, home: environment.HOME ?? environment.USERPROFILE ?? "", appData: environment.APPDATA, localAppData: environment.LOCALAPPDATA, } satisfies BrowserImportPathContext; }); /** Shape of the slice of Chromium's `Local State` that names its profiles. */ const LocalState = Schema.Struct({ profile: Schema.optional( Schema.Struct({ info_cache: Schema.optional( Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })), ), }), ), }); const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); /** A single plain path segment: no separators, no `.`/`..`, not empty. */ const isSafeProfileDirectory = (directory: string): boolean => directory.length > 0 && directory !== "." && directory !== ".." && !/[\\/]/.test(directory) && !directory.includes("\u0000"); const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); /** * How many importable cookies a profile holds, counted without decrypting * anything. Firefox containers use identities Electron cannot represent, so * its count uses the same default-container predicate as the reader. Best * effort: a locked, missing or unexpected database yields `undefined` rather * than failing the listing. */ const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, directory: string, ): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> { const database = yield* resolveCookieDatabase(definition, context, directory); if (database === undefined) return undefined; return yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const rows = definition.engine === "firefox" ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''` : yield* sql`select count(*) as count from cookies`; const [row] = yield* decodeCookieCount(rows); return row?.count; }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })), Effect.orElseSucceed(() => undefined), ); }); const withCookieCounts = ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, profiles: ReadonlyArray<BrowserImportSourceProfile>, ) => Effect.forEach(profiles, (profile) => countProfileCookies(definition, context, profile.directory).pipe( Effect.map((cookieCount) => cookieCount === undefined ? profile : { ...profile, cookieCount }, ), ), ); const SafariProfileRows = Schema.Array( Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }), ); const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows); const isSafariProfileUuid = (value: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); const listSafariProfiles = Effect.fnUntraced(function* ( context: BrowserImportPathContext, root: string, ) { const fileSystem = yield* FileSystem.FileSystem; const library = context.path.dirname(root); const metadata = context.path.join(library, "Safari", "SafariTabs.db"); const declared = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; return yield* decodeSafariProfiles( yield* sql` select title, external_uuid from bookmarks where parent = 0 and type = 1 and subtype = 2 and deleted = 0 order by order_index `, ); }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })), Effect.orElseSucceed(() => []), ); const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile"); const profiles: Array<BrowserImportSourceProfile> = [ { directory: ".", name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari", }, ]; const stores = context.path.join(library, "WebKit", "WebsiteDataStore"); const profileDirectory = (uuid: string) => context.path.join(stores, uuid.toLowerCase(), "Cookies"); for (const profile of declared) { if (!isSafariProfileUuid(profile.external_uuid)) continue; profiles.push({ directory: profileDirectory(profile.external_uuid), name: profile.title?.trim() || profile.external_uuid, }); } // If Safari's metadata is unavailable, recover stores that have cookies. // With readable metadata, avoid resurrecting deleted profiles left on disk. if (declared.length === 0) { const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries.filter(isSafariProfileUuid).sort()) { const directory = context.path.join(stores, entry, "Cookies"); if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) { profiles.push({ directory, name: entry }); } } } return profiles; }); /** * Profiles the source browser knows about. * * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When * that metadata is missing, unreadable or malformed, the directories that * actually hold a cookie database are scanned instead. Assuming a single * `Default` would report a browser whose cookies live in `Profile 1` as having * nothing to import — and it is then left out of the menu entirely. */ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, ): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> { const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return []; if (definition.engine === "safari") { return yield* listSafariProfiles(context, root); } if (definition.engine === "firefox") { const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>), ); // `profiles.ini` also lists profiles the installer created but the user // never launched, which hold no cookie database and nothing to import. // Only keep the ones a database proves exist, like the directory scans // below do. When none of the declared profiles has one, fall through to