Skip to content

Commit 1b132e4

Browse files
fix(desktop): detect a running Firefox, find Opera on Windows, keep containers apart
Firefox keeps its lock files inside each profile, not at the user-data root, so the running check never found them and offered imports from a live, mid-write database. It now walks the source's profiles and looks for all three names the platforms use. Opera does not follow the local-AppData `User Data` convention its Chromium relatives use — it lives under roaming `%APPDATA%\Opera Software\Opera Stable` — so it was never detected on Windows. Firefox isolates cookies per container and per private window through `originAttributes`. Electron has no equivalent, so importing them all collapsed several identities onto one host/name/path and handed the profile whichever container was written last. Only the default container is imported. The sidecar copy no longer ignores every error alongside the missing-file case; the new snapshot test caught that the earlier fix had landed on the pre-extraction copy of this code rather than the shared module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 524a094 commit 1b132e4

6 files changed

Lines changed: 249 additions & 38 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { assert, describe, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
6+
import { snapshotCookieDatabase } from "./CookieDatabase.ts";
7+
8+
const run = <A, E>(effect: Effect.Effect<A, E, never>) => effect;
9+
10+
describe("snapshotCookieDatabase", () => {
11+
it.effect("copies the write-ahead sidecars alongside the database", () =>
12+
run(
13+
Effect.gen(function* () {
14+
const fileSystem = yield* FileSystem.FileSystem;
15+
const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-snap-" });
16+
const source = `${directory}/Cookies`;
17+
yield* fileSystem.writeFileString(source, "db");
18+
yield* fileSystem.writeFileString(`${source}-wal`, "wal");
19+
20+
const snapshot = yield* snapshotCookieDatabase(source);
21+
22+
assert.equal(yield* fileSystem.readFileString(snapshot), "db");
23+
assert.equal(yield* fileSystem.readFileString(`${snapshot}-wal`), "wal");
24+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
25+
),
26+
);
27+
28+
it.effect("treats an absent sidecar as normal", () =>
29+
run(
30+
Effect.gen(function* () {
31+
const fileSystem = yield* FileSystem.FileSystem;
32+
const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-snap-" });
33+
const source = `${directory}/Cookies`;
34+
// A closed browser has already checkpointed its WAL away, which is the
35+
// common case rather than a failure.
36+
yield* fileSystem.writeFileString(source, "db");
37+
38+
const snapshot = yield* snapshotCookieDatabase(source);
39+
40+
assert.equal(yield* fileSystem.readFileString(snapshot), "db");
41+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
42+
),
43+
);
44+
45+
it.effect("fails when a sidecar exists but cannot be read", () =>
46+
run(
47+
Effect.gen(function* () {
48+
const fileSystem = yield* FileSystem.FileSystem;
49+
const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-snap-" });
50+
const source = `${directory}/Cookies`;
51+
yield* fileSystem.writeFileString(source, "db");
52+
// A sidecar that is present but uncopyable, rather than absent.
53+
yield* fileSystem.makeDirectory(`${source}-wal`);
54+
55+
// Ignoring this would open the snapshot without its write-ahead log
56+
// and silently return a cookie set missing its newest transactions.
57+
const error = yield* snapshotCookieDatabase(source).pipe(Effect.scoped, Effect.flip);
58+
59+
assert.notEqual(error.reason._tag, "NotFound");
60+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
61+
),
62+
);
63+
});

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

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,28 @@ export interface ImportedCookie {
3232
*
3333
* Scoped: the temporary directory goes away when the caller's scope closes.
3434
*/
35-
export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(
36-
function* (cookiePath: string) {
37-
const fileSystem = yield* FileSystem.FileSystem;
38-
const path = yield* Path.Path;
35+
export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* (
36+
cookiePath: string,
37+
) {
38+
const fileSystem = yield* FileSystem.FileSystem;
39+
const path = yield* Path.Path;
3940

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-
);
41+
const directory = yield* fileSystem.makeTempDirectoryScoped({
42+
prefix: "t3code-cookie-import-",
43+
});
44+
const target = path.join(directory, path.basename(cookiePath));
45+
yield* fileSystem.copyFile(cookiePath, target);
46+
// A sidecar only exists while the browser holds the database open, so an
47+
// absent one is normal. Anything else — a permission error, a partial read
48+
// — is not: SQLite would then open the snapshot without the write-ahead
49+
// log and quietly return a cookie set missing its newest transactions.
50+
yield* Effect.forEach(["-wal", "-shm"], (suffix) =>
51+
fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(
52+
Effect.catchIf(
53+
(error) => error.reason._tag === "NotFound",
54+
() => Effect.void,
55+
),
56+
),
57+
);
58+
return target;
59+
});

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

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* (
2222
isSecure: number;
2323
isHttpOnly: number;
2424
sameSite: number;
25+
originAttributes?: string;
2526
}>,
2627
) {
2728
const fileSystem = yield* FileSystem.FileSystem;
@@ -31,12 +32,14 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* (
3132
database.exec(
3233
`create table moz_cookies (
3334
id integer primary key, host text, name text, value text, path text,
34-
expiry integer, isSecure integer, isHttpOnly integer, sameSite integer
35+
expiry integer, isSecure integer, isHttpOnly integer, sameSite integer,
36+
originAttributes text not null default ''
3537
)`,
3638
);
3739
const insert = database.prepare(
38-
`insert into moz_cookies (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite)
39-
values (?, ?, ?, ?, ?, ?, ?, ?)`,
40+
`insert into moz_cookies
41+
(host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, originAttributes)
42+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
4043
);
4144
for (const row of rows) {
4245
insert.run(
@@ -48,6 +51,7 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* (
4851
row.isSecure,
4952
row.isHttpOnly,
5053
row.sameSite,
54+
row.originAttributes ?? "",
5155
);
5256
}
5357
database.close();
@@ -118,6 +122,54 @@ describe("readFirefoxCookies", () => {
118122
),
119123
);
120124

125+
it.effect("imports only the default container", () =>
126+
run(
127+
Effect.gen(function* () {
128+
const file = yield* writeFirefoxCookieDatabase([
129+
{
130+
host: "mail.test",
131+
name: "session",
132+
value: "default-container",
133+
path: "/",
134+
expiry: 1_800_000_000,
135+
isSecure: 1,
136+
isHttpOnly: 0,
137+
sameSite: 1,
138+
},
139+
{
140+
// Same host, name and path as above: Firefox keeps these apart by
141+
// container, Electron cannot, so importing both would hand the
142+
// profile whichever one happened to be written last.
143+
host: "mail.test",
144+
name: "session",
145+
value: "work-container",
146+
path: "/",
147+
expiry: 1_800_000_000,
148+
isSecure: 1,
149+
isHttpOnly: 0,
150+
sameSite: 1,
151+
originAttributes: "^userContextId=2",
152+
},
153+
{
154+
host: "mail.test",
155+
name: "private",
156+
value: "private-window",
157+
path: "/",
158+
expiry: 1_800_000_000,
159+
isSecure: 1,
160+
isHttpOnly: 0,
161+
sameSite: 1,
162+
originAttributes: "^privateBrowsingId=1",
163+
},
164+
]);
165+
166+
const cookies = yield* readFirefoxCookies(file);
167+
168+
expect(cookies.map((cookie) => cookie.value)).toEqual(["default-container"]);
169+
}),
170+
),
171+
);
172+
121173
it.effect("reads without mutating the source database", () =>
122174
run(
123175
Effect.gen(function* () {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,15 @@ export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")
4949

5050
const rows = yield* Effect.gen(function* () {
5151
const sql = yield* SqlClient.SqlClient;
52+
// Only the default container. Firefox isolates cookies per container and
53+
// per private window via `originAttributes` (`^userContextId=2`,
54+
// `^privateBrowsingId=1`); Electron has no equivalent, so importing them
55+
// all would collapse several identities onto one host/name/path and hand
56+
// the profile an arbitrary container's session.
5257
const raw = yield* sql`
5358
select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite
5459
from moz_cookies
60+
where originAttributes = ''
5561
`;
5662
return yield* decodeCookieRows(raw);
5763
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));

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

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,7 @@ describe("listSourceProfiles", () => {
114114
Effect.gen(function* () {
115115
const fileSystem = yield* FileSystem.FileSystem;
116116
const context = yield* withSourceHome();
117-
yield* fileSystem.writeFileString(
118-
`${userDataDirectory(context)}/Local State`,
119-
"{not-json",
120-
);
117+
yield* fileSystem.writeFileString(`${userDataDirectory(context)}/Local State`, "{not-json");
121118

122119
assert.deepEqual(yield* listSourceProfiles(helium, context), [
123120
{ directory: "Default", name: "Default" },
@@ -140,3 +137,61 @@ describe("cookieDatabasePath", () => {
140137
),
141138
);
142139
});
140+
141+
const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
142+
const opera = BROWSER_IMPORT_SOURCES.find((source) => source.id === "opera")!;
143+
144+
describe("isSourceRunning for Firefox", () => {
145+
it.effect("finds the lock inside the profile, not at the root", () =>
146+
run(
147+
Effect.gen(function* () {
148+
const fileSystem = yield* FileSystem.FileSystem;
149+
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
150+
const context = yield* sourcePathContext.pipe(
151+
Effect.provideService(HostProcessEnvironment, { HOME: home }),
152+
Effect.provideService(HostProcessPlatform, "darwin"),
153+
);
154+
const root = firefox.userDataDirectory(context)!;
155+
const profile = `${root}/Profiles/abcd.default-release`;
156+
yield* fileSystem.makeDirectory(profile, { recursive: true });
157+
158+
assert.isFalse(yield* isSourceRunning(firefox, context));
159+
160+
// Firefox keeps its locks per profile. A root-level lock is not one,
161+
// and looking there was why a running Firefox read as importable.
162+
yield* fileSystem.writeFileString(`${root}/lock`, "");
163+
assert.isFalse(yield* isSourceRunning(firefox, context));
164+
165+
yield* fileSystem.writeFileString(`${profile}/.parentlock`, "");
166+
assert.isTrue(yield* isSourceRunning(firefox, context));
167+
}),
168+
),
169+
);
170+
});
171+
172+
describe("Windows user-data directories", () => {
173+
it.effect("puts Opera under roaming AppData without a User Data level", () =>
174+
run(
175+
Effect.gen(function* () {
176+
const context = yield* sourcePathContext.pipe(
177+
Effect.provideService(HostProcessEnvironment, {
178+
USERPROFILE: "C:\\Users\\u",
179+
APPDATA: "C:\\Users\\u\\AppData\\Roaming",
180+
LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local",
181+
}),
182+
Effect.provideService(HostProcessPlatform, "win32"),
183+
);
184+
185+
// Opera does not follow the local-AppData `User Data` convention its
186+
// Chromium relatives use, so deriving it that way never found it.
187+
assert.include(opera.userDataDirectory(context) ?? "", "Roaming");
188+
assert.include(opera.userDataDirectory(context) ?? "", "Opera Stable");
189+
assert.notInclude(opera.userDataDirectory(context) ?? "", "User Data");
190+
191+
const chrome = BROWSER_IMPORT_SOURCES.find((source) => source.id === "chrome")!;
192+
assert.include(chrome.userDataDirectory(context) ?? "", "Local");
193+
assert.include(chrome.userDataDirectory(context) ?? "", "User Data");
194+
}),
195+
),
196+
);
197+
});

0 commit comments

Comments
 (0)