From 7ba9f7a468726f9fad87b1e0369d72e0593e6347 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 23:05:37 +0200 Subject: [PATCH 1/6] feat(desktop): import from Chrome, Edge, Brave, Vivaldi, Opera, Arc, Firefox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises the importer from one hardcoded browser to a source registry with two engines. Chromium forks are table entries: Chrome, Edge, Brave, Vivaldi, Opera, Arc and Helium all share the existing extractor and differ only in their paths and keychain coordinates. Those coordinates are pinned per fork rather than derived, because the forks disagree — Helium uses "Helium Storage Key" / "Helium" where the others use " Safe Storage" / "". Firefox is a second engine. It stores cookies unencrypted in `cookies.sqlite`, so there is no key to fetch and no consent prompt — that is Mozilla's design choice, not a control being circumvented, and it is why Firefox works identically on all three platforms while Chromium still needs a per-platform credential store. Paths resolve for macOS, Windows and Linux from an injected context rather than from `process`, so a platform's layout can be checked without running on it. Chromium off macOS still reports `unsupportedPlatform` until those key stores land; Firefox does not. The snapshot-before-read step moves to a shared module, since both engines keep the database open with WAL and must never have the browser's own file opened for writing. Firefox has tests against a real `moz_cookies` fixture, including that the source file is left untouched, and `profiles.ini` parsing covers the `Install*` sections that name a default profile without describing one. Co-Authored-By: Claude Opus 5 (1M context) --- .../BrowserImport/BrowserImport.test.ts | 22 +- .../preview/BrowserImport/BrowserImport.ts | 76 ++-- .../BrowserImport/ChromiumCookies.test.ts | 2 +- .../preview/BrowserImport/ChromiumCookies.ts | 86 +---- .../preview/BrowserImport/CookieDatabase.ts | 84 +++++ .../BrowserImport/FirefoxCookies.test.ts | 188 ++++++++++ .../preview/BrowserImport/FirefoxCookies.ts | 74 ++++ .../src/preview/BrowserImport/Sources.test.ts | 73 ++-- .../src/preview/BrowserImport/Sources.ts | 349 ++++++++++++++---- packages/contracts/src/browserImport.ts | 11 +- 10 files changed, 746 insertions(+), 219 deletions(-) create mode 100644 apps/desktop/src/preview/BrowserImport/CookieDatabase.ts create mode 100644 apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts create mode 100644 apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index f7d1b02f6ca3..9176ab963f0a 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as BrowserSession from "../BrowserSession.ts"; import * as BrowserImport from "./BrowserImport.ts"; -import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts"; +import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -34,15 +34,16 @@ const withImporter = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); const environment = Layer.succeed(HostProcessEnvironment, { HOME: home }); - const paths = yield* sourcePaths.pipe( + const context = yield* sourcePathContext.pipe( Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), ); - yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, { - recursive: true, - }); + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); // The cookie database is what marks a source as installed, so a fixture // without one is reported as absent before any other check runs. - yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db"); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); const importer = yield* BrowserImport.BrowserImport.pipe( Effect.provide( @@ -55,7 +56,7 @@ const withImporter = Effect.fnUntraced(function* () { ), ), ); - return { importer, home, paths }; + return { importer, home, root }; }); describe("BrowserImport.importCookies", () => { @@ -89,13 +90,10 @@ describe("BrowserImport.importCookies", () => { it.effect("refuses to import while the source browser holds its profile", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const { importer, paths } = yield* withImporter(); + const { importer, root } = yield* withImporter(); // The lock Chromium leaves while it is running, dangling target and // all. This must stop the import before it ever asks the keychain. - yield* fileSystem.symlink( - "host-that-does-not-exist-1234", - `${helium.userDataDirectory(paths)}/SingletonLock`, - ); + yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); const error = yield* importer .importCookies({ diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index cd4ba0392dad..b733ec7231a2 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -17,20 +17,23 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as BrowserSession from "../BrowserSession.ts"; -import { readChromiumCookies } from "./ChromiumCookies.ts"; +import { readChromiumCookies, type CookieReadResult } from "./ChromiumCookies.ts"; +import type { ImportedCookie } from "./CookieDatabase.ts"; +import { readFirefoxCookies } from "./FirefoxCookies.ts"; import { BROWSER_IMPORT_SOURCES, cookieDatabasePath, isSourceInstalled, isSourceRunning, listSourceProfiles, - sourcePaths, + sourcePathContext, + type BrowserImportPathContext, type BrowserImportSourceDefinition, - type SourcePaths, } from "./Sources.ts"; export class BrowserImportFailedError extends Schema.TaggedErrorClass()( @@ -64,12 +67,16 @@ export class BrowserImport extends Context.Service< const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( definition: BrowserImportSourceDefinition, - platform: NodeJS.Platform, - paths: SourcePaths, + context: BrowserImportPathContext, ): Effect.fn.Return { - if (!definition.platforms.includes(platform)) return "unsupportedPlatform"; - if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled"; - if (yield* isSourceRunning(definition, paths)) return "browserRunning"; + if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; + // Chromium's key lives in an OS credential store, and only the macOS one is + // implemented; Firefox needs no key at all, so it works everywhere. + if (definition.engine === "chromium" && context.platform !== "darwin") { + return "unsupportedPlatform"; + } + if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; + if (yield* isSourceRunning(definition, context)) return "browserRunning"; return undefined; }); @@ -89,18 +96,19 @@ export const make = Effect.gen(function* BrowserImportMake() { // Captured here so the service's methods stay free of a requirements // channel: the layer is built where NodeServices is already in scope. const platformServices = yield* Effect.context(); - const paths = yield* sourcePaths; + const pathContext = yield* sourcePathContext; const listSources: Effect.Effect> = Effect.forEach( BROWSER_IMPORT_SOURCES, Effect.fnUntraced(function* (definition) { - const unavailable = yield* unavailableReason(definition, platform, paths); + const unavailable = yield* unavailableReason(definition, pathContext); return { id: definition.id, name: definition.name, // Listing profiles touches the source's own files, so skip it when the // source is unusable anyway. - profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [], + profiles: + unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], ...(unavailable === undefined ? {} : { unavailable }), } satisfies BrowserImportSource; }), @@ -121,7 +129,7 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - const blocked = yield* unavailableReason(definition, platform, paths).pipe( + const blocked = yield* unavailableReason(definition, pathContext).pipe( Effect.provide(platformServices), ); if (blocked !== undefined) { @@ -140,7 +148,7 @@ export const make = Effect.gen(function* BrowserImportMake() { // source itself reported it. Forwarding it unchecked would let `..` // segments walk out of the browser's user-data directory and read any // cookie database reachable on disk. - const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe( + const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( Effect.provide(platformServices), ); const requestedProfile = sourceProfiles.find( @@ -153,12 +161,36 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - const read = yield* readChromiumCookies({ - cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory), - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - platform, - }).pipe( + const databasePath = cookieDatabasePath(definition, pathContext, requestedProfile.directory); + if (databasePath === undefined) { + return yield* new BrowserImportFailedError({ + sourceId: definition.id, + reason: "unsupportedPlatform", + }); + } + + // Normalized to one shape so the skipped tally survives either engine: + // Firefox stores plaintext, so nothing there is ever unreadable. + const read: Effect.Effect< + CookieReadResult, + { readonly reason: BrowserImportFailureReason }, + FileSystem.FileSystem | Path.Path | Scope.Scope + > = + definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + Effect.mapError((cause) => ({ reason: "readFailed" as const, cause })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + // Only reached on macOS: `unavailableReason` rejects Chromium + // elsewhere until those key stores are implemented. + keychainService: definition.keychainService ?? "", + keychainAccount: definition.keychainAccount ?? "", + platform, + }); + + const result = yield* read.pipe( Effect.scoped, Effect.provide(platformServices), Effect.mapError( @@ -183,9 +215,9 @@ export const make = Effect.gen(function* BrowserImportMake() { let imported = 0; // Rows the reader could not decrypt are already lost cookies, so they // count as skipped rather than vanishing from the tally. - let skipped = read.undecryptable; - const skippedDomains = new Set(read.undecryptableHosts); - for (const cookie of read.cookies) { + let skipped = result.undecryptable; + const skippedDomains = new Set(result.undecryptableHosts); + for (const cookie of result.cookies) { const written = yield* Effect.tryPromise({ try: () => session.cookies.set({ diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 46e0c02f2399..0f2195c15869 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { cookieScope } from "./ChromiumCookies.ts"; +import { cookieScope } from "./CookieDatabase.ts"; describe("cookieScope", () => { it("keeps a host-only cookie host-only", () => { diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d1462afc481a..cfbffeea978c 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -19,11 +19,16 @@ import * as NodeCrypto from "node:crypto"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + bareHost, + cookieScope, + snapshotCookieDatabase, + type ImportedCookie, +} from "./CookieDatabase.ts"; + /** macOS OSCrypt parameters. Chromium has used these since the feature landed. */ const MAC_KEY_ITERATIONS = 1003; const MAC_KEY_SALT = "saltysalt"; @@ -32,24 +37,7 @@ const MAC_KEY_LENGTH = 16; const AES_IV = Buffer.alloc(16, 0x20); const V10_PREFIX = "v10"; -export interface ChromiumCookie { - readonly url: string; - readonly name: string; - readonly value: string; - /** - * Set only for domain cookies, which Chromium stores with a leading dot. - * A host-only cookie leaves this undefined: Electron treats any `domain` it - * is given as a domain cookie and re-adds the dot, which would widen the - * cookie to every subdomain of the host it was scoped to. - */ - readonly domain: string | undefined; - readonly path: string; - readonly secure: boolean; - readonly httpOnly: boolean; - /** Seconds since the UNIX epoch, or undefined for a session cookie. */ - readonly expirationDate: number | undefined; - readonly sameSite: "no_restriction" | "lax" | "strict"; -} +export type ChromiumCookie = ImportedCookie; export const ChromiumCookieReadReason = Schema.Literals([ "needsKeychainApproval", @@ -161,64 +149,6 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo return password; }); -/** - * Chromium keeps the cookie DB open with WAL, and reading it in place can - * observe a torn state. Copying first — including the sidecars — gives a - * consistent snapshot without touching the browser's own files. - * - * Scoped: the temp directory is removed when the caller's scope closes. - */ -const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(function* ( - cookiePath: string, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-cookie-import-" }); - const target = path.join(directory, "Cookies"); - yield* fileSystem.copyFile(cookiePath, target); - // A sidecar only exists while the browser holds the database open, so an - // absent one is normal. Anything else — a permission error, a partial read — - // is not: SQLite would then open the snapshot without the write-ahead log - // and quietly return a cookie set missing its most recent transactions. - yield* Effect.forEach(["-wal", "-shm"], (suffix) => - fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe( - Effect.catchIf( - (error) => error.reason._tag === "NotFound", - () => Effect.void, - ), - ), - ); - return target; -}); - -/** - * The URL and domain Electron should register a stored row under. - * - * Chromium marks a domain cookie with a leading dot on `host_key`. Electron - * matches on a URL, so the dot comes off for that; `domain` is passed through - * only for domain cookies, because supplying it at all makes Electron treat - * the cookie as one and re-add the dot — widening a host-only cookie to every - * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, - * which require it to be absent. - */ -export const cookieScope = ( - hostKey: string, - path: string, - secure: boolean, -): { readonly url: string; readonly domain: string | undefined } => { - const isDomainCookie = hostKey.startsWith("."); - const host = isDomainCookie ? hostKey.slice(1) : hostKey; - return { - url: `${secure ? "https" : "http"}://${host}${path}`, - ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }), - }; -}; - -/** The host without Chromium's domain-cookie leading dot, for display. */ -const bareHost = (hostKey: string): string => - hostKey.startsWith(".") ? hostKey.slice(1) : hostKey; - const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => { const buffer = Buffer.from(encrypted); if (buffer.length === 0) return ""; diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts new file mode 100644 index 000000000000..0dbda3461070 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -0,0 +1,84 @@ +/** + * Shared pieces of cookie extraction: the shape both engines produce, and the + * snapshot every reader takes before touching a live database. + * + * @module CookieDatabase + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +/** A cookie in the shape Electron's `session.cookies.set` accepts. */ +export interface ImportedCookie { + readonly url: string; + readonly name: string; + readonly value: string; + /** + * Set only for domain cookies, which the sources mark with a leading dot. + * A host-only cookie leaves this undefined: Electron treats any `domain` it + * is given as marking a domain cookie and re-adds the dot, which would widen + * the cookie to every subdomain of the host it was scoped to, and rejects + * `__Host-` cookies, which require it to be absent. + */ + readonly domain: string | undefined; + readonly path: string; + readonly secure: boolean; + readonly httpOnly: boolean; + /** Seconds since the UNIX epoch, or undefined for a session cookie. */ + readonly expirationDate: number | undefined; + readonly sameSite: "no_restriction" | "lax" | "strict"; +} + +/** + * The URL and domain Electron should register a stored row under. + * + * Both engines mark a domain cookie with a leading dot on the host. Electron + * matches on a URL, so the dot comes off for that; `domain` is passed through + * only for domain cookies, because supplying it at all makes Electron treat + * the cookie as one and re-add the dot — widening a host-only cookie to every + * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, + * which require it to be absent. + */ +export const cookieScope = ( + host: string, + path: string, + secure: boolean, +): { readonly url: string; readonly domain: string | undefined } => { + const isDomainCookie = host.startsWith("."); + return { + url: `${secure ? "https" : "http"}://${bareHost(host)}${path}`, + domain: isDomainCookie ? host : undefined, + }; +}; + +/** A host without the leading dot both engines put on a domain cookie, for display. */ +export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); + +/** + * Copies a cookie database, and its write-ahead sidecars, to a temporary + * directory before reading, and returns the copy's path. + * + * Both engines keep the file open with WAL while the browser runs, so reading + * in place can observe a torn write. Copying also guarantees we never open the + * browser's own file for writing. + * + * Scoped: the temporary directory goes away when the caller's scope closes. + */ +export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( + cookiePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-import-", + }); + const target = path.join(directory, path.basename(cookiePath)); + yield* fileSystem.copyFile(cookiePath, target); + // The sidecars only exist while the browser holds the database open, so a + // missing one is normal rather than a failure. + yield* Effect.forEach(["-wal", "-shm"], (suffix) => + fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(Effect.ignore), + ); + return target; +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts new file mode 100644 index 000000000000..d90d760e77ae --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -0,0 +1,188 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds a Firefox-shaped +// `cookies.sqlite` fixture with the same native bindings Firefox itself uses. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as NodeSqlite from "node:sqlite"; + +import { readFirefoxCookies } from "./FirefoxCookies.ts"; +import { parseFirefoxProfiles } from "./Sources.ts"; + +/** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */ +const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( + rows: ReadonlyArray<{ + host: string; + name: string; + value: string; + path: string; + expiry: number; + isSecure: number; + isHttpOnly: number; + sameSite: number; + }>, +) { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-test-" }); + const file = `${directory}/cookies.sqlite`; + const database = new NodeSqlite.DatabaseSync(file); + database.exec( + `create table moz_cookies ( + id integer primary key, host text, name text, value text, path text, + expiry integer, isSecure integer, isHttpOnly integer, sameSite integer + )`, + ); + const insert = database.prepare( + `insert into moz_cookies (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite) + values (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const row of rows) { + insert.run( + row.host, + row.name, + row.value, + row.path, + row.expiry, + row.isSecure, + row.isHttpOnly, + row.sameSite, + ); + } + database.close(); + return file; +}); + +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("readFirefoxCookies", () => { + it.effect("maps moz_cookies onto the shape Electron accepts", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: ".github.com", + name: "session", + value: "abc", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 1, + sameSite: 1, + }, + { + host: "example.test", + name: "plain", + value: "v", + path: "/app", + // Firefox writes 0 for a session cookie. + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + sameSite: 0, + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies).toEqual([ + { + // The leading dot stays on the domain but not in the URL, which is + // what Electron matches against. + url: "https://github.com/", + name: "session", + value: "abc", + domain: ".github.com", + path: "/", + secure: true, + httpOnly: true, + expirationDate: 1_800_000_000, + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only in Firefox, so no `domain`: supplying one would make + // Electron widen it to every subdomain of example.test. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + // Session cookies carry no expiry rather than one at the epoch. + expirationDate: undefined, + sameSite: "no_restriction", + }, + ]); + }), + ), + ); + + it.effect("reads without mutating the source database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const file = yield* writeFirefoxCookieDatabase([ + { + host: "a.test", + name: "n", + value: "v", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 2, + }, + ]); + const before = yield* fileSystem.stat(file); + + yield* readFirefoxCookies(file); + + // The browser's own file is snapshotted, never opened for writing. + const after = yield* fileSystem.stat(file); + expect(after.mtime).toEqual(before.mtime); + expect(after.size).toBe(before.size); + }), + ), + ); +}); + +describe("parseFirefoxProfiles", () => { + it("reads named profiles and ignores Install sections", () => { + // `Install*` sections name a default profile but do not describe one, so + // counting them would invent a profile whose directory does not exist. + const parsed = parseFirefoxProfiles( + [ + "[Install4F96D1932A9F858E]", + "Default=Profiles/abcd1234.default-release", + "Locked=1", + "", + "[Profile0]", + "Name=default-release", + "IsRelative=1", + "Path=Profiles/abcd1234.default-release", + "", + "[Profile1]", + "Name=Work", + "IsRelative=0", + "Path=/Volumes/External/firefox-work", + "", + "[General]", + "StartWithLastProfile=1", + ].join("\n"), + ); + + expect(parsed).toEqual([ + { directory: "Profiles/abcd1234.default-release", name: "default-release" }, + { directory: "/Volumes/External/firefox-work", name: "Work" }, + ]); + }); + + it("falls back to the path when a profile has no name", () => { + expect(parseFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n"))).toEqual([ + { directory: "Profiles/x.default", name: "Profiles/x.default" }, + ]); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts new file mode 100644 index 000000000000..03cbf1ae7470 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -0,0 +1,74 @@ +/** + * Firefox cookie extraction. + * + * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key + * to fetch and no consent prompt — the file is readable by anything running as + * the user. That is Mozilla's design choice, not a control being circumvented, + * which is why this path works identically on macOS, Windows, and Linux while + * the Chromium one needs a per-platform credential store. + * + * @module FirefoxCookies + */ +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts"; + +/** + * `moz_cookies.sameSite` uses 0 = none, 1 = lax, 2 = strict. Unlike Chromium + * there is no "unspecified" sentinel, but treat anything unrecognised as lax: + * that is the modern default, and guessing "none" would widen a cookie's scope + * on import. + */ +const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { + if (value === 0) return "no_restriction"; + if (value === 2) return "strict"; + return "lax"; +}; + +const CookieRow = Schema.Struct({ + host: Schema.String, + name: Schema.String, + value: Schema.String, + path: Schema.String, + // Already seconds since the UNIX epoch, unlike Chromium's 1601-based + // microseconds, so no conversion is needed. + expiry: Schema.Number, + isSecure: Schema.Number, + isHttpOnly: Schema.Number, + sameSite: Schema.Number, +}); +const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); + +export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( + cookieDatabasePath: string, +) { + const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath); + + const rows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const raw = yield* sql` + select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite + from moz_cookies + `; + return yield* decodeCookieRows(raw); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); + + return rows.map((row) => { + const secure = row.isSecure === 1; + const scope = cookieScope(row.host, row.path, secure); + return { + url: scope.url, + name: row.name, + value: row.value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.isHttpOnly === 1, + expirationDate: row.expiry > 0 ? row.expiry : undefined, + sameSite: sameSiteFromColumn(row.sameSite), + } satisfies ImportedCookie; + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 34d04fca1fcc..c0a6e4c31be3 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -2,20 +2,21 @@ // table with the same native bindings the source reads. import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, 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 Scope from "effect/Scope"; import * as NodeSqlite from "node:sqlite"; +import type { BrowserImportPathContext } from "./Sources.ts"; import { BROWSER_IMPORT_SOURCES, cookieDatabasePath, isSourceInstalled, isSourceRunning, listSourceProfiles, - sourcePaths, + sourcePathContext, } from "./Sources.ts"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -24,13 +25,21 @@ const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; const withSourceHome = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); - const paths = yield* sourcePaths.pipe( + const context = yield* sourcePathContext.pipe( Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), ); - yield* fileSystem.makeDirectory(helium.userDataDirectory(paths), { recursive: true }); - return paths; + yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true }); + return context; }); +/** Every case here runs on darwin, where Helium always resolves a directory. */ +const userDataDirectory = (context: BrowserImportPathContext) => { + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + return root; +}; + const run = (effect: Effect.Effect) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); @@ -49,18 +58,18 @@ describe("isSourceRunning", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - assert.isFalse(yield* isSourceRunning(helium, paths)); + const context = yield* withSourceHome(); + assert.isFalse(yield* isSourceRunning(helium, context)); // Chromium points the lock at `-`, a target that never // exists on disk. A check that follows the link reports a running // browser as closed, letting an import read a live, mid-write database. yield* fileSystem.symlink( "host-that-does-not-exist-1234", - `${helium.userDataDirectory(paths)}/SingletonLock`, + `${userDataDirectory(context)}/SingletonLock`, ); - assert.isTrue(yield* isSourceRunning(helium, paths)); + assert.isTrue(yield* isSourceRunning(helium, context)); }), ), ); @@ -71,28 +80,28 @@ describe("isSourceInstalled", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); + const context = yield* withSourceHome(); + const root = userDataDirectory(context); // Installers for native messaging hosts create an empty user-data // directory for every Chromium fork they know about, so treating the // directory as evidence lists browsers the user does not have. yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); - assert.isFalse(yield* isSourceInstalled(helium, paths)); + assert.isFalse(yield* isSourceInstalled(helium, context)); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); - assert.isTrue(yield* isSourceInstalled(helium, paths)); + assert.isTrue(yield* isSourceInstalled(helium, context)); // A real install whose cookies live outside `Default` still counts: // reporting it as absent hides the source from the menu entirely. yield* fileSystem.remove(`${root}/Default`, { recursive: true }); yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); - assert.isTrue(yield* isSourceInstalled(helium, paths)); + assert.isTrue(yield* isSourceInstalled(helium, context)); yield* fileSystem.remove(root, { recursive: true }); - assert.isFalse(yield* isSourceInstalled(helium, paths)); + assert.isFalse(yield* isSourceInstalled(helium, context)); }), ), ); @@ -103,15 +112,15 @@ describe("listSourceProfiles", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); + const context = yield* withSourceHome(); + const root = userDataDirectory(context); // Assuming `Default` would report a browser whose cookies live in // `Profile 1` as having nothing to import, and it is then hidden. yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Profile 1", name: "Profile 1" }, ]); }), @@ -122,13 +131,13 @@ describe("listSourceProfiles", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); + const context = yield* withSourceHome(); yield* fileSystem.writeFileString( - `${helium.userDataDirectory(paths)}/Local State`, + `${userDataDirectory(context)}/Local State`, `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`, ); - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Default", name: "You" }, // Blank display name falls back to the directory rather than // rendering an empty row. @@ -142,13 +151,13 @@ describe("listSourceProfiles", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); + const context = yield* withSourceHome(); + const root = userDataDirectory(context); yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json"); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Default", name: "Default" }, ]); }), @@ -158,8 +167,8 @@ describe("listSourceProfiles", () => { it.effect("reports nothing when no directory holds a cookie database", () => run( Effect.gen(function* () { - const paths = yield* withSourceHome(); - assert.deepEqual(yield* listSourceProfiles(helium, paths), []); + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(helium, context), []); }), ), ); @@ -184,10 +193,10 @@ describe("cookieDatabasePath", () => { it.effect("places the database under the requested source profile", () => run( Effect.gen(function* () { - const paths = yield* withSourceHome(); + const context = yield* withSourceHome(); assert.equal( - cookieDatabasePath(helium, paths, "Profile 1"), - `${paths.home}/Library/Application Support/net.imput.helium/Profile 1/Cookies`, + cookieDatabasePath(helium, context, "Profile 1"), + `${context.home}/Library/Application Support/net.imput.helium/Profile 1/Cookies`, ); }), ), @@ -199,16 +208,16 @@ describe("listSourceProfiles hardening", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); + const context = yield* withSourceHome(); // `Local State` is writable by anything running as the user, so a // crafted key must not reach `cookieDatabasePath` and read a database // outside the browser's user-data directory. yield* fileSystem.writeFileString( - `${helium.userDataDirectory(paths)}/Local State`, + `${userDataDirectory(context)}/Local State`, `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, ); - const profiles = yield* listSourceProfiles(helium, paths); + const profiles = yield* listSourceProfiles(helium, context); assert.deepEqual( profiles.map((profile) => profile.directory), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 206800b5f824..b7223a28a899 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,64 +1,268 @@ /** * Importable browser sources. * - * Each entry pins its own on-disk and keychain coordinates rather than - * deriving them: Chromium forks do not agree on the convention. Helium, for - * instance, uses the keychain service "Helium Storage Key" / account "Helium" - * where Chrome and its closer relatives use " Safe Storage" / "". + * Two engines are modelled. 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. + * + * Each entry pins its own paths and keychain coordinates rather than deriving + * them, because the forks do not agree. Helium uses the keychain service + * "Helium Storage Key" / account "Helium" where Chrome and its closer + * relatives use " Safe Storage" / "", and the user-data directory + * differs per fork and per platform. * * @module BrowserImportSources */ import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, 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 Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +export type BrowserImportEngine = "chromium" | "firefox"; + /** - * Where a source's files live, resolved once per call rather than read from - * the ambient process so the registry stays testable. + * 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 SourcePaths { +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 const sourcePaths = Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* HostProcessEnvironment; - return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths; -}); - export interface BrowserImportSourceDefinition { readonly id: BrowserImportSourceId; readonly name: string; - /** Platforms the definition's paths are valid for. */ + readonly engine: BrowserImportEngine; + /** Platforms the definition has paths for. */ readonly platforms: ReadonlyArray; - readonly userDataDirectory: (paths: SourcePaths) => string; + readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ + readonly keychainService?: string; + readonly keychainAccount?: string; +} + +const macApplicationSupport = ( + context: BrowserImportPathContext, + ...segments: ReadonlyArray +) => context.path.join(context.home, "Library", "Application Support", ...segments); + +/** + * One Chromium fork across the three platforms. The macOS and Linux leaves + * differ per fork, and Windows nests everything under a `User Data` directory. + * Omitting a platform's segments marks the fork as unavailable there. + */ +const chromiumSource = (input: { + readonly id: BrowserImportSourceId; + readonly name: string; readonly keychainService: string; readonly keychainAccount: string; -} + readonly macSegments: ReadonlyArray; + readonly windowsSegments?: ReadonlyArray; + readonly linuxSegments?: ReadonlyArray; +}): BrowserImportSourceDefinition => ({ + id: input.id, + name: input.name, + engine: "chromium", + platforms: [ + "darwin" as NodeJS.Platform, + ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), + ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), + ], + keychainService: input.keychainService, + keychainAccount: input.keychainAccount, + 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, "User Data") + : undefined; + } + return input.linuxSegments + ? context.path.join(context.home, ".config", ...input.linuxSegments) + : undefined; + }, +}); export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ - { + chromiumSource({ + id: "chrome", + name: "Chrome", + keychainService: "Chrome Safe Storage", + keychainAccount: "Chrome", + macSegments: ["Google", "Chrome"], + windowsSegments: ["Google", "Chrome"], + linuxSegments: ["google-chrome"], + }), + chromiumSource({ + id: "edge", + name: "Microsoft Edge", + keychainService: "Microsoft Edge Safe Storage", + keychainAccount: "Microsoft Edge", + macSegments: ["Microsoft Edge"], + windowsSegments: ["Microsoft", "Edge"], + linuxSegments: ["microsoft-edge"], + }), + chromiumSource({ + id: "brave", + name: "Brave", + keychainService: "Brave Safe Storage", + keychainAccount: "Brave", + macSegments: ["BraveSoftware", "Brave-Browser"], + windowsSegments: ["BraveSoftware", "Brave-Browser"], + linuxSegments: ["BraveSoftware", "Brave-Browser"], + }), + chromiumSource({ + id: "vivaldi", + name: "Vivaldi", + keychainService: "Vivaldi Safe Storage", + keychainAccount: "Vivaldi", + macSegments: ["Vivaldi"], + windowsSegments: ["Vivaldi"], + linuxSegments: ["vivaldi"], + }), + chromiumSource({ + id: "opera", + name: "Opera", + keychainService: "Opera Safe Storage", + keychainAccount: "Opera", + macSegments: ["com.operasoftware.Opera"], + windowsSegments: ["Programs", "Opera"], + linuxSegments: ["opera"], + }), + // Arc and Helium ship macOS-only builds. + chromiumSource({ + id: "arc", + name: "Arc", + keychainService: "Arc Safe Storage", + keychainAccount: "Arc", + macSegments: ["Arc", "User Data"], + }), + chromiumSource({ id: "helium", name: "Helium", - platforms: ["darwin"], - userDataDirectory: ({ path, home }) => - path.join(home, "Library", "Application Support", "net.imput.helium"), keychainService: "Helium Storage Key", keychainAccount: "Helium", + macSegments: ["net.imput.helium"], + }), + { + 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"); + }, }, ]; +/** + * Chromium stores the database as `Cookies` under the profile directory; + * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` + * may already be absolute. + */ export const cookieDatabasePath = ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, + context: BrowserImportPathContext, profileDirectory: string, -): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies"); +): string | undefined => { + const root = definition.userDataDirectory(context); + if (root === undefined) return undefined; + const fileName = definition.engine === "firefox" ? "cookies.sqlite" : "Cookies"; + return context.path.isAbsolute(profileDirectory) + ? context.path.join(profileDirectory, fileName) + : context.path.join(root, profileDirectory, fileName); +}; + +/** + * 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): ReadonlyArray { + const profiles: BrowserImportSourceProfile[] = []; + let current: { name?: string; path?: string } | null = null; + + const flush = () => { + if (current?.path) { + profiles.push({ directory: current.path, name: current.name?.trim() || current.path }); + } + 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; + } + 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; +}); + +/** + * Whether a directory entry exists, without following it or opening it. + * + * Both `stat` and `exists` resolve symlinks, and the locks below deliberately + * dangle — Chromium points `SingletonLock` at `-` and Firefox + * points `lock` at `:+`, neither of which exists on disk. Following + * them reports every running browser as closed, which would let an import read + * a live, mid-write database. `readLink` is the probe that answers for the + * entry itself. + * + * Not opening the file is what lets Safari be detected: TCC permits `stat` on + * the jar inside its container but refuses a read. + */ +const entryExists = Effect.fnUntraced(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.stat(path).pipe( + Effect.catchCause(() => fileSystem.readLink(path)), + Effect.as(true), + Effect.orElseSucceed(() => false), + ); +}); /** Shape of the slice of Chromium's `Local State` that names its profiles. */ const LocalState = Schema.Struct({ @@ -124,26 +328,44 @@ const withCookieCounts = ( ); /** - * Profiles the source browser knows about, read from its `Local State`. + * Profiles the source browser knows about. * - * When that file is missing, unreadable or malformed, the user-data directory - * is scanned for directories that hold a cookie database. Assuming `Default` - * instead would report a browser whose cookies live in `Profile 1` as having + * 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. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, -) { + context: BrowserImportPathContext, +): Effect.fn.Return, never, FileSystem.FileSystem> { const fileSystem = yield* FileSystem.FileSystem; - const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State"); + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + + if (definition.engine === "firefox") { + const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( + Effect.map(parseFirefoxProfiles), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + if (declared.length > 0) return declared; + + // No readable `profiles.ini`, so fall back to scanning the directory the + // profiles actually live in. + return yield* fileSystem.readDirectory(context.path.join(root, "Profiles")).pipe( + Effect.map((entries) => + entries.map((entry) => ({ directory: context.path.join("Profiles", entry), name: entry })), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + } - const root = definition.userDataDirectory(paths); - const declared = yield* fileSystem.readFileString(localStatePath).pipe( + const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( Effect.flatMap(decodeLocalState), Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), // The keys are directory names from the browser's own metadata file, which - // is writable by anything running as the user. Anything but a single plain + // anything running as the user can write. Anything but a single plain // segment is dropped: `..` or a path separator would otherwise be handed // to `cookieDatabasePath` and read a database outside the user-data // directory. @@ -155,16 +377,13 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ); if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared); - // `Local State` is missing, unreadable or malformed. Scanning for - // directories that hold a cookie database finds the profiles anyway; - // assuming `Default` would report a browser whose cookies live in - // `Profile 1` as having nothing to import, and it is then hidden entirely. + // `Local State` is missing, unreadable or malformed. Scanning for directories + // that hold a cookie database finds the profiles anyway. const entries = yield* fileSystem .readDirectory(root) .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); - const candidates = entries.filter(isSafeProfileDirectory); - const found = yield* Effect.forEach(candidates, (directory) => - entryExists(cookieDatabasePath(definition, paths, directory)).pipe( + const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) => + entryExists(cookieDatabasePath(definition, context, directory) ?? "").pipe( Effect.map((exists) => (exists ? { directory, name: directory } : undefined)), ), ); @@ -175,36 +394,19 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ); }); -/** - * Whether a directory entry exists, without following it or opening it. - * - * `stat` resolves symlinks and the locks below deliberately dangle, so - * `readLink` is the probe that answers for the entry itself. - */ -const entryExists = Effect.fnUntraced(function* (path: string) { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.stat(path).pipe( - Effect.catchCause(() => fileSystem.readLink(path)), - Effect.as(true), - Effect.orElseSucceed(() => false), - ); -}); - /** Whether the browser is running, which leaves its cookie DB mid-write. */ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, -) { - const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock"); - // Chromium writes a `SingletonLock` symlink for as long as an instance holds - // the profile. Its presence is a far cheaper and more targeted signal than - // scanning the process table for a name. - // - // The link points at `-`, a target that never exists, and both - // `stat` and `exists` follow links — so they report every running browser as - // closed, which would let an import read a live, mid-write database. - // `readLink` is the one probe that answers for the entry itself. - return yield* entryExists(lock); + context: BrowserImportPathContext, +): Effect.fn.Return { + const root = definition.userDataDirectory(context); + if (root === undefined) return false; + // Chromium writes a `SingletonLock` symlink and Firefox a `lock` / + // `parent.lock` for as long as an instance holds the profile. Far cheaper + // and more targeted than scanning the process table for a name. + const locks = definition.engine === "firefox" ? ["lock", "parent.lock"] : ["SingletonLock"]; + const found = yield* Effect.forEach(locks, (lock) => entryExists(context.path.join(root, lock))); + return found.some(Boolean); }); /** @@ -224,11 +426,12 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") */ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, -) { - const profiles = yield* listSourceProfiles(definition, paths); - const found = yield* Effect.forEach(profiles, (profile) => - entryExists(cookieDatabasePath(definition, paths, profile.directory)), - ); + context: BrowserImportPathContext, +): Effect.fn.Return { + const profiles = yield* listSourceProfiles(definition, context); + const found = yield* Effect.forEach(profiles, (profile) => { + const database = cookieDatabasePath(definition, context, profile.directory); + return database === undefined ? Effect.succeed(false) : entryExists(database); + }); return found.some(Boolean); }); diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index d7e2aa26444f..70d7f8ae6b62 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -16,7 +16,16 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; -export const BROWSER_IMPORT_SOURCE_IDS = ["helium"] as const; +export const BROWSER_IMPORT_SOURCE_IDS = [ + "chrome", + "edge", + "brave", + "vivaldi", + "opera", + "arc", + "helium", + "firefox", +] as const; export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); export type BrowserImportSourceId = typeof BrowserImportSourceId.Type; From e832b1bcb0f9ea51c1e974405386304bce0c29f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 00:35:16 +0200 Subject: [PATCH 2/6] fix(desktop): detect a running Firefox, find Opera on Windows, keep containers apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firefox keeps its lock files inside each profile, not at the user-data root, so the running check never found them and offered imports from a live, mid-write database. It now walks the source's profiles and looks for all three names the platforms use. Opera does not follow the local-AppData `User Data` convention its Chromium relatives use — it lives under roaming `%APPDATA%\Opera Software\Opera Stable` — so it was never detected on Windows. Firefox isolates cookies per container and per private window through `originAttributes`. Electron has no equivalent, so importing them all collapsed several identities onto one host/name/path and handed the profile whichever container was written last. Only the default container is imported. The sidecar copy no longer ignores every error alongside the missing-file case; the new snapshot test caught that the earlier fix had landed on the pre-extraction copy of this code rather than the shared module. Co-Authored-By: Claude Opus 5 (1M context) --- .../BrowserImport/CookieDatabase.test.ts | 63 +++++++++++++++++++ .../preview/BrowserImport/CookieDatabase.ts | 13 +++- .../BrowserImport/FirefoxCookies.test.ts | 58 ++++++++++++++++- .../preview/BrowserImport/FirefoxCookies.ts | 6 ++ .../src/preview/BrowserImport/Sources.test.ts | 58 +++++++++++++++++ .../src/preview/BrowserImport/Sources.ts | 48 +++++++++++--- 6 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts new file mode 100644 index 000000000000..da1392f03453 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -0,0 +1,63 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { snapshotCookieDatabase } from "./CookieDatabase.ts"; + +const run = (effect: Effect.Effect) => effect; + +describe("snapshotCookieDatabase", () => { + it.effect("copies the write-ahead sidecars alongside the database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-snap-" }); + const source = `${directory}/Cookies`; + yield* fileSystem.writeFileString(source, "db"); + yield* fileSystem.writeFileString(`${source}-wal`, "wal"); + + const snapshot = yield* snapshotCookieDatabase(source); + + assert.equal(yield* fileSystem.readFileString(snapshot), "db"); + assert.equal(yield* fileSystem.readFileString(`${snapshot}-wal`), "wal"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ), + ); + + it.effect("treats an absent sidecar as normal", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-snap-" }); + const source = `${directory}/Cookies`; + // A closed browser has already checkpointed its WAL away, which is the + // common case rather than a failure. + yield* fileSystem.writeFileString(source, "db"); + + const snapshot = yield* snapshotCookieDatabase(source); + + assert.equal(yield* fileSystem.readFileString(snapshot), "db"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ), + ); + + it.effect("fails when a sidecar exists but cannot be read", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-snap-" }); + const source = `${directory}/Cookies`; + yield* fileSystem.writeFileString(source, "db"); + // A sidecar that is present but uncopyable, rather than absent. + yield* fileSystem.makeDirectory(`${source}-wal`); + + // Ignoring this would open the snapshot without its write-ahead log + // and silently return a cookie set missing its newest transactions. + const error = yield* snapshotCookieDatabase(source).pipe(Effect.scoped, Effect.flip); + + assert.notEqual(error.reason._tag, "NotFound"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts index 0dbda3461070..261c4c0d9160 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -75,10 +75,17 @@ export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDa }); const target = path.join(directory, path.basename(cookiePath)); yield* fileSystem.copyFile(cookiePath, target); - // The sidecars only exist while the browser holds the database open, so a - // missing one is normal rather than a failure. + // A sidecar only exists while the browser holds the database open, so an + // absent one is normal. Anything else — a permission error, a partial read + // — is not: SQLite would then open the snapshot without the write-ahead + // log and quietly return a cookie set missing its newest transactions. yield* Effect.forEach(["-wal", "-shm"], (suffix) => - fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(Effect.ignore), + fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.void, + ), + ), ); return target; }); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index d90d760e77ae..320e9ca62203 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -22,6 +22,7 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( isSecure: number; isHttpOnly: number; sameSite: number; + originAttributes?: string; }>, ) { const fileSystem = yield* FileSystem.FileSystem; @@ -31,12 +32,14 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( database.exec( `create table moz_cookies ( id integer primary key, host text, name text, value text, path text, - expiry integer, isSecure integer, isHttpOnly integer, sameSite integer + expiry integer, isSecure integer, isHttpOnly integer, sameSite integer, + originAttributes text not null default '' )`, ); const insert = database.prepare( - `insert into moz_cookies (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite) - values (?, ?, ?, ?, ?, ?, ?, ?)`, + `insert into moz_cookies + (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, originAttributes) + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); for (const row of rows) { insert.run( @@ -48,6 +51,7 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( row.isSecure, row.isHttpOnly, row.sameSite, + row.originAttributes ?? "", ); } database.close(); @@ -120,6 +124,54 @@ describe("readFirefoxCookies", () => { ), ); + it.effect("imports only the default container", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: "mail.test", + name: "session", + value: "default-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + }, + { + // Same host, name and path as above: Firefox keeps these apart by + // container, Electron cannot, so importing both would hand the + // profile whichever one happened to be written last. + host: "mail.test", + name: "session", + value: "work-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^userContextId=2", + }, + { + host: "mail.test", + name: "private", + value: "private-window", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^privateBrowsingId=1", + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies.map((cookie) => cookie.value)).toEqual(["default-container"]); + }), + ), + ); + it.effect("reads without mutating the source database", () => run( Effect.gen(function* () { diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 03cbf1ae7470..83f842f0a4a0 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -49,9 +49,15 @@ export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies") const rows = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Only the default container. Firefox isolates cookies per container and + // per private window via `originAttributes` (`^userContextId=2`, + // `^privateBrowsingId=1`); Electron has no equivalent, so importing them + // all would collapse several identities onto one host/name/path and hand + // the profile an arbitrary container's session. const raw = yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite from moz_cookies + where originAttributes = '' `; return yield* decodeCookieRows(raw); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index c0a6e4c31be3..aeee644f3c8e 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -203,6 +203,64 @@ describe("cookieDatabasePath", () => { ); }); +const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; +const opera = BROWSER_IMPORT_SOURCES.find((source) => source.id === "opera")!; + +describe("isSourceRunning for Firefox", () => { + it.effect("finds the lock inside the profile, not at the root", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/abcd.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // Firefox keeps its locks per profile. A root-level lock is not one, + // and looking there was why a running Firefox read as importable. + yield* fileSystem.writeFileString(`${root}/lock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); + assert.isTrue(yield* isSourceRunning(firefox, context)); + }), + ), + ); +}); + +describe("Windows user-data directories", () => { + it.effect("puts Opera under roaming AppData without a User Data level", () => + run( + Effect.gen(function* () { + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + USERPROFILE: "C:\\Users\\u", + APPDATA: "C:\\Users\\u\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", + }), + Effect.provideService(HostProcessPlatform, "win32"), + ); + + // Opera does not follow the local-AppData `User Data` convention its + // Chromium relatives use, so deriving it that way never found it. + assert.include(opera.userDataDirectory(context) ?? "", "Roaming"); + assert.include(opera.userDataDirectory(context) ?? "", "Opera Stable"); + assert.notInclude(opera.userDataDirectory(context) ?? "", "User Data"); + + const chrome = BROWSER_IMPORT_SOURCES.find((source) => source.id === "chrome")!; + assert.include(chrome.userDataDirectory(context) ?? "", "Local"); + assert.include(chrome.userDataDirectory(context) ?? "", "User Data"); + }), + ), + ); +}); + describe("listSourceProfiles hardening", () => { it.effect("drops profile directories that are not a single plain segment", () => run( diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index b7223a28a899..f90cfb4f1478 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -59,8 +59,9 @@ const macApplicationSupport = ( /** * One Chromium fork across the three platforms. The macOS and Linux leaves - * differ per fork, and Windows nests everything under a `User Data` directory. - * Omitting a platform's segments marks the fork as unavailable there. + * differ per fork, and Windows usually nests everything under a `User Data` + * directory in local AppData. Omitting a platform's segments marks the fork as + * unavailable there. */ const chromiumSource = (input: { readonly id: BrowserImportSourceId; @@ -69,6 +70,12 @@ const chromiumSource = (input: { readonly keychainAccount: string; readonly macSegments: ReadonlyArray; readonly windowsSegments?: ReadonlyArray; + /** + * Forks that sit under roaming `%APPDATA%` with no `User Data` level. Opera + * is the one that does this; everything else follows the local-AppData + * convention above. + */ + readonly windowsRoamingSegments?: ReadonlyArray; readonly linuxSegments?: ReadonlyArray; }): BrowserImportSourceDefinition => ({ id: input.id, @@ -76,7 +83,7 @@ const chromiumSource = (input: { engine: "chromium", platforms: [ "darwin" as NodeJS.Platform, - ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), + ...(input.windowsSegments || input.windowsRoamingSegments ? ["win32" as NodeJS.Platform] : []), ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), ], keychainService: input.keychainService, @@ -84,6 +91,11 @@ const chromiumSource = (input: { userDataDirectory: (context) => { if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); if (context.platform === "win32") { + if (input.windowsRoamingSegments) { + return context.appData + ? context.path.join(context.appData, ...input.windowsRoamingSegments) + : undefined; + } return input.windowsSegments && context.localAppData ? context.path.join(context.localAppData, ...input.windowsSegments, "User Data") : undefined; @@ -137,7 +149,7 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray { const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Chromium writes a `SingletonLock` symlink and Firefox a `lock` / - // `parent.lock` for as long as an instance holds the profile. Far cheaper - // and more targeted than scanning the process table for a name. - const locks = definition.engine === "firefox" ? ["lock", "parent.lock"] : ["SingletonLock"]; - const found = yield* Effect.forEach(locks, (lock) => entryExists(context.path.join(root, lock))); + // Both engines leave a lock file for as long as an instance holds a profile, + // which is far cheaper and more targeted than scanning the process table. + // + // They differ in where: Chromium keeps one `SingletonLock` for the whole + // user-data directory, Firefox keeps its locks inside each profile, under + // three names across platforms (`lock` on macOS and Linux, `.parentlock` + // beside it, `parent.lock` on Windows). Looking for Firefox's at the root + // finds nothing and reports a running browser as importable. + if (definition.engine !== "firefox") { + return yield* entryExists(context.path.join(root, "SingletonLock")); + } + + const profiles = yield* listSourceProfiles(definition, context); + const found = yield* Effect.forEach(profiles, (profile) => { + const directory = context.path.isAbsolute(profile.directory) + ? profile.directory + : context.path.join(root, profile.directory); + return Effect.forEach(FIREFOX_LOCK_NAMES, (lock) => + entryExists(context.path.join(directory, lock)), + ).pipe(Effect.map((results) => results.some(Boolean))); + }); return found.some(Boolean); }); From 05b5a95c4a32c9f558b3059384c5b92cc6b88025 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 00:56:12 +0200 Subject: [PATCH 3/6] refactor(desktop): give the Firefox read path a tagged error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Firefox branch failed with an anonymous `{ reason, cause }` literal, and typing the shared `read` value as that shape erased `ChromiumCookieReadError`'s tag from the error channel — neither branch could then be handled with `Effect.catchTags`. `FirefoxCookieReadError` mirrors its Chromium counterpart, so the union stays structurally identifiable. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/BrowserImport.ts | 20 +++++++++---- .../preview/BrowserImport/FirefoxCookies.ts | 30 +++++++++++++++++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index b733ec7231a2..5406fc6a6457 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -22,9 +22,13 @@ import * as Scope from "effect/Scope"; import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as BrowserSession from "../BrowserSession.ts"; -import { readChromiumCookies, type CookieReadResult } from "./ChromiumCookies.ts"; +import { + ChromiumCookieReadError, + readChromiumCookies, + type CookieReadResult, +} from "./ChromiumCookies.ts"; import type { ImportedCookie } from "./CookieDatabase.ts"; -import { readFirefoxCookies } from "./FirefoxCookies.ts"; +import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; import { BROWSER_IMPORT_SOURCES, cookieDatabasePath, @@ -169,17 +173,21 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - // Normalized to one shape so the skipped tally survives either engine: - // Firefox stores plaintext, so nothing there is ever unreadable. + // Both branches fail with a tagged error carrying a `reason`, so the union + // stays structurally identifiable rather than collapsing to an anonymous + // shape that `Effect.catchTags` could not tell apart. + // Both branches fail with a tagged error, so the union stays structurally + // identifiable. The success side is normalized to one shape too, so the + // skipped tally survives either engine — Firefox stores plaintext, so + // nothing there is ever unreadable. const read: Effect.Effect< CookieReadResult, - { readonly reason: BrowserImportFailureReason }, + ChromiumCookieReadError | FirefoxCookieReadError, FileSystem.FileSystem | Path.Path | Scope.Scope > = definition.engine === "firefox" ? readFirefoxCookies(databasePath).pipe( Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), - Effect.mapError((cause) => ({ reason: "readFailed" as const, cause })), ) : readChromiumCookies({ cookieDatabasePath: databasePath, diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 83f842f0a4a0..fc6d6f099c03 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -22,6 +22,27 @@ import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./Cook * that is the modern default, and guessing "none" would widen a cookie's scope * on import. */ +export const FirefoxCookieReadReason = Schema.Literals(["readFailed"]); +export type FirefoxCookieReadReason = typeof FirefoxCookieReadReason.Type; + +/** + * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error + * the service can tell apart, rather than one of them widening the channel to + * an anonymous shape. + */ +export class FirefoxCookieReadError extends Schema.TaggedErrorClass()( + "FirefoxCookieReadError", + { + reason: FirefoxCookieReadReason, + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not read Firefox cookies: ${this.reason}.`; + } +} + const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { if (value === 0) return "no_restriction"; if (value === 2) return "strict"; @@ -45,7 +66,9 @@ const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( cookieDatabasePath: string, ) { - const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath); + const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( + Effect.mapError((cause) => new FirefoxCookieReadError({ reason: "readFailed", cause })), + ); const rows = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -60,7 +83,10 @@ export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies") where originAttributes = '' `; return yield* decodeCookieRows(raw); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), + Effect.mapError((cause) => new FirefoxCookieReadError({ reason: "readFailed", cause })), + ); return rows.map((row) => { const secure = row.isSecure === 1; From 54bff364ca0be988337439ad7c97054332817c3c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 01:22:59 +0200 Subject: [PATCH 4/6] refactor(desktop): name the database a Firefox read failed on Firefox keeps one cookie database per profile, so a failure carrying only a reason cannot be traced back to the profile that produced it. The path is now a structural attribute, matching `ChromiumCookieReadError`. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/preview/BrowserImport/FirefoxCookies.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index fc6d6f099c03..30685ab2b486 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -34,12 +34,18 @@ export class FirefoxCookieReadError extends Schema.TaggedErrorClass new FirefoxCookieReadError({ reason: "readFailed", cause })), + Effect.mapError( + (cause) => new FirefoxCookieReadError({ reason: "readFailed", cookieDatabasePath, cause }), + ), ); const rows = yield* Effect.gen(function* () { @@ -85,7 +93,9 @@ export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies") return yield* decodeCookieRows(raw); }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), - Effect.mapError((cause) => new FirefoxCookieReadError({ reason: "readFailed", cause })), + Effect.mapError( + (cause) => new FirefoxCookieReadError({ reason: "readFailed", cookieDatabasePath, cause }), + ), ); return rows.map((row) => { From 124c5043d696106ba3aaa43ea43c751e7c8fe0d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 03:16:35 +0200 Subject: [PATCH 5/6] refactor(desktop): drop the redundant reason from the Firefox read error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firefox has one failure mode — its plaintext database would not open — so a single-value `reason` literal encoded the same thing as the tag, and `cause` was optional though every construction site wraps a real failure. The error now carries the database path and a required cause, and `BrowserImport` supplies the user-facing reason where it maps the union. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/BrowserImport.ts | 9 +++++++- .../preview/BrowserImport/FirefoxCookies.ts | 22 ++++++++----------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 5406fc6a6457..145aaf07c558 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -203,7 +203,14 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.provide(platformServices), Effect.mapError( (cause) => - new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + new BrowserImportFailedError({ + sourceId: definition.id, + // Firefox has one failure mode — its plaintext database would not + // open — so its error carries no reason of its own and the + // user-facing one is supplied here. + reason: cause._tag === "FirefoxCookieReadError" ? "readFailed" : cause.reason, + cause, + }), ), ); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 30685ab2b486..fbbca3f5495a 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -22,30 +22,30 @@ import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./Cook * that is the modern default, and guessing "none" would widen a cookie's scope * on import. */ -export const FirefoxCookieReadReason = Schema.Literals(["readFailed"]); -export type FirefoxCookieReadReason = typeof FirefoxCookieReadReason.Type; - /** * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error * the service can tell apart, rather than one of them widening the channel to * an anonymous shape. + * + * No `reason` field: unlike Chromium there is only one way this fails — the + * plaintext database would not open — and the tag already says which engine it + * was. `BrowserImport` supplies the user-facing reason when it maps the union. */ export class FirefoxCookieReadError extends Schema.TaggedErrorClass()( "FirefoxCookieReadError", { - reason: FirefoxCookieReadReason, /** * Which database the read was for. Firefox keeps one per profile, so * without it a failure cannot be traced back to the profile that caused * it. */ cookieDatabasePath: Schema.String, - /** Kept for the log; never surfaced to the user. */ - cause: Schema.optional(Schema.Defect()), + /** Always present: every construction site wraps a real failure. */ + cause: Schema.Defect(), }, ) { override get message(): string { - return `Could not read Firefox cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`; } } @@ -73,9 +73,7 @@ export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies") cookieDatabasePath: string, ) { const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( - Effect.mapError( - (cause) => new FirefoxCookieReadError({ reason: "readFailed", cookieDatabasePath, cause }), - ), + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), ); const rows = yield* Effect.gen(function* () { @@ -93,9 +91,7 @@ export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies") return yield* decodeCookieRows(raw); }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), - Effect.mapError( - (cause) => new FirefoxCookieReadError({ reason: "readFailed", cookieDatabasePath, cause }), - ), + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), ); return rows.map((row) => { From f17e12330b02f4f305185b85ada2727ac19c3865 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 03:55:26 +0200 Subject: [PATCH 6/6] refactor(desktop): handle the cookie read failures by tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The union was discriminated with a `cause._tag` ternary inside `Effect.mapError` even though both members are statically known tagged errors; `Effect.catchTags` says the same thing without the manual check. The comment above `read` claiming both carry a `reason` was stale — Firefox's no longer does. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/BrowserImport.ts | 34 +++++++++---------- .../src/preview/BrowserImport/Sources.ts | 19 +++++------ 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 145aaf07c558..243fd539a8fb 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -27,7 +27,6 @@ import { readChromiumCookies, type CookieReadResult, } from "./ChromiumCookies.ts"; -import type { ImportedCookie } from "./CookieDatabase.ts"; import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; import { BROWSER_IMPORT_SOURCES, @@ -173,13 +172,10 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - // Both branches fail with a tagged error carrying a `reason`, so the union - // stays structurally identifiable rather than collapsing to an anonymous - // shape that `Effect.catchTags` could not tell apart. // Both branches fail with a tagged error, so the union stays structurally - // identifiable. The success side is normalized to one shape too, so the - // skipped tally survives either engine — Firefox stores plaintext, so - // nothing there is ever unreadable. + // identifiable and each tag is handled on its own below. The success side + // is normalized to one shape too, so the skipped tally survives either + // engine — Firefox stores plaintext, so nothing there is ever unreadable. const read: Effect.Effect< CookieReadResult, ChromiumCookieReadError | FirefoxCookieReadError, @@ -201,17 +197,19 @@ export const make = Effect.gen(function* BrowserImportMake() { const result = yield* read.pipe( Effect.scoped, Effect.provide(platformServices), - Effect.mapError( - (cause) => - new BrowserImportFailedError({ - sourceId: definition.id, - // Firefox has one failure mode — its plaintext database would not - // open — so its error carries no reason of its own and the - // user-facing one is supplied here. - reason: cause._tag === "FirefoxCookieReadError" ? "readFailed" : cause.reason, - cause, - }), - ), + Effect.catchTags({ + ChromiumCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), + // Firefox has one failure mode — its plaintext database would not open + // — so its error carries no reason of its own and the user-facing one + // is supplied here. + FirefoxCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), + ), + }), ); const session = yield* browserSession.getSession(input.scope, input.persistent).pipe( diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index f90cfb4f1478..20b954642c29 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -307,32 +307,29 @@ const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow */ const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, + context: BrowserImportPathContext, directory: string, ): Effect.fn.Return { + const databasePath = cookieDatabasePath(definition, context, directory); + if (databasePath === undefined) return undefined; return yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const rows = yield* sql`select count(*) as count from cookies`; const [row] = yield* decodeCookieCount(rows); return row?.count; }).pipe( - Effect.provide( - NodeSqliteClient.layer({ - filename: cookieDatabasePath(definition, paths, directory), - readonly: true, - }), - ), + Effect.provide(NodeSqliteClient.layer({ filename: databasePath, readonly: true })), Effect.orElseSucceed(() => undefined), ); }); const withCookieCounts = ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, + context: BrowserImportPathContext, profiles: ReadonlyArray, ) => Effect.forEach(profiles, (profile) => - countProfileCookies(definition, paths, profile.directory).pipe( + countProfileCookies(definition, context, profile.directory).pipe( Effect.map((cookieCount) => cookieCount === undefined ? profile : { ...profile, cookieCount }, ), @@ -387,7 +384,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared); + if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); // `Local State` is missing, unreadable or malformed. Scanning for directories // that hold a cookie database finds the profiles anyway. @@ -401,7 +398,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ); return yield* withCookieCounts( definition, - paths, + context, found.filter((profile) => profile !== undefined), ); });