Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
20 changes: 18 additions & 2 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"
import { readSafariCookies, SafariCookieReadError } from "./SafariCookies.ts";
import {
BROWSER_IMPORT_SOURCES,
cookieDatabasePath,
cookieDatabaseCandidatePaths,
isSourceInstalled,
isSourceRunning,
listSourceProfiles,
Expand Down Expand Up @@ -164,7 +164,23 @@ export const make = Effect.gen(function* BrowserImportMake() {
});
}

const databasePath = cookieDatabasePath(definition, pathContext, requestedProfile.directory);
const candidates = cookieDatabaseCandidatePaths(
definition,
pathContext,
requestedProfile.directory,
);
const databasePath =
candidates.length === 0
? undefined
: yield* Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
return yield* Effect.forEach(candidates, (candidate) =>
fileSystem.stat(candidate).pipe(
Effect.map(() => candidate),
Effect.orElse(() => Effect.succeed(undefined)),
),
).pipe(Effect.map((results) => results.find((p) => p !== undefined)));
});
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
UtkarshUsername marked this conversation as resolved.
Outdated
if (databasePath === undefined) {
return yield* new BrowserImportFailedError({
sourceId: definition.id,
Expand Down
94 changes: 93 additions & 1 deletion apps/desktop/src/preview/BrowserImport/Sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as NodeSqlite from "node:sqlite";
import type { BrowserImportPathContext } from "./Sources.ts";
import {
BROWSER_IMPORT_SOURCES,
cookieDatabaseCandidatePaths,
cookieDatabasePath,
isSourceInstalled,
isSourceRunning,
Expand Down Expand Up @@ -105,6 +106,20 @@ describe("isSourceInstalled", () => {
}),
),
);

it.effect("detects a Chromium 127+ install with cookies under Network/", () =>
run(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const context = yield* withSourceHome();
const root = userDataDirectory(context);

yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db");
assert.isTrue(yield* isSourceInstalled(helium, context));
}),
),
);
});

describe("listSourceProfiles", () => {
Expand Down Expand Up @@ -173,6 +188,22 @@ describe("listSourceProfiles", () => {
),
);

it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () =>
run(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const context = yield* withSourceHome();
const root = userDataDirectory(context);
yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db");

assert.deepEqual(yield* listSourceProfiles(helium, context), [
{ directory: "Default", name: "Default" },
]);
}),
),
);

it.effect("counts a profile's cookies without decrypting them", () =>
run(
Effect.gen(function* () {
Expand All @@ -193,16 +224,55 @@ describe("cookieDatabasePath", () => {
it.effect("places the database under the requested source profile", () =>
run(
Effect.gen(function* () {
const path = yield* Path.Path;
const context = yield* withSourceHome();
assert.equal(
cookieDatabasePath(helium, context, "Profile 1"),
`${context.home}/Library/Application Support/net.imput.helium/Profile 1/Cookies`,
path.join(context.home, "Library/Application Support/net.imput.helium/Profile 1/Cookies"),
);
}),
),
);
});

describe("cookieDatabaseCandidatePaths", () => {
it.effect("returns both Cookies and Network/Cookies for Chromium engines", () =>
run(
Effect.gen(function* () {
const path = yield* Path.Path;
const context = yield* withSourceHome();
const candidates = cookieDatabaseCandidatePaths(helium, context, "Default");
assert.deepEqual(candidates, [
path.join(context.home, "Library/Application Support/net.imput.helium/Default/Cookies"),
path.join(
context.home,
"Library/Application Support/net.imput.helium/Default/Network/Cookies",
),
]);
}),
),
);

it.effect("returns only cookies.sqlite for Firefox", () =>
run(
Effect.gen(function* () {
const path = yield* Path.Path;
const context = yield* sourcePathContext.pipe(
Effect.provideService(HostProcessEnvironment, { HOME: "/tmp/test" }),
Effect.provideService(HostProcessPlatform, "darwin"),
);
const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default");
assert.deepEqual(candidates, [
path.join(
"/tmp/test",
"Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite",
),
]);
}),
),
);
});

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

Expand Down Expand Up @@ -232,6 +302,28 @@ describe("isSourceRunning for Firefox", () => {
}),
),
);

it.effect("does not treat a stale parent.lock file as a running browser", () =>
run(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
const context = yield* sourcePathContext.pipe(
Effect.provideService(HostProcessEnvironment, { HOME: home }),
Effect.provideService(HostProcessPlatform, "win32"),
);
const root = firefox.userDataDirectory(context)!;
const profile = `${root}/Profiles/gx7x7fqx.default-release`;
yield* fileSystem.makeDirectory(profile, { recursive: true });

// On Windows, Firefox creates parent.lock as a regular file that
// persists after the process exits. The file is only locked while
// Firefox is running; the old stat-based check always found it.
yield* fileSystem.writeFileString(`${profile}/parent.lock`, "");
assert.isFalse(yield* isSourceRunning(firefox, context));
}),
),
);
});

describe("Windows user-data directories", () => {
Expand Down
95 changes: 75 additions & 20 deletions apps/desktop/src/preview/BrowserImport/Sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,25 +208,44 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
* Chromium stores the database as `Cookies` under the profile directory;
* Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini`
* may already be absolute.
*
* Newer Chromium versions (127+) moved the cookie database into a `Network`
* subdirectory. The candidate list includes both locations so callers can
* tolerate either layout.
*/
export const cookieDatabasePath = (
export const cookieDatabaseCandidatePaths = (
definition: BrowserImportSourceDefinition,
context: BrowserImportPathContext,
profileDirectory: string,
): string | undefined => {
): ReadonlyArray<string> => {
const root = definition.userDataDirectory(context);
if (root === undefined) return undefined;
const fileName =
definition.engine === "firefox"
? "cookies.sqlite"
: definition.engine === "safari"
? "Cookies.binarycookies"
: "Cookies";
return context.path.isAbsolute(profileDirectory)
? context.path.join(profileDirectory, fileName)
: context.path.join(root, profileDirectory, fileName);
if (root === undefined) return [];
const profilePath = context.path.isAbsolute(profileDirectory)
? profileDirectory
: context.path.join(root, profileDirectory);
if (definition.engine === "firefox") {
return [context.path.join(profilePath, "cookies.sqlite")];
}
if (definition.engine === "safari") {
return [context.path.join(profilePath, "Cookies.binarycookies")];
}
// Chromium: older versions use `Cookies`, 127+ use `Network/Cookies`.
return [
context.path.join(profilePath, "Cookies"),
context.path.join(profilePath, "Network", "Cookies"),
];
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
};

/**
* Legacy single-path helper that returns the first candidate. Prefer
* `cookieDatabaseCandidatePaths` when checking for existence.
*/
export const cookieDatabasePath = (
definition: BrowserImportSourceDefinition,
context: BrowserImportPathContext,
profileDirectory: string,
): string | undefined => cookieDatabaseCandidatePaths(definition, context, profileDirectory)[0];

Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
/** Chromium keeps the DPAPI-wrapped Windows key in `Local State`. */
export const localStatePath = (
definition: BrowserImportSourceDefinition,
Expand Down Expand Up @@ -310,6 +329,35 @@ const entryExists = Effect.fnUntraced(function* (path: string) {
);
});

/**
* Whether a lock file is actually held by a running process. On macOS and
* Linux Firefox lock files are dangling symlinks that disappear when the
* process exits, so `entryExists` is sufficient. On Windows the lock file
* (`parent.lock`) is a regular file locked via `LockFileEx` that persists on
* disk after the process exits; `stat` always succeeds, so we must try to
* open the file with write access to detect the active lock.
*/
const isLockHeld = Effect.fnUntraced(function* (
lockPath: string,
platform: "darwin" | "linux" | "win32",
) {
if (platform !== "win32") return yield* entryExists(lockPath);
return yield* Effect.tryPromise({
try: () =>
new Promise<boolean>((resolve) => {
const fs = require("node:fs") as typeof import("node:fs");
fs.open(lockPath, "r+", (openErr: NodeJS.ErrnoException | null, fd: number) => {
Comment thread
UtkarshUsername marked this conversation as resolved.
Outdated
if (openErr) {
resolve(openErr.code === "EBUSY" || openErr.code === "EPERM");
return;
}
fs.close(fd, () => resolve(false));
});
}),
catch: () => false,
});
});
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

/** Shape of the slice of Chromium's `Local State` that names its profiles. */
const LocalState = Schema.Struct({
profile: Schema.optional(
Expand Down Expand Up @@ -344,7 +392,10 @@ const countProfileCookies = Effect.fnUntraced(function* (
context: BrowserImportPathContext,
directory: string,
): Effect.fn.Return<number | undefined, never> {
const databasePath = cookieDatabasePath(definition, context, directory);
const candidates = cookieDatabaseCandidatePaths(definition, context, directory);
const databasePath = yield* Effect.forEach(candidates, (candidate) =>
entryExists(candidate).pipe(Effect.map((exists) => (exists ? candidate : undefined))),
).pipe(Effect.map((results) => results.find((p) => p !== undefined)));
if (databasePath === undefined) return undefined;
return yield* Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
Expand Down Expand Up @@ -431,8 +482,10 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf
.readDirectory(root)
.pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) =>
entryExists(cookieDatabasePath(definition, context, directory) ?? "").pipe(
Effect.map((exists) => (exists ? { directory, name: directory } : undefined)),
Effect.forEach(cookieDatabaseCandidatePaths(definition, context, directory), (candidate) =>
entryExists(candidate),
).pipe(
Effect.map((results) => (results.some(Boolean) ? { directory, name: directory } : undefined)),
),
);
return yield* withCookieCounts(
Expand Down Expand Up @@ -472,7 +525,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
? profile.directory
: context.path.join(root, profile.directory);
return Effect.forEach(FIREFOX_LOCK_NAMES, (lock) =>
entryExists(context.path.join(directory, lock)),
isLockHeld(context.path.join(directory, lock), context.platform),
).pipe(Effect.map((results) => results.some(Boolean)));
});
return found.some(Boolean);
Expand All @@ -498,9 +551,11 @@ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstall
context: BrowserImportPathContext,
): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
const profiles = yield* listSourceProfiles(definition, context);
const found = yield* Effect.forEach(profiles, (profile) => {
const database = cookieDatabasePath(definition, context, profile.directory);
return database === undefined ? Effect.succeed(false) : entryExists(database);
});
const found = yield* Effect.forEach(profiles, (profile) =>
Effect.forEach(
cookieDatabaseCandidatePaths(definition, context, profile.directory),
(candidate) => entryExists(candidate),
).pipe(Effect.map((results) => results.some(Boolean))),
);
return found.some(Boolean);
});
Loading