|
| 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 * as Context from "effect/Context"; |
| 14 | +import * as Effect from "effect/Effect"; |
| 15 | +import * as Layer from "effect/Layer"; |
| 16 | +import * as Schema from "effect/Schema"; |
| 17 | + |
| 18 | +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 19 | + |
| 20 | +import * as BrowserSession from "../BrowserSession.ts"; |
| 21 | +import { |
| 22 | + ChromiumCookieReadError, |
| 23 | + readChromiumCookies, |
| 24 | + type ChromiumCookie, |
| 25 | +} from "./ChromiumCookies.ts"; |
| 26 | +import { |
| 27 | + BROWSER_IMPORT_SOURCES, |
| 28 | + cookieDatabasePath, |
| 29 | + isSourceInstalled, |
| 30 | + isSourceRunning, |
| 31 | + listSourceProfiles, |
| 32 | + type BrowserImportSourceDefinition, |
| 33 | +} from "./Sources.ts"; |
| 34 | + |
| 35 | +export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()( |
| 36 | + "BrowserImportFailedError", |
| 37 | + { |
| 38 | + sourceId: Schema.String, |
| 39 | + reason: Schema.String, |
| 40 | + }, |
| 41 | +) { |
| 42 | + override get message(): string { |
| 43 | + return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`; |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +export class BrowserImport extends Context.Service< |
| 48 | + BrowserImport, |
| 49 | + { |
| 50 | + readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>; |
| 51 | + readonly importCookies: (input: { |
| 52 | + readonly input: BrowserImportInput; |
| 53 | + /** Partition scope of the target profile, derived by the caller in main. */ |
| 54 | + readonly scope: string; |
| 55 | + readonly persistent: boolean; |
| 56 | + }) => Effect.Effect<BrowserImportResult, BrowserImportFailedError>; |
| 57 | + } |
| 58 | +>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} |
| 59 | + |
| 60 | +const unavailableReason = async ( |
| 61 | + definition: BrowserImportSourceDefinition, |
| 62 | + platform: NodeJS.Platform, |
| 63 | +): Promise<BrowserImportUnavailableReason | undefined> => { |
| 64 | + if (!definition.platforms.includes(platform)) return "unsupportedPlatform"; |
| 65 | + if (!(await isSourceInstalled(definition))) return "notInstalled"; |
| 66 | + if (await isSourceRunning(definition)) return "browserRunning"; |
| 67 | + return undefined; |
| 68 | +}; |
| 69 | + |
| 70 | +export const make = Effect.gen(function* BrowserImportMake() { |
| 71 | + const browserSession = yield* BrowserSession.BrowserSession; |
| 72 | + const platform = yield* HostProcessPlatform; |
| 73 | + const executablePath = yield* HostProcessExecutablePath; |
| 74 | + |
| 75 | + const listSources = Effect.promise(async (): Promise<ReadonlyArray<BrowserImportSource>> => { |
| 76 | + const sources: BrowserImportSource[] = []; |
| 77 | + for (const definition of BROWSER_IMPORT_SOURCES) { |
| 78 | + const unavailable = await unavailableReason(definition, platform); |
| 79 | + sources.push({ |
| 80 | + id: definition.id, |
| 81 | + name: definition.name, |
| 82 | + // Listing profiles touches the source's own files, so skip it when the |
| 83 | + // source is unusable anyway. |
| 84 | + profiles: unavailable === undefined ? await listSourceProfiles(definition) : [], |
| 85 | + ...(unavailable === undefined ? {} : { unavailable }), |
| 86 | + }); |
| 87 | + } |
| 88 | + return sources; |
| 89 | + }); |
| 90 | + |
| 91 | + const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { |
| 92 | + readonly input: BrowserImportInput; |
| 93 | + readonly scope: string; |
| 94 | + readonly persistent: boolean; |
| 95 | + }) { |
| 96 | + const definition = BROWSER_IMPORT_SOURCES.find( |
| 97 | + (candidate) => candidate.id === input.input.sourceId, |
| 98 | + ); |
| 99 | + if (!definition) { |
| 100 | + return yield* new BrowserImportFailedError({ |
| 101 | + sourceId: input.input.sourceId, |
| 102 | + reason: "unknown source", |
| 103 | + }); |
| 104 | + } |
| 105 | + |
| 106 | + const blocked = yield* Effect.promise(() => unavailableReason(definition, platform)); |
| 107 | + if (blocked !== undefined) { |
| 108 | + return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); |
| 109 | + } |
| 110 | + |
| 111 | + // macOS attributes the Keychain prompt and the resulting ACL grant to the |
| 112 | + // executable that asks, so record which one that was — in a packaged build |
| 113 | + // it is the signed app, in dev whatever binary hosts the main process. |
| 114 | + yield* Effect.logInfo("Reading browser cookie key from the keychain", { |
| 115 | + sourceId: definition.id, |
| 116 | + executablePath, |
| 117 | + }); |
| 118 | + |
| 119 | + const cookies: ReadonlyArray<ChromiumCookie> = yield* Effect.tryPromise({ |
| 120 | + try: () => |
| 121 | + readChromiumCookies({ |
| 122 | + cookieDatabasePath: cookieDatabasePath(definition, input.input.sourceProfileDirectory), |
| 123 | + keychainService: definition.keychainService, |
| 124 | + keychainAccount: definition.keychainAccount, |
| 125 | + platform, |
| 126 | + }), |
| 127 | + catch: (cause) => |
| 128 | + new BrowserImportFailedError({ |
| 129 | + sourceId: definition.id, |
| 130 | + reason: cause instanceof ChromiumCookieReadError ? cause.failure.reason : "readFailed", |
| 131 | + }), |
| 132 | + }); |
| 133 | + |
| 134 | + const session = yield* browserSession |
| 135 | + .getSession(input.scope, input.persistent) |
| 136 | + .pipe( |
| 137 | + Effect.mapError( |
| 138 | + () => new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }), |
| 139 | + ), |
| 140 | + ); |
| 141 | + |
| 142 | + // Written one at a time rather than in parallel: Chromium's cookie store |
| 143 | + // serialises writes anyway, and a rejected cookie should only cost itself. |
| 144 | + let imported = 0; |
| 145 | + let skipped = 0; |
| 146 | + for (const cookie of cookies) { |
| 147 | + const written = yield* Effect.tryPromise({ |
| 148 | + try: () => |
| 149 | + session.cookies.set({ |
| 150 | + url: cookie.url, |
| 151 | + name: cookie.name, |
| 152 | + value: cookie.value, |
| 153 | + domain: cookie.domain, |
| 154 | + path: cookie.path, |
| 155 | + secure: cookie.secure, |
| 156 | + httpOnly: cookie.httpOnly, |
| 157 | + sameSite: cookie.sameSite, |
| 158 | + ...(cookie.expirationDate === undefined |
| 159 | + ? {} |
| 160 | + : { expirationDate: cookie.expirationDate }), |
| 161 | + }), |
| 162 | + catch: () => undefined, |
| 163 | + }).pipe( |
| 164 | + Effect.as(true), |
| 165 | + Effect.catchCause(() => Effect.succeed(false)), |
| 166 | + ); |
| 167 | + if (written) imported += 1; |
| 168 | + else skipped += 1; |
| 169 | + } |
| 170 | + |
| 171 | + return { imported, skipped }; |
| 172 | + }); |
| 173 | + |
| 174 | + return BrowserImport.of({ listSources, importCookies }); |
| 175 | +}); |
| 176 | + |
| 177 | +export const layer = Layer.effect(BrowserImport, make); |
0 commit comments