| 1 | /** |
| 2 | * Browser import service - lists importable sources and writes their cookies |
| 3 | * into a T3 Code browser profile's Electron partition. |
| 4 | * |
| 5 | * @module BrowserImport |
| 6 | */ |
| 7 | import type { |
| 8 | BrowserImportInput, |
| 9 | BrowserImportResult, |
| 10 | BrowserImportSource, |
| 11 | BrowserImportUnavailableReason, |
| 12 | } from "@t3tools/contracts"; |
| 13 | import { BrowserImportFailureReason } from "@t3tools/contracts"; |
| 14 | import * as Context from "effect/Context"; |
| 15 | import type { Session } from "electron"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import * as FileSystem from "effect/FileSystem"; |
| 18 | import * as Layer from "effect/Layer"; |
| 19 | import * as Path from "effect/Path"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | import * as Scope from "effect/Scope"; |
| 22 | import { ChildProcessSpawner } from "effect/process"; |
| 23 | |
| 24 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 25 | |
| 26 | import * as BrowserSession from "../BrowserSession.ts"; |
| 27 | import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; |
| 28 | import type { CookieReadResult } from "./CookieDatabase.ts"; |
| 29 | import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; |
| 30 | import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts"; |
| 31 | import { |
| 32 | BROWSER_IMPORT_SOURCES, |
| 33 | resolveCookieDatabase, |
| 34 | isSourceInstalled, |
| 35 | isSourceRunning, |
| 36 | listSourceProfiles, |
| 37 | sourcePathContext, |
| 38 | type BrowserImportPathContext, |
| 39 | type BrowserImportSourceDefinition, |
| 40 | } from "./Sources.ts"; |
| 41 | |
| 42 | export class BrowserImportFailedError extends Schema.TaggedError<BrowserImportFailedError>()( |
| 43 | "BrowserImportFailedError", |
| 44 | { |
| 45 | sourceId: Schema.String, |
| 46 | reason: BrowserImportFailureReason, |
| 47 | /** Kept for the log; the user only ever sees the reason's copy. */ |
| 48 | cause: Schema.optional(Schema.Defect()), |
| 49 | }, |
| 50 | ) { |
| 51 | // The reason token is part of the message on purpose: IPC flattens the error |
| 52 | // to its message, and the renderer maps that token back to user-facing copy. |
| 53 | override get message(): string { |
| 54 | return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`; |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | export class BrowserCookieWriteError extends Schema.TaggedError<BrowserCookieWriteError>()( |
| 59 | "BrowserCookieWriteError", |
| 60 | { |
| 61 | url: Schema.String, |
| 62 | name: Schema.String, |
| 63 | cause: Schema.Defect(), |
| 64 | }, |
| 65 | ) { |
| 66 | override get message(): string { |
| 67 | return `Could not write imported cookie ${this.name} for ${this.url}.`; |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | export class BrowserImport extends Context.Service< |
| 72 | BrowserImport, |
| 73 | { |
| 74 | readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>; |
| 75 | readonly importCookies: (input: { |
| 76 | readonly input: BrowserImportInput; |
| 77 | /** Partition scope of the target profile, derived by the caller in main. */ |
| 78 | readonly scope: string; |
| 79 | readonly persistent: boolean; |
| 80 | readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; |
| 81 | }) => Effect.Effect<BrowserImportResult, BrowserImportFailedError>; |
| 82 | } |
| 83 | >()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} |
| 84 | |
| 85 | const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( |
| 86 | definition: BrowserImportSourceDefinition, |
| 87 | context: BrowserImportPathContext, |
| 88 | ): Effect.fn.Return< |
| 89 | BrowserImportUnavailableReason | undefined, |
| 90 | never, |
| 91 | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner |
| 92 | > { |
| 93 | if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; |
| 94 | if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; |
| 95 | if (yield* isSourceRunning(definition, context)) return "browserRunning"; |
| 96 | // Safari's jar is found by `stat`, which TCC permits without Full Disk |
| 97 | // Access — so a Safari that lists as ready may still refuse the read. Probe |
| 98 | // the grant here, so the wizard can open on the permission step and a |
| 99 | // post-grant recheck can tell granted from still-denied, rather than only |
| 100 | // discovering it by attempting the import. |
| 101 | if (definition.engine === "safari") { |
| 102 | const jar = yield* resolveCookieDatabase(definition, context, "."); |
| 103 | if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; |
| 104 | } |
| 105 | return undefined; |
| 106 | }); |
| 107 | |
| 108 | /** The host a constructed cookie URL points at, for naming what was skipped. */ |
| 109 | const cookieHost = (url: string): string => { |
| 110 | try { |
| 111 | return new URL(url).hostname; |
| 112 | } catch { |
| 113 | return url; |
| 114 | } |
| 115 | }; |
| 116 | |
| 117 | export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( |
| 118 | session: { readonly cookies: Pick<Session["cookies"], "set" | "flushStore"> }, |
| 119 | read: CookieReadResult, |
| 120 | ) { |
| 121 | let imported = 0; |
| 122 | let skipped = read.undecryptable; |
| 123 | const skippedDomains = new Set(read.undecryptableHosts); |
| 124 | for (const cookie of read.cookies) { |
| 125 | const written = yield* Effect.tryPromise({ |
| 126 | try: () => |
| 127 | session.cookies.set({ |
| 128 | url: cookie.url, |
| 129 | name: cookie.name, |
| 130 | value: cookie.value, |
| 131 | // Omitted for host-only cookies: Electron reads any `domain` as a |
| 132 | // domain cookie and re-adds the leading dot, widening its scope. |
| 133 | ...(cookie.domain === undefined ? {} : { domain: cookie.domain }), |
| 134 | path: cookie.path, |
| 135 | secure: cookie.secure, |
| 136 | httpOnly: cookie.httpOnly, |
| 137 | sameSite: cookie.sameSite, |
| 138 | ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }), |
| 139 | }), |
| 140 | catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), |
| 141 | }).pipe( |
| 142 | Effect.as(true), |
| 143 | Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), |
| 144 | Effect.catchTags({ BrowserCookieWriteError: () => Effect.succeed(false) }), |
| 145 | ); |
| 146 | if (written) { |
| 147 | imported += 1; |
| 148 | } else { |
| 149 | skipped += 1; |
| 150 | skippedDomains.add(cookieHost(cookie.url)); |
| 151 | } |
| 152 | } |
| 153 | // `set` resolves once the cookie is in memory; Chromium writes the store to |
| 154 | // disk on its own schedule. Flush before reporting "Done", so a crash right |
| 155 | // after does not lose what the user was just told was imported. A failed |
| 156 | // flush is logged rather than surfaced: the cookies are still in the |
| 157 | // session and land on disk at the next scheduled write. |
| 158 | if (imported > 0) { |
| 159 | yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( |
| 160 | Effect.tapError((error) => |
| 161 | Effect.logWarning("Imported cookies could not be flushed to disk", { cause: error.cause }), |
| 162 | ), |
| 163 | Effect.ignore, |
| 164 | ); |
| 165 | } |
| 166 | return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; |
| 167 | }); |
| 168 | |
| 169 | /** @public Service construction is part of the canonical Effect module API. */ |
| 170 | export const make = Effect.gen(function* BrowserImportMake() { |
| 171 | const browserSession = yield* BrowserSession.BrowserSession; |
| 172 | const platform = yield* HostProcess.Platform; |
| 173 | const executablePath = yield* HostProcess.ExecutablePath; |
| 174 | // Captured here so the service's methods stay free of a requirements |
| 175 | // channel: the layer is built where NodeServices is already in scope. |
| 176 | const platformServices = yield* Effect.context< |
| 177 | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner |
| 178 | >(); |
| 179 | const pathContext = yield* sourcePathContext; |
| 180 | |
| 181 | const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach( |
| 182 | BROWSER_IMPORT_SOURCES, |
| 183 | Effect.fnUntraced(function* (definition) { |
| 184 | const unavailable = yield* unavailableReason(definition, pathContext); |
| 185 | return { |
| 186 | id: definition.id, |
| 187 | name: definition.name, |
| 188 | // Listing profiles touches the source's own files, so skip it when the |
| 189 | // source is unusable anyway. |
| 190 | profiles: |
| 191 | unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], |
| 192 | ...(unavailable === undefined ? {} : { unavailable }), |
| 193 | } satisfies BrowserImportSource; |
| 194 | }), |
| 195 | ).pipe(Effect.provide(platformServices)); |
| 196 | |
| 197 | const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { |
| 198 | readonly input: BrowserImportInput; |
| 199 | readonly scope: string; |
| 200 | readonly persistent: boolean; |
| 201 | readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; |
| 202 | }) { |
| 203 | const definition = BROWSER_IMPORT_SOURCES.find( |
| 204 | (candidate) => candidate.id === input.input.sourceId, |
| 205 | ); |
| 206 | if (!definition) { |
| 207 | return yield* new BrowserImportFailedError({ |
| 208 | sourceId: input.input.sourceId, |
| 209 | reason: "unknownSource", |
| 210 | }); |
| 211 | } |
| 212 | |
| 213 | const blocked = yield* unavailableReason(definition, pathContext).pipe( |
| 214 | Effect.provide(platformServices), |
| 215 | ); |
| 216 | if (blocked !== undefined) { |
| 217 | return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); |
| 218 | } |
| 219 | |
| 220 | if (platform === "darwin" && definition.engine === "chromium") { |
| 221 | // macOS attributes the Keychain prompt and the resulting ACL grant to the |
| 222 | // executable that asks, so record which one that was — in a packaged build |
| 223 | // it is the signed app, in dev whatever binary hosts the main process. |
| 224 | yield* Effect.logInfo("Reading browser cookie key from the keychain", { |
| 225 | sourceId: definition.id, |
| 226 | executablePath, |
| 227 | }); |
| 228 | } |
| 229 | |
| 230 | // The profile directory arrives over IPC, so it is only honoured when the |
| 231 | // source itself reported it. Forwarding it unchecked would let `..` |
| 232 | // segments walk out of the browser's user-data directory and read any |
| 233 | // cookie database reachable on disk. |
| 234 | const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( |
| 235 | Effect.provide(platformServices), |
| 236 | ); |
| 237 | const requestedProfile = sourceProfiles.find( |
| 238 | (profile) => profile.directory === input.input.sourceProfileDirectory, |
| 239 | ); |
| 240 | if (requestedProfile === undefined) { |
| 241 | return yield* new BrowserImportFailedError({ |
| 242 | sourceId: definition.id, |
| 243 | reason: "unknownSourceProfile", |
| 244 | }); |
| 245 | } |
| 246 | |
| 247 | // The profile was listed against a database moments ago; resolve it again |
| 248 | // rather than assume a path, since a Chromium jar may sit under `Network/`. |
| 249 | const databasePath = yield* resolveCookieDatabase( |
| 250 | definition, |
| 251 | pathContext, |
| 252 | requestedProfile.directory, |
| 253 | ).pipe(Effect.provide(platformServices)); |
| 254 | if (databasePath === undefined) { |
| 255 | // A profile we listed moments ago can lose its database before the |
| 256 | // import runs (browser data cleanup, a profile reset). That is a read |
| 257 | // failure, not a platform problem. |
| 258 | return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); |
| 259 | } |
| 260 | |
| 261 | // Both branches fail with a tagged error, so the union stays structurally |
| 262 | // identifiable and each tag is handled on its own below. The success side |
| 263 | // is normalized to one shape too, so the skipped tally survives either |
| 264 | // engine — Firefox stores plaintext, so nothing there is ever unreadable. |
| 265 | const userDataDirectory = definition.userDataDirectory(pathContext); |
| 266 | const read: Effect.Effect< |
| 267 | CookieReadResult, |
| 268 | ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError, |
| 269 | FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner |
| 270 | > = |
| 271 | definition.engine === "safari" |
| 272 | ? readSafariCookies(databasePath).pipe( |
| 273 | Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), |
| 274 | ) |
| 275 | : definition.engine === "firefox" |
| 276 | ? readFirefoxCookies(databasePath).pipe( |
| 277 | Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), |
| 278 | ) |
| 279 | : readChromiumCookies({ |
| 280 | cookieDatabasePath: databasePath, |
| 281 | keychainService: definition.keychainService, |
| 282 | keychainAccount: definition.keychainAccount, |
| 283 | linuxSecretApplication: definition.linuxSecretApplication, |
| 284 | ...(platform === "win32" && userDataDirectory !== undefined |
| 285 | ? { |
| 286 | windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), |
| 287 | } |
| 288 | : {}), |
| 289 | platform, |
| 290 | }); |
| 291 | |
| 292 | const result = yield* read.pipe( |
| 293 | Effect.scoped, |
| 294 | Effect.provide(platformServices), |
| 295 | Effect.catchTags({ |
| 296 | ChromiumCookieReadError: (cause) => |
| 297 | Effect.fail( |
| 298 | new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), |
| 299 | ), |
| 300 | // Firefox has one failure mode — its plaintext database would not open |
| 301 | // — so its error carries no reason of its own and the user-facing one |
| 302 | // is supplied here. |
| 303 | FirefoxCookieReadError: (cause) => |
| 304 | Effect.fail( |
| 305 | new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), |
| 306 | ), |
| 307 | // Safari's reasons are already user-facing: a TCC refusal is the Full |
| 308 | // Disk Access prompt, anything else is a read failure. |
| 309 | SafariCookieReadError: (cause) => |
| 310 | Effect.fail( |
| 311 | new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), |
| 312 | ), |
| 313 | }), |
| 314 | ); |
| 315 | |
| 316 | const session = yield* browserSession |
| 317 | .getSession(input.scope, input.persistent, input.namespace) |
| 318 | .pipe( |
| 319 | Effect.mapError( |
| 320 | (cause) => |
| 321 | new BrowserImportFailedError({ |
| 322 | sourceId: definition.id, |
| 323 | reason: "sessionUnavailable", |
| 324 | cause, |
| 325 | }), |
| 326 | ), |
| 327 | ); |
| 328 | |
| 329 | // Written one at a time rather than in parallel: Chromium's cookie store |
| 330 | // serialises writes anyway, and a rejected cookie should only cost itself. |
| 331 | return yield* writeCookies(session, result); |
| 332 | }); |
| 333 | |
| 334 | return BrowserImport.of({ listSources, importCookies }); |
| 335 | }); |
| 336 | |
| 337 | export const layer = Layer.effect(BrowserImport, make); |