Skip to content

Commit 973b17e

Browse files
feat(desktop): import from Chrome, Edge, Brave, Vivaldi, Opera, Arc, Firefox
Generalises the importer from one hardcoded browser to a source registry with two engines. Chromium forks are table entries: Chrome, Edge, Brave, Vivaldi, Opera, Arc and Helium all share the existing extractor and differ only in their paths and keychain coordinates. Those coordinates are pinned per fork rather than derived, because the forks disagree — Helium uses "Helium Storage Key" / "Helium" where the others use "<Name> Safe Storage" / "<Name>". Firefox is a second engine. It stores cookies unencrypted in `cookies.sqlite`, so there is no key to fetch and no consent prompt — that is Mozilla's design choice, not a control being circumvented, and it is why Firefox works identically on all three platforms while Chromium still needs a per-platform credential store. Paths resolve for macOS, Windows and Linux from an injected context rather than from `process`, so a platform's layout can be checked without running on it. Chromium off macOS still reports `unsupportedPlatform` until those key stores land; Firefox does not. The snapshot-before-read step moves to a shared module, since both engines keep the database open with WAL and must never have the browser's own file opened for writing. Firefox has tests against a real `moz_cookies` fixture, including that the source file is left untouched, and `profiles.ini` parsing covers the `Install*` sections that name a default profile without describing one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5853095 commit 973b17e

9 files changed

Lines changed: 686 additions & 147 deletions

File tree

apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as Layer from "effect/Layer";
1111

1212
import * as BrowserSession from "../BrowserSession.ts";
1313
import * as BrowserImport from "./BrowserImport.ts";
14-
import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";
14+
import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";
1515

1616
const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
1717

@@ -34,12 +34,13 @@ const withImporter = Effect.fnUntraced(function* () {
3434
const fileSystem = yield* FileSystem.FileSystem;
3535
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
3636
const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
37-
const paths = yield* sourcePaths.pipe(
37+
const context = yield* sourcePathContext.pipe(
3838
Effect.provideService(HostProcessEnvironment, { HOME: home }),
39+
Effect.provideService(HostProcessPlatform, "darwin"),
3940
);
40-
yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {
41-
recursive: true,
42-
});
41+
const root = helium.userDataDirectory(context);
42+
if (root === undefined) throw new Error("Helium has no macOS user-data directory");
43+
yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
4344

4445
const importer = yield* BrowserImport.BrowserImport.pipe(
4546
Effect.provide(
@@ -52,7 +53,7 @@ const withImporter = Effect.fnUntraced(function* () {
5253
),
5354
),
5455
);
55-
return { importer, home, paths };
56+
return { importer, home, root };
5657
});
5758

5859
describe("BrowserImport.importCookies", () => {
@@ -86,12 +87,12 @@ describe("BrowserImport.importCookies", () => {
8687
it.effect("refuses to import while the source browser holds its profile", () =>
8788
Effect.gen(function* () {
8889
const fileSystem = yield* FileSystem.FileSystem;
89-
const { importer, paths } = yield* withImporter();
90+
const { importer, root } = yield* withImporter();
9091
// The lock Chromium leaves while it is running, dangling target and
9192
// all. This must stop the import before it ever asks the keychain.
9293
yield* fileSystem.symlink(
9394
"host-that-does-not-exist-1234",
94-
`${helium.userDataDirectory(paths)}/SingletonLock`,
95+
`${root}/SingletonLock`,
9596
);
9697

9798
const error = yield* importer

apps/desktop/src/preview/BrowserImport/BrowserImport.ts

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,23 @@ import * as FileSystem from "effect/FileSystem";
1717
import * as Layer from "effect/Layer";
1818
import * as Path from "effect/Path";
1919
import * as Schema from "effect/Schema";
20+
import * as Scope from "effect/Scope";
2021

2122
import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2223

2324
import * as BrowserSession from "../BrowserSession.ts";
2425
import { readChromiumCookies } from "./ChromiumCookies.ts";
26+
import type { ImportedCookie } from "./CookieDatabase.ts";
27+
import { readFirefoxCookies } from "./FirefoxCookies.ts";
2528
import {
2629
BROWSER_IMPORT_SOURCES,
2730
cookieDatabasePath,
2831
isSourceInstalled,
2932
isSourceRunning,
3033
listSourceProfiles,
31-
sourcePaths,
34+
sourcePathContext,
35+
type BrowserImportPathContext,
3236
type BrowserImportSourceDefinition,
33-
type SourcePaths,
3437
} from "./Sources.ts";
3538

3639
export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
@@ -64,12 +67,16 @@ export class BrowserImport extends Context.Service<
6467

6568
const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
6669
definition: BrowserImportSourceDefinition,
67-
platform: NodeJS.Platform,
68-
paths: SourcePaths,
70+
context: BrowserImportPathContext,
6971
): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
70-
if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
71-
if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
72-
if (yield* isSourceRunning(definition, paths)) return "browserRunning";
72+
if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
73+
// Chromium's key lives in an OS credential store, and only the macOS one is
74+
// implemented; Firefox needs no key at all, so it works everywhere.
75+
if (definition.engine === "chromium" && context.platform !== "darwin") {
76+
return "unsupportedPlatform";
77+
}
78+
if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
79+
if (yield* isSourceRunning(definition, context)) return "browserRunning";
7380
return undefined;
7481
});
7582

@@ -80,18 +87,18 @@ export const make = Effect.gen(function* BrowserImportMake() {
8087
// Captured here so the service's methods stay free of a requirements
8188
// channel: the layer is built where NodeServices is already in scope.
8289
const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
83-
const paths = yield* sourcePaths;
90+
const pathContext = yield* sourcePathContext;
8491

8592
const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
8693
BROWSER_IMPORT_SOURCES,
8794
Effect.fnUntraced(function* (definition) {
88-
const unavailable = yield* unavailableReason(definition, platform, paths);
95+
const unavailable = yield* unavailableReason(definition, pathContext);
8996
return {
9097
id: definition.id,
9198
name: definition.name,
9299
// Listing profiles touches the source's own files, so skip it when the
93100
// source is unusable anyway.
94-
profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
101+
profiles: unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [],
95102
...(unavailable === undefined ? {} : { unavailable }),
96103
} satisfies BrowserImportSource;
97104
}),
@@ -112,7 +119,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
112119
});
113120
}
114121

115-
const blocked = yield* unavailableReason(definition, platform, paths).pipe(
122+
const blocked = yield* unavailableReason(definition, pathContext).pipe(
116123
Effect.provide(platformServices),
117124
);
118125
if (blocked !== undefined) {
@@ -131,7 +138,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
131138
// source itself reported it. Forwarding it unchecked would let `..`
132139
// segments walk out of the browser's user-data directory and read any
133140
// cookie database reachable on disk.
134-
const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
141+
const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe(
135142
Effect.provide(platformServices),
136143
);
137144
const requestedProfile = sourceProfiles.find(
@@ -144,12 +151,33 @@ export const make = Effect.gen(function* BrowserImportMake() {
144151
});
145152
}
146153

147-
const cookies = yield* readChromiumCookies({
148-
cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory),
149-
keychainService: definition.keychainService,
150-
keychainAccount: definition.keychainAccount,
151-
platform,
152-
}).pipe(
154+
const databasePath = cookieDatabasePath(definition, pathContext, requestedProfile.directory);
155+
if (databasePath === undefined) {
156+
return yield* new BrowserImportFailedError({
157+
sourceId: definition.id,
158+
reason: "unsupportedPlatform",
159+
});
160+
}
161+
162+
const read: Effect.Effect<
163+
ReadonlyArray<ImportedCookie>,
164+
{ readonly reason: BrowserImportFailureReason },
165+
FileSystem.FileSystem | Path.Path | Scope.Scope
166+
> =
167+
definition.engine === "firefox"
168+
? readFirefoxCookies(databasePath).pipe(
169+
Effect.mapError((cause) => ({ reason: "readFailed" as const, cause })),
170+
)
171+
: readChromiumCookies({
172+
cookieDatabasePath: databasePath,
173+
// Only reached on macOS: `unavailableReason` rejects Chromium
174+
// elsewhere until those key stores are implemented.
175+
keychainService: definition.keychainService ?? "",
176+
keychainAccount: definition.keychainAccount ?? "",
177+
platform,
178+
});
179+
180+
const cookies = yield* read.pipe(
153181
Effect.scoped,
154182
Effect.provide(platformServices),
155183
Effect.mapError(

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import * as NodeCrypto from "node:crypto";
1919

2020
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
2121
import * as Effect from "effect/Effect";
22-
import * as FileSystem from "effect/FileSystem";
23-
import * as Path from "effect/Path";
2422
import * as Schema from "effect/Schema";
2523
import * as SqlClient from "effect/unstable/sql/SqlClient";
2624

25+
import { snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
26+
2727
/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
2828
const MAC_KEY_ITERATIONS = 1003;
2929
const MAC_KEY_SALT = "saltysalt";
@@ -32,18 +32,7 @@ const MAC_KEY_LENGTH = 16;
3232
const AES_IV = Buffer.alloc(16, 0x20);
3333
const V10_PREFIX = "v10";
3434

35-
export interface ChromiumCookie {
36-
readonly url: string;
37-
readonly name: string;
38-
readonly value: string;
39-
readonly domain: string;
40-
readonly path: string;
41-
readonly secure: boolean;
42-
readonly httpOnly: boolean;
43-
/** Seconds since the UNIX epoch, or undefined for a session cookie. */
44-
readonly expirationDate: number | undefined;
45-
readonly sameSite: "no_restriction" | "lax" | "strict";
46-
}
35+
export type ChromiumCookie = ImportedCookie;
4736

4837
export const ChromiumCookieReadReason = Schema.Literals([
4938
"needsKeychainApproval",
@@ -144,30 +133,6 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo
144133
return password;
145134
});
146135

147-
/**
148-
* Chromium keeps the cookie DB open with WAL, and reading it in place can
149-
* observe a torn state. Copying first — including the sidecars — gives a
150-
* consistent snapshot without touching the browser's own files.
151-
*
152-
* Scoped: the temp directory is removed when the caller's scope closes.
153-
*/
154-
const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(function* (
155-
cookiePath: string,
156-
) {
157-
const fileSystem = yield* FileSystem.FileSystem;
158-
const path = yield* Path.Path;
159-
160-
const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-cookie-import-" });
161-
const target = path.join(directory, "Cookies");
162-
yield* fileSystem.copyFile(cookiePath, target);
163-
// The sidecars only exist while the browser holds the database open, so a
164-
// missing one is normal rather than a failure.
165-
yield* Effect.forEach(["-wal", "-shm"], (suffix) =>
166-
fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(Effect.ignore),
167-
);
168-
return target;
169-
});
170-
171136
const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => {
172137
const buffer = Buffer.from(encrypted);
173138
if (buffer.length === 0) return "";
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Shared pieces of cookie extraction: the shape both engines produce, and the
3+
* snapshot every reader takes before touching a live database.
4+
*
5+
* @module CookieDatabase
6+
*/
7+
import * as Effect from "effect/Effect";
8+
import * as FileSystem from "effect/FileSystem";
9+
import * as Path from "effect/Path";
10+
11+
/** A cookie in the shape Electron's `session.cookies.set` accepts. */
12+
export interface ImportedCookie {
13+
readonly url: string;
14+
readonly name: string;
15+
readonly value: string;
16+
readonly domain: string;
17+
readonly path: string;
18+
readonly secure: boolean;
19+
readonly httpOnly: boolean;
20+
/** Seconds since the UNIX epoch, or undefined for a session cookie. */
21+
readonly expirationDate: number | undefined;
22+
readonly sameSite: "no_restriction" | "lax" | "strict";
23+
}
24+
25+
/**
26+
* Copies a cookie database, and its write-ahead sidecars, to a temporary
27+
* directory before reading, and returns the copy's path.
28+
*
29+
* Both engines keep the file open with WAL while the browser runs, so reading
30+
* in place can observe a torn write. Copying also guarantees we never open the
31+
* browser's own file for writing.
32+
*
33+
* Scoped: the temporary directory goes away when the caller's scope closes.
34+
*/
35+
export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(
36+
function* (cookiePath: string) {
37+
const fileSystem = yield* FileSystem.FileSystem;
38+
const path = yield* Path.Path;
39+
40+
const directory = yield* fileSystem.makeTempDirectoryScoped({
41+
prefix: "t3code-cookie-import-",
42+
});
43+
const target = path.join(directory, path.basename(cookiePath));
44+
yield* fileSystem.copyFile(cookiePath, target);
45+
// The sidecars only exist while the browser holds the database open, so a
46+
// missing one is normal rather than a failure.
47+
yield* Effect.forEach(["-wal", "-shm"], (suffix) =>
48+
fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(Effect.ignore),
49+
);
50+
return target;
51+
},
52+
);

0 commit comments

Comments
 (0)