337 lines · 13.5 KB · TypeScript
RawDownload
/** * 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>()(  "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>()(  "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<ReadonlyArray<BrowserImportSource>>;    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<BrowserImportResult, BrowserImportFailedError>;  }>()("@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<Session["cookies"], "set" | "flushStore"> },  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<ReadonlyArray<BrowserImportSource>> = 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);