Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4f6fb87
feat(desktop): import from Chrome, Edge, Brave, Vivaldi, Opera, Arc, …
juliusmarminge Aug 16, 2026
3656866
fix(desktop): detect a running Firefox and keep containers apart
juliusmarminge Aug 16, 2026
0e0133b
refactor(desktop): give the Firefox read path a tagged error
juliusmarminge Aug 16, 2026
c5d326b
refactor(desktop): name the database a Firefox read failed on
juliusmarminge Aug 16, 2026
187b43d
refactor(desktop): drop the redundant reason from the Firefox read error
juliusmarminge Aug 17, 2026
428d226
refactor(desktop): handle the cookie read failures by tag
juliusmarminge Aug 17, 2026
b188689
fix(desktop): find Linux Firefox profiles
juliusmarminge Aug 29, 2026
9266fc1
fix(desktop): validate Firefox profile paths
juliusmarminge Aug 29, 2026
26aba8d
fix(desktop): count Firefox profile cookies
juliusmarminge Aug 29, 2026
0f057ea
refactor(desktop): share browser cookie result types
juliusmarminge Aug 29, 2026
604ded8
docs(desktop): describe shared cookie read results
juliusmarminge Aug 29, 2026
7bc002c
fix(desktop): follow browser database symlinks
juliusmarminge Aug 29, 2026
a45827c
fix(desktop): convert Firefox cookie expiries by schema version
juliusmarminge Sep 2, 2026
266ec2a
fix(desktop): stop reading Firefox's leftover lock files as a running…
juliusmarminge Sep 2, 2026
c44e959
fix(desktop): probe Firefox's fcntl lock so a running macOS Firefox i…
juliusmarminge Sep 2, 2026
a220676
fix(desktop): log the keychain read only for Chromium, and narrow the…
juliusmarminge Sep 2, 2026
b42d1a8
fix(desktop): recognise any local address as the owner of a Firefox l…
juliusmarminge Sep 2, 2026
92bfdf8
fix(desktop): find the fcntl probe from a Dock launch, and count host…
juliusmarminge Sep 2, 2026
02e3fc0
fix(desktop): import an unset SameSite as unspecified, not none
juliusmarminge Sep 2, 2026
5ddf1b8
fix(desktop): read Firefox SameSite from every schema
juliusmarminge Sep 2, 2026
b37548a
perf(desktop): resolve host addresses once per Firefox lock scan
juliusmarminge Sep 2, 2026
0cf1076
chore(desktop): keep the container comment beside its predicate
juliusmarminge Sep 2, 2026
eea4224
fix(desktop): discover Firefox Snap profiles for browser import
juliusmarminge Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as Ref from "effect/Ref";

import * as BrowserSession from "../BrowserSession.ts";
import * as BrowserImport from "./BrowserImport.ts";
import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";
import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";

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

Expand Down Expand Up @@ -52,15 +52,16 @@ const withImporter = Effect.fnUntraced(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
const paths = yield* sourcePaths.pipe(
const context = yield* sourcePathContext.pipe(
Effect.provideService(HostProcessEnvironment, { HOME: home }),
Effect.provideService(HostProcessPlatform, "darwin"),
);
yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {
recursive: true,
});
const root = helium.userDataDirectory(context);
if (root === undefined) throw new Error("Helium has no macOS user-data directory");
yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
// The cookie database is what marks a source as installed, so a fixture
// without one is reported as absent before any other check runs.
yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db");
yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");

const importer = yield* BrowserImport.BrowserImport.pipe(
Effect.provide(
Expand All @@ -73,7 +74,7 @@ const withImporter = Effect.fnUntraced(function* () {
),
),
);
return { importer, home, paths };
return { importer, home, root };
});

describe("BrowserImport.importCookies", () => {
Expand Down Expand Up @@ -107,13 +108,10 @@ describe("BrowserImport.importCookies", () => {
it.effect("refuses to import while the source browser holds its profile", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const { importer, paths } = yield* withImporter();
const { importer, root } = yield* withImporter();
// The lock Chromium leaves while it is running, dangling target and
// all. This must stop the import before it ever asks the keychain.
yield* fileSystem.symlink(
"host-that-does-not-exist-1234",
`${helium.userDataDirectory(paths)}/SingletonLock`,
);
yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);

const error = yield* importer
.importCookies({
Expand Down
113 changes: 81 additions & 32 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import { ChildProcessSpawner } from "effect/unstable/process";

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

import * as BrowserSession from "../BrowserSession.ts";
import { readChromiumCookies, type CookieReadResult } from "./ChromiumCookies.ts";
import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
import type { CookieReadResult } from "./CookieDatabase.ts";
import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
import {
BROWSER_IMPORT_SOURCES,
resolveCookieDatabase,
isSourceInstalled,
isSourceRunning,
listSourceProfiles,
sourcePaths,
sourcePathContext,
type BrowserImportPathContext,
type BrowserImportSourceDefinition,
type SourcePaths,
} from "./Sources.ts";

export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
Expand Down Expand Up @@ -79,12 +83,20 @@ export class BrowserImport extends Context.Service<

const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
definition: BrowserImportSourceDefinition,
platform: NodeJS.Platform,
paths: SourcePaths,
): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
if (yield* isSourceRunning(definition, paths)) return "browserRunning";
context: BrowserImportPathContext,
): Effect.fn.Return<
BrowserImportUnavailableReason | undefined,
never,
FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
> {
if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
// Chromium's key lives in an OS credential store, and only the macOS one is
// implemented; Firefox needs no key at all, so it works everywhere.
if (definition.engine === "chromium" && context.platform !== "darwin") {
return "unsupportedPlatform";
}
if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
if (yield* isSourceRunning(definition, context)) return "browserRunning";
return undefined;
});

Expand Down Expand Up @@ -155,19 +167,22 @@ export const make = Effect.gen(function* BrowserImportMake() {
const executablePath = yield* HostProcessExecutablePath;
// Captured here so the service's methods stay free of a requirements
// channel: the layer is built where NodeServices is already in scope.
const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
const paths = yield* sourcePaths;
const platformServices = yield* Effect.context<
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
>();
const pathContext = yield* sourcePathContext;

const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
BROWSER_IMPORT_SOURCES,
Effect.fnUntraced(function* (definition) {
const unavailable = yield* unavailableReason(definition, platform, paths);
const unavailable = yield* unavailableReason(definition, pathContext);
return {
id: definition.id,
name: definition.name,
// Listing profiles touches the source's own files, so skip it when the
// source is unusable anyway.
profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
profiles:
unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [],
...(unavailable === undefined ? {} : { unavailable }),
} satisfies BrowserImportSource;
}),
Expand All @@ -189,7 +204,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
});
}

const blocked = yield* unavailableReason(definition, platform, paths).pipe(
const blocked = yield* unavailableReason(definition, pathContext).pipe(
Effect.provide(platformServices),
);
if (blocked !== undefined) {
Expand All @@ -199,16 +214,21 @@ export const make = Effect.gen(function* BrowserImportMake() {
// macOS attributes the Keychain prompt and the resulting ACL grant to the
// executable that asks, so record which one that was — in a packaged build
// it is the signed app, in dev whatever binary hosts the main process.
yield* Effect.logInfo("Reading browser cookie key from the keychain", {
sourceId: definition.id,
executablePath,
});
// Only Chromium reads a key; a Firefox import touches no keychain, and
// logging that it did would put a false security-sensitive event in the
// audit trail.
if (definition.engine === "chromium") {
yield* Effect.logInfo("Reading browser cookie key from the keychain", {
sourceId: definition.id,
executablePath,
});
}

// The profile directory arrives over IPC, so it is only honoured when the
// source itself reported it. Forwarding it unchecked would let `..`
// segments walk out of the browser's user-data directory and read any
// cookie database reachable on disk.
const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe(
Effect.provide(platformServices),
);
const requestedProfile = sourceProfiles.find(
Expand All @@ -222,28 +242,57 @@ export const make = Effect.gen(function* BrowserImportMake() {
}

// The profile was listed against a database moments ago; resolve it again
// rather than assume a path, since the live jar may sit under `Network/`.
// rather than assume a path, since a Chromium jar may sit under `Network/`.
const databasePath = yield* resolveCookieDatabase(
definition,
paths,
pathContext,
requestedProfile.directory,
).pipe(Effect.provide(platformServices));
if (databasePath === undefined) {
// A profile we listed moments ago can lose its database before the
// import runs (browser data cleanup, a profile reset). That is a read
// failure, not a platform problem.
return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" });
}

const read = yield* readChromiumCookies({
cookieDatabasePath: databasePath,
keychainService: definition.keychainService,
keychainAccount: definition.keychainAccount,
platform,
}).pipe(
// Both branches fail with a tagged error, so the union stays structurally
// identifiable and each tag is handled on its own below. The success side
// is normalized to one shape too, so the skipped tally survives either
// engine — Firefox stores plaintext, so nothing there is ever unreadable.
const read: Effect.Effect<
CookieReadResult,
ChromiumCookieReadError | FirefoxCookieReadError,
FileSystem.FileSystem | Path.Path | Scope.Scope
> =
definition.engine === "firefox"
? readFirefoxCookies(databasePath).pipe(
Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
)
: readChromiumCookies({
cookieDatabasePath: databasePath,
// Only reached on macOS: `unavailableReason` rejects Chromium
// elsewhere until those key stores are implemented.
keychainService: definition.keychainService ?? "",
keychainAccount: definition.keychainAccount ?? "",
platform,
});

const result = yield* read.pipe(
Effect.scoped,
Effect.provide(platformServices),
Effect.mapError(
(cause) =>
new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
),
Effect.catchTags({
ChromiumCookieReadError: (cause) =>
Effect.fail(
new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
),
// Firefox has one failure mode — its plaintext database would not open
// — so its error carries no reason of its own and the user-facing one
// is supplied here.
FirefoxCookieReadError: (cause) =>
Effect.fail(
new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }),
),
}),
);

const session = yield* browserSession
Expand All @@ -261,7 +310,7 @@ export const make = Effect.gen(function* BrowserImportMake() {

// Written one at a time rather than in parallel: Chromium's cookie store
// serialises writes anyway, and a rejected cookie should only cost itself.
return yield* writeCookies(session, read);
return yield* writeCookies(session, result);
});

return BrowserImport.of({ listSources, importCookies });
Expand Down
91 changes: 2 additions & 89 deletions apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,103 +6,16 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as NodeCrypto from "node:crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Scope from "effect/Scope";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import {
cookieScope,
readChromiumCookieDatabase,
snapshotCookieDatabase,
} from "./ChromiumCookies.ts";
import { readChromiumCookieDatabase } from "./ChromiumCookies.ts";
import { cookieScope } from "./CookieDatabase.ts";

const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]);
};

const runNode = <A, E>(
effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);

describe("snapshotCookieDatabase", () => {
it.effect("includes committed WAL data in one consistent database", () =>
runNode(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3code-cookie-source-",
});
const source = path.join(sourceDirectory, "Cookies");

const snapshot = yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`PRAGMA journal_mode = WAL`;
yield* sql`PRAGMA wal_autocheckpoint = 0`;
yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`;

expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true);
return yield* snapshotCookieDatabase(source);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));

const rows = yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`;
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true })));

expect(rows).toEqual([{ name: "committed-in-wal" }]);
}),
),
);

it.effect("propagates snapshot failures and removes its temporary directory", () =>
runNode(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3code-cookie-invalid-source-",
});
const source = path.join(sourceDirectory, "Cookies");
yield* fileSystem.writeFileString(source, "not a sqlite database");

const prefix = `t3code-cookie-failed-${process.pid}-`;
const error = yield* snapshotCookieDatabase(source, prefix).pipe(
Effect.scoped,
Effect.flip,
);

expect(error._tag).toBe("SqlError");
const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory));
expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false);
}),
),
);

it.effect("removes a successful snapshot when its scope closes", () =>
runNode(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3code-cookie-cleanup-source-",
});
const source = path.join(sourceDirectory, "Cookies");

yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));

const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped);
expect(yield* fileSystem.exists(snapshot)).toBe(false);
}),
),
);
});

describe("cookieScope", () => {
it("keeps a host-only cookie host-only", () => {
// Chromium stores a host-only cookie without a leading dot. Passing any
Expand Down
Loading
Loading