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 */
7import type {
8 BrowserImportInput,
9 BrowserImportResult,
10 BrowserImportSource,
11 BrowserImportUnavailableReason,
12} from "@t3tools/contracts";
13import { BrowserImportFailureReason } from "@t3tools/contracts";
14import * as Context from "effect/Context";
15import type { Session } from "electron";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";
19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21import * as Scope from "effect/Scope";
22import { ChildProcessSpawner } from "effect/process";
23
24import * as HostProcess from "@t3tools/shared/HostProcess";
25
26import * as BrowserSession from "../BrowserSession.ts";
27import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
28import type { CookieReadResult } from "./CookieDatabase.ts";
29import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
30import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts";
31import {
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
42export 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
58export 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
71export 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
85const 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. */
109const cookieHost = (url: string): string => {
110 try {
111 return new URL(url).hostname;
112 } catch {
113 return url;
114 }
115};
116
117export 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. */
170export 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
337export const layer = Layer.effect(BrowserImport, make);