Skip to content

Commit bc1215b

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 757f025 commit bc1215b

7 files changed

Lines changed: 594 additions & 86 deletions

File tree

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

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,24 @@ import * as Effect from "effect/Effect";
1515
import * as Layer from "effect/Layer";
1616
import * as Schema from "effect/Schema";
1717

18-
import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
18+
import {
19+
HostProcessEnvironment,
20+
HostProcessExecutablePath,
21+
HostProcessPlatform,
22+
} from "@t3tools/shared/hostProcess";
23+
import * as NodeOS from "node:os";
1924

2025
import * as BrowserSession from "../BrowserSession.ts";
21-
import {
22-
ChromiumCookieReadError,
23-
readChromiumCookies,
24-
type ChromiumCookie,
25-
} from "./ChromiumCookies.ts";
26+
import type { ImportedCookie } from "./CookieDatabase.ts";
27+
import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
28+
import { readFirefoxCookies } from "./FirefoxCookies.ts";
2629
import {
2730
BROWSER_IMPORT_SOURCES,
2831
cookieDatabasePath,
2932
isSourceInstalled,
3033
isSourceRunning,
3134
listSourceProfiles,
35+
type BrowserImportPathContext,
3236
type BrowserImportSourceDefinition,
3337
} from "./Sources.ts";
3438

@@ -59,29 +63,42 @@ export class BrowserImport extends Context.Service<
5963

6064
const unavailableReason = async (
6165
definition: BrowserImportSourceDefinition,
62-
platform: NodeJS.Platform,
66+
context: BrowserImportPathContext,
6367
): 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";
68+
if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
69+
// Chromium's key lives in an OS credential store, and only the macOS one is
70+
// implemented; Firefox needs no key at all, so it works everywhere.
71+
if (definition.engine === "chromium" && context.platform !== "darwin") {
72+
return "unsupportedPlatform";
73+
}
74+
if (!(await isSourceInstalled(definition, context))) return "notInstalled";
75+
if (await isSourceRunning(definition, context)) return "browserRunning";
6776
return undefined;
6877
};
6978

7079
export const make = Effect.gen(function* BrowserImportMake() {
7180
const browserSession = yield* BrowserSession.BrowserSession;
7281
const platform = yield* HostProcessPlatform;
7382
const executablePath = yield* HostProcessExecutablePath;
83+
const environment = yield* HostProcessEnvironment;
84+
const pathContext: BrowserImportPathContext = {
85+
platform,
86+
home: NodeOS.homedir(),
87+
appData: environment["APPDATA"],
88+
localAppData: environment["LOCALAPPDATA"],
89+
};
7490

7591
const listSources = Effect.promise(async (): Promise<ReadonlyArray<BrowserImportSource>> => {
7692
const sources: BrowserImportSource[] = [];
7793
for (const definition of BROWSER_IMPORT_SOURCES) {
78-
const unavailable = await unavailableReason(definition, platform);
94+
const unavailable = await unavailableReason(definition, pathContext);
7995
sources.push({
8096
id: definition.id,
8197
name: definition.name,
8298
// Listing profiles touches the source's own files, so skip it when the
8399
// source is unusable anyway.
84-
profiles: unavailable === undefined ? await listSourceProfiles(definition) : [],
100+
profiles:
101+
unavailable === undefined ? await listSourceProfiles(definition, pathContext) : [],
85102
...(unavailable === undefined ? {} : { unavailable }),
86103
});
87104
}
@@ -103,7 +120,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
103120
});
104121
}
105122

106-
const blocked = yield* Effect.promise(() => unavailableReason(definition, platform));
123+
const blocked = yield* Effect.promise(() => unavailableReason(definition, pathContext));
107124
if (blocked !== undefined) {
108125
return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
109126
}
@@ -116,14 +133,30 @@ export const make = Effect.gen(function* BrowserImportMake() {
116133
executablePath,
117134
});
118135

119-
const cookies: ReadonlyArray<ChromiumCookie> = yield* Effect.tryPromise({
136+
const databasePath = cookieDatabasePath(
137+
definition,
138+
pathContext,
139+
input.input.sourceProfileDirectory,
140+
);
141+
if (databasePath === undefined) {
142+
return yield* new BrowserImportFailedError({
143+
sourceId: definition.id,
144+
reason: "unsupportedPlatform",
145+
});
146+
}
147+
148+
const cookies: ReadonlyArray<ImportedCookie> = yield* Effect.tryPromise({
120149
try: () =>
121-
readChromiumCookies({
122-
cookieDatabasePath: cookieDatabasePath(definition, input.input.sourceProfileDirectory),
123-
keychainService: definition.keychainService,
124-
keychainAccount: definition.keychainAccount,
125-
platform,
126-
}),
150+
definition.engine === "firefox"
151+
? readFirefoxCookies(databasePath)
152+
: readChromiumCookies({
153+
cookieDatabasePath: databasePath,
154+
// Only reached on macOS: `unavailableReason` rejects Chromium
155+
// elsewhere until those key stores are implemented.
156+
keychainService: definition.keychainService ?? "",
157+
keychainAccount: definition.keychainAccount ?? "",
158+
platform,
159+
}),
127160
catch: (cause) =>
128161
new BrowserImportFailedError({
129162
sourceId: definition.id,

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

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@
1515
*/
1616
import * as Keyring from "@napi-rs/keyring";
1717
import * as NodeCrypto from "node:crypto";
18-
import * as NodeFSP from "node:fs/promises";
19-
import * as NodeOS from "node:os";
20-
import * as NodePath from "node:path";
2118
import * as NodeSqlite from "node:sqlite";
2219

20+
import { snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
21+
2322
/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
2423
const MAC_KEY_ITERATIONS = 1003;
2524
const MAC_KEY_SALT = "saltysalt";
@@ -28,18 +27,7 @@ const MAC_KEY_LENGTH = 16;
2827
const AES_IV = Buffer.alloc(16, 0x20);
2928
const V10_PREFIX = "v10";
3029

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

4432
export type ChromiumCookieReadFailure =
4533
| { readonly reason: "needsKeychainApproval" }
@@ -116,27 +104,6 @@ async function readMacKeychainPassword(service: string, account: string): Promis
116104
return password;
117105
}
118106

119-
/**
120-
* Chromium keeps the cookie DB open with WAL, and reading it in place can
121-
* observe a torn state. Copying first — including the sidecars — gives a
122-
* consistent snapshot without touching the browser's own files.
123-
*/
124-
async function copyCookieDatabase(cookiePath: string): Promise<{
125-
readonly path: string;
126-
readonly cleanup: () => Promise<void>;
127-
}> {
128-
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-cookie-import-"));
129-
const target = NodePath.join(directory, "Cookies");
130-
await NodeFSP.copyFile(cookiePath, target);
131-
for (const suffix of ["-wal", "-shm"]) {
132-
await NodeFSP.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).catch(() => undefined);
133-
}
134-
return {
135-
path: target,
136-
cleanup: () => NodeFSP.rm(directory, { recursive: true, force: true }).catch(() => undefined),
137-
};
138-
}
139-
140107
const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => {
141108
const buffer = Buffer.from(encrypted);
142109
if (buffer.length === 0) return "";
@@ -184,7 +151,7 @@ export async function readChromiumCookies(
184151
"sha1",
185152
);
186153

187-
const snapshot = await copyCookieDatabase(source.cookieDatabasePath);
154+
const snapshot = await snapshotCookieDatabase(source.cookieDatabasePath);
188155
try {
189156
const database = new NodeSqlite.DatabaseSync(snapshot.path, { readOnly: true });
190157
try {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// @effect-diagnostics nodeBuiltinImport:off
2+
/**
3+
* Shared pieces of cookie extraction: the shape both engines produce, and the
4+
* snapshot every reader takes before touching a live database.
5+
*
6+
* @module CookieDatabase
7+
*/
8+
import * as NodeFSP from "node:fs/promises";
9+
import * as NodeOS from "node:os";
10+
import * as NodePath from "node:path";
11+
12+
/** A cookie in the shape Electron's `session.cookies.set` accepts. */
13+
export interface ImportedCookie {
14+
readonly url: string;
15+
readonly name: string;
16+
readonly value: string;
17+
readonly domain: string;
18+
readonly path: string;
19+
readonly secure: boolean;
20+
readonly httpOnly: boolean;
21+
/** Seconds since the UNIX epoch, or undefined for a session cookie. */
22+
readonly expirationDate: number | undefined;
23+
readonly sameSite: "no_restriction" | "lax" | "strict";
24+
}
25+
26+
/**
27+
* Copies a cookie database, and its write-ahead sidecars, to a temporary
28+
* directory before reading.
29+
*
30+
* Both engines keep the file open with WAL while the browser runs, so reading
31+
* in place can observe a torn write. Copying also guarantees we never open the
32+
* browser's own file for writing.
33+
*/
34+
export async function snapshotCookieDatabase(cookiePath: string): Promise<{
35+
readonly path: string;
36+
readonly cleanup: () => Promise<void>;
37+
}> {
38+
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-cookie-import-"));
39+
const target = NodePath.join(directory, NodePath.basename(cookiePath));
40+
await NodeFSP.copyFile(cookiePath, target);
41+
for (const suffix of ["-wal", "-shm"]) {
42+
await NodeFSP.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).catch(() => undefined);
43+
}
44+
return {
45+
path: target,
46+
cleanup: () => NodeFSP.rm(directory, { recursive: true, force: true }).catch(() => undefined),
47+
};
48+
}

0 commit comments

Comments
 (0)