/** * Browser import service - lists importable sources and writes their cookies * into a T3 Code browser profile's Electron partition. * * @module BrowserImport */ import type { BrowserImportInput, BrowserImportResult, BrowserImportSource, BrowserImportUnavailableReason, } from "@t3tools/contracts"; import { BrowserImportFailureReason } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type { Session } from "electron"; import * as Effect from "effect/Effect"; 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 { ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as BrowserSession from "../BrowserSession.ts"; import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; import type { CookieReadResult } from "./CookieDatabase.ts"; import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts"; import { BROWSER_IMPORT_SOURCES, resolveCookieDatabase, isSourceInstalled, isSourceRunning, listSourceProfiles, sourcePathContext, type BrowserImportPathContext, type BrowserImportSourceDefinition, } from "./Sources.ts"; export class BrowserImportFailedError extends Schema.TaggedError()( "BrowserImportFailedError", { sourceId: Schema.String, reason: BrowserImportFailureReason, /** Kept for the log; the user only ever sees the reason's copy. */ cause: Schema.optional(Schema.Defect()), }, ) { // The reason token is part of the message on purpose: IPC flattens the error // to its message, and the renderer maps that token back to user-facing copy. override get message(): string { return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`; } } export class BrowserCookieWriteError extends Schema.TaggedError()( "BrowserCookieWriteError", { url: Schema.String, name: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Could not write imported cookie ${this.name} for ${this.url}.`; } } export class BrowserImport extends Context.Service< BrowserImport, { readonly listSources: Effect.Effect>; readonly importCookies: (input: { readonly input: BrowserImportInput; /** Partition scope of the target profile, derived by the caller in main. */ readonly scope: string; readonly persistent: boolean; readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; }) => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, ): Effect.fn.Return< BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner > { if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; // Safari's jar is found by `stat`, which TCC permits without Full Disk // Access — so a Safari that lists as ready may still refuse the read. Probe // the grant here, so the wizard can open on the permission step and a // post-grant recheck can tell granted from still-denied, rather than only // discovering it by attempting the import. if (definition.engine === "safari") { const jar = yield* resolveCookieDatabase(definition, context, "."); if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; } return undefined; }); /** The host a constructed cookie URL points at, for naming what was skipped. */ const cookieHost = (url: string): string => { try { return new URL(url).hostname; } catch { return url; } }; export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( session: { readonly cookies: Pick }, read: CookieReadResult, ) { let imported = 0; let skipped = read.undecryptable; const skippedDomains = new Set(read.undecryptableHosts); for (const cookie of read.cookies) { const written = yield* Effect.tryPromise({ try: () => session.cookies.set({ url: cookie.url, name: cookie.name, value: cookie.value, // Omitted for host-only cookies: Electron reads any `domain` as a // domain cookie and re-adds the leading dot, widening its scope. ...(cookie.domain === undefined ? {} : { domain: cookie.domain }), path: cookie.path, secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite, ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }), }), catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), }).pipe( Effect.as(true), Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), Effect.catchTags({ BrowserCookieWriteError: () => Effect.succeed(false) }), ); if (written) { imported += 1; } else { skipped += 1; skippedDomains.add(cookieHost(cookie.url)); } } // `set` resolves once the cookie is in memory; Chromium writes the store to // disk on its own schedule. Flush before reporting "Done", so a crash right // after does not lose what the user was just told was imported. A failed // flush is logged rather than surfaced: the cookies are still in the // session and land on disk at the next scheduled write. if (imported > 0) { yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( Effect.tapError((error) => Effect.logWarning("Imported cookies could not be flushed to disk", { cause: error.cause }), ), Effect.ignore, ); } return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; }); /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcessPlatform; const executablePath = yield* HostProcessExecutablePath; // 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< FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >(); const pathContext = yield* sourcePathContext; const listSources: Effect.Effect> = Effect.forEach( BROWSER_IMPORT_SOURCES, Effect.fnUntraced(function* (definition) { 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, pathContext) : [], ...(unavailable === undefined ? {} : { unavailable }), } satisfies BrowserImportSource; }), ).pipe(Effect.provide(platformServices)); const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { readonly input: BrowserImportInput; readonly scope: string; readonly persistent: boolean; readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; }) { const definition = BROWSER_IMPORT_SOURCES.find( (candidate) => candidate.id === input.input.sourceId, ); if (!definition) { return yield* new BrowserImportFailedError({ sourceId: input.input.sourceId, reason: "unknownSource", }); } const blocked = yield* unavailableReason(definition, pathContext).pipe( Effect.provide(platformServices), ); if (blocked !== undefined) { return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); } if (platform === "darwin" && definition.engine === "chromium") { // macOS attributes the Keychain prompt and the resulting ACL grant to the // executable that asks, so record which one that was — in a packaged build // it is the signed app, in dev whatever binary hosts the main process. yield* Effect.logInfo("Reading browser cookie key from the keychain", { sourceId: definition.id, executablePath, }); } // The profile directory arrives over IPC, so it is only honoured when the // 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, pathContext).pipe( Effect.provide(platformServices), ); const requestedProfile = sourceProfiles.find( (profile) => profile.directory === input.input.sourceProfileDirectory, ); if (requestedProfile === undefined) { return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "unknownSourceProfile", }); } // The profile was listed against a database moments ago; resolve it again // rather than assume a path, since a Chromium jar may sit under `Network/`. const databasePath = yield* resolveCookieDatabase( definition, pathContext, requestedProfile.directory, ).pipe(Effect.provide(platformServices)); if (databasePath === undefined) { // A profile we listed moments ago can lose its database before the // import runs (browser data cleanup, a profile reset). That is a read // failure, not a platform problem. return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); } // Both branches fail with a tagged error, so the union stays structurally // 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 userDataDirectory = definition.userDataDirectory(pathContext); const read: Effect.Effect< CookieReadResult, ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > = definition.engine === "safari" ? readSafariCookies(databasePath).pipe( Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), ) : definition.engine === "firefox" ? readFirefoxCookies(databasePath).pipe( Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), ) : readChromiumCookies({ cookieDatabasePath: databasePath, keychainService: definition.keychainService, keychainAccount: definition.keychainAccount, linuxSecretApplication: definition.linuxSecretApplication, ...(platform === "win32" && userDataDirectory !== undefined ? { windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), } : {}), platform, }); const result = yield* read.pipe( Effect.scoped, Effect.provide(platformServices), 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 }), ), // Safari's reasons are already user-facing: a TCC refusal is the Full // Disk Access prompt, anything else is a read failure. SafariCookieReadError: (cause) => Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), ), }), ); const session = yield* browserSession .getSession(input.scope, input.persistent, input.namespace) .pipe( Effect.mapError( (cause) => new BrowserImportFailedError({ sourceId: definition.id, reason: "sessionUnavailable", cause, }), ), ); // Written one at a time rather than in parallel: Chromium's cookie store // serialises writes anyway, and a rejected cookie should only cost itself. return yield* writeCookies(session, result); }); return BrowserImport.of({ listSources, importCookies }); }); export const layer = Layer.effect(BrowserImport, make);