authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 17:23:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:23:06-07:00
loged2bdbb278c4fedf6ce79d4925908b759f7fd992
treecbc1c8911616b943ab0c5d96b7a1f386fe93eeea
parentcaf4981e32f4fd3c9adb8339b5bec2d95d09b7aa
signature Signed by PGP key B5690EEEBB952194

feat(desktop): import cookies from Safari (#7262)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

22 files changed, 1189 insertions(+), 27 deletions(-)

apps/desktop/src/electron/ElectronShell.test.ts+14
......@@ -36,6 +36,20 @@ describe("ElectronShell", () => {
3636 }).pipe(Effect.provide(ElectronShell.layer)),
3737 );
3838
39 it.effect("opens the Full Disk Access settings anchor", () =>
40 Effect.gen(function* () {
41 openExternalMock.mockResolvedValue(undefined);
42
43 const electronShell = yield* ElectronShell.ElectronShell;
44 const result = yield* electronShell.openSystemSettings("full-disk-access");
45
46 assert.equal(result, true);
47 assert.deepEqual(openExternalMock.mock.calls, [
48 ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"],
49 ]);
50 }).pipe(Effect.provide(ElectronShell.layer)),
51 );
52
3953 it.effect("opens remote SSH editor URLs", () =>
4054 Effect.gen(function* () {
4155 openExternalMock.mockResolvedValue(undefined);
apps/desktop/src/electron/ElectronShell.ts+28-1
......@@ -1,4 +1,8 @@
1import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
1import {
2 REMOTE_CAPABLE_EDITOR_IDS,
3 remoteSchemeForEditor,
4 type SystemSettingsPane,
5} from "@t3tools/contracts";
26import * as Context from "effect/Context";
37import * as Effect from "effect/Effect";
48import * as Layer from "effect/Layer";
......@@ -6,6 +10,20 @@ import * as Option from "effect/Option";
610
711import * as Electron from "electron";
812
13/**
14 * Deep links to individual System Settings panes. These are app-fixed, not
15 * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep
16 * arbitrary link schemes from reaching the OS handler — and open through their
17 * own path below. The pane rather than the URL crosses the IPC boundary, so a
18 * renderer can only ask for one of these known destinations.
19 *
20 * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor.
21 */
22const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
23 "full-disk-access":
24 "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
25};
26
927// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
1028// must reach the OS handler; every other non-web scheme stays blocked.
1129const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
......@@ -43,6 +61,8 @@ export class ElectronShell extends Context.Service<
4361 ElectronShell,
4462 {
4563 readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
64 /** Opens a known System Settings pane by identifier, not by URL. */
65 readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect<boolean>;
4666 readonly copyText: (text: string) => Effect.Effect<void>;
4767 }
4868>()("@t3tools/desktop/electron/ElectronShell") {}
......@@ -59,6 +79,13 @@ export const make = ElectronShell.of({
5979 ),
6080 ),
6181 }),
82 openSystemSettings: (pane) =>
83 Effect.promise(() =>
84 Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then(
85 () => true,
86 () => false,
87 ),
88 ),
6289 copyText: (text) =>
6390 Effect.sync(() => {
6491 Electron.clipboard.writeText(text);
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -38,6 +38,7 @@ import {
3838 getSystemLocale,
3939 getWindowFullscreenState,
4040 openExternal,
41 openSystemSettings,
4142 probeRemoteEditors,
4243 pickFolder,
4344 pickProjectFavicon,
......@@ -94,6 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
9495 yield* ipc.handle(setTheme);
9596 yield* ipc.handle(showContextMenu);
9697 yield* ipc.handle(openExternal);
98 yield* ipc.handle(openSystemSettings);
9799 yield* ipc.handle(probeRemoteEditors);
98100 yield* ipc.handle(getUpdateState);
99101 yield* ipc.handle(setUpdateChannel);
apps/desktop/src/ipc/channels.ts+1
......@@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
44export const SET_THEME_CHANNEL = "desktop:set-theme";
55export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
66export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
7export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
78export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
89export const MENU_ACTION_CHANNEL = "desktop:menu-action";
910export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
apps/desktop/src/ipc/methods/window.ts+11
......@@ -9,6 +9,7 @@ import {
99 PickFolderOptionsSchema,
1010 PRIMARY_LOCAL_ENVIRONMENT_ID,
1111 REMOTE_CAPABLE_EDITOR_IDS,
12 SystemSettingsPaneSchema,
1213 type DesktopEnvironmentBootstrap,
1314 type PickedThemeFile,
1415} from "@t3tools/contracts";
......@@ -298,6 +299,16 @@ export const openExternal = DesktopIpc.makeIpcMethod({
298299 }),
299300});
300301
302export const openSystemSettings = DesktopIpc.makeIpcMethod({
303 channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL,
304 payload: SystemSettingsPaneSchema,
305 result: Schema.Boolean,
306 handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) {
307 const shell = yield* ElectronShell.ElectronShell;
308 return yield* shell.openSystemSettings(pane);
309 }),
310});
311
301312export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
302313 channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
303314 payload: Schema.Undefined,
apps/desktop/src/preload.ts+2
......@@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
116116 ...(position === undefined ? {} : { position }),
117117 }),
118118 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
119 openSystemSettings: (pane: string) =>
120 ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
119121 probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
120122 onMenuAction: (listener) => {
121123 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
apps/desktop/src/preview/BrowserImport/BrowserImport.ts+35-15
......@@ -27,6 +27,7 @@ import * as BrowserSession from "../BrowserSession.ts";
2727import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
2828import type { CookieReadResult } from "./CookieDatabase.ts";
2929import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
30import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts";
3031import {
3132 BROWSER_IMPORT_SOURCES,
3233 resolveCookieDatabase,
......@@ -92,6 +93,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function*
9293 if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
9394 if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
9495 if (yield* isSourceRunning(definition, context)) return "browserRunning";
96 // Safari's jar is found by `stat`, which TCC permits without Full Disk
97 // Access — so a Safari that lists as ready may still refuse the read. Probe
98 // the grant here, so the wizard can open on the permission step and a
99 // post-grant recheck can tell granted from still-denied, rather than only
100 // discovering it by attempting the import.
101 if (definition.engine === "safari") {
102 const jar = yield* resolveCookieDatabase(definition, context, ".");
103 if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess";
104 }
95105 return undefined;
96106});
97107
......@@ -254,25 +264,29 @@ export const make = Effect.gen(function* BrowserImportMake() {
254264 const userDataDirectory = definition.userDataDirectory(pathContext);
255265 const read: Effect.Effect<
256266 CookieReadResult,
257 ChromiumCookieReadError | FirefoxCookieReadError,
267 ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError,
258268 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
259269 > =
260 definition.engine === "firefox"
261 ? readFirefoxCookies(databasePath).pipe(
270 definition.engine === "safari"
271 ? readSafariCookies(databasePath).pipe(
262272 Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
263273 )
264 : readChromiumCookies({
265 cookieDatabasePath: databasePath,
266 keychainService: definition.keychainService,
267 keychainAccount: definition.keychainAccount,
268 linuxSecretApplication: definition.linuxSecretApplication,
269 ...(platform === "win32" && userDataDirectory !== undefined
270 ? {
271 windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
272 }
273 : {}),
274 platform,
275 });
274 : definition.engine === "firefox"
275 ? readFirefoxCookies(databasePath).pipe(
276 Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
277 )
278 : readChromiumCookies({
279 cookieDatabasePath: databasePath,
280 keychainService: definition.keychainService,
281 keychainAccount: definition.keychainAccount,
282 linuxSecretApplication: definition.linuxSecretApplication,
283 ...(platform === "win32" && userDataDirectory !== undefined
284 ? {
285 windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
286 }
287 : {}),
288 platform,
289 });
276290
277291 const result = yield* read.pipe(
278292 Effect.scoped,
......@@ -289,6 +303,12 @@ export const make = Effect.gen(function* BrowserImportMake() {
289303 Effect.fail(
290304 new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }),
291305 ),
306 // Safari's reasons are already user-facing: a TCC refusal is the Full
307 // Disk Access prompt, anything else is a read failure.
308 SafariCookieReadError: (cause) =>
309 Effect.fail(
310 new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
311 ),
292312 }),
293313 );
294314
apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts created+443
......@@ -0,0 +1,443 @@
1// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar
2// format byte by byte.
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as PlatformError from "effect/PlatformError";
8
9import {
10 isPermissionDenied,
11 parseBinaryCookies,
12 readSafariCookies,
13 safariAccessDenied,
14 SafariCookieReadError,
15} from "./SafariCookies.ts";
16
17const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
18
19interface FixtureCookie {
20 readonly domain: string;
21 readonly name: string;
22 readonly path: string;
23 readonly value: string;
24 readonly flags: number;
25 /** Seconds since 2001-01-01, as Safari stores them. */
26 readonly expiry: number;
27}
28
29/** Encodes one cookie exactly as Safari lays it out. */
30function encodeCookie(cookie: FixtureCookie): Buffer {
31 const strings = [cookie.domain, cookie.name, cookie.path, cookie.value];
32 const headerSize = 56;
33 const offsets: number[] = [];
34 let cursor = headerSize;
35 for (const value of strings) {
36 offsets.push(cursor);
37 cursor += Buffer.byteLength(value) + 1;
38 }
39 const size = cursor;
40
41 const buffer = Buffer.alloc(size);
42 buffer.writeUInt32LE(size, 0);
43 buffer.writeUInt32LE(0, 4);
44 buffer.writeUInt32LE(cookie.flags, 8);
45 buffer.writeUInt32LE(0, 12);
46 buffer.writeUInt32LE(offsets[0]!, 16);
47 buffer.writeUInt32LE(offsets[1]!, 20);
48 buffer.writeUInt32LE(offsets[2]!, 24);
49 buffer.writeUInt32LE(offsets[3]!, 28);
50 buffer.writeUInt32LE(0, 32);
51 buffer.writeUInt32LE(0, 36);
52 buffer.writeDoubleLE(cookie.expiry, 40);
53 buffer.writeDoubleLE(0, 48);
54 strings.forEach((value, index) => {
55 buffer.write(value, offsets[index]!, "utf8");
56 });
57 return buffer;
58}
59
60/** Builds a single-page `Cookies.binarycookies` file. */
61function encodeBinaryCookies(cookies: ReadonlyArray<FixtureCookie>): Buffer {
62 const encoded = cookies.map(encodeCookie);
63 const headerSize = 12 + encoded.length * 4;
64 const offsets: number[] = [];
65 let cursor = headerSize;
66 for (const cookie of encoded) {
67 offsets.push(cursor);
68 cursor += cookie.length;
69 }
70
71 const page = Buffer.alloc(cursor);
72 page.writeUInt32BE(0x0000_0100, 0);
73 page.writeUInt32LE(encoded.length, 4);
74 offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4));
75 encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!));
76
77 const header = Buffer.alloc(8 + 4);
78 header.write("cook", 0, "latin1");
79 header.writeUInt32BE(1, 4);
80 header.writeUInt32BE(page.length, 8);
81 return Buffer.concat([header, page]);
82}
83
84describe("parseBinaryCookies", () => {
85 it("reads Safari's format and rebases its 2001 epoch", () => {
86 const file = encodeBinaryCookies([
87 {
88 domain: ".apple.com",
89 name: "session",
90 path: "/",
91 value: "abc",
92 // secure | httpOnly
93 flags: 0x1 | 0x4,
94 expiry: 800_000_000,
95 },
96 {
97 domain: "example.test",
98 name: "plain",
99 path: "/app",
100 value: "v",
101 flags: 0,
102 expiry: 0,
103 },
104 ]);
105
106 expect(parseBinaryCookies(file)).toEqual([
107 {
108 url: "https://apple.com/",
109 name: "session",
110 value: "abc",
111 domain: ".apple.com",
112 path: "/",
113 secure: true,
114 httpOnly: true,
115 // Safari counts from 2001-01-01, Electron from 1970.
116 expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS,
117 // The format predates SameSite; Lax is the safe modern default.
118 sameSite: "lax",
119 },
120 {
121 url: "http://example.test/app",
122 name: "plain",
123 value: "v",
124 // Host-only: no leading dot in the jar, so no `domain` for Electron,
125 // which would otherwise re-add the dot and widen it to subdomains.
126 domain: undefined,
127 path: "/app",
128 secure: false,
129 httpOnly: false,
130 expirationDate: undefined,
131 sameSite: "lax",
132 },
133 ]);
134 });
135
136 it("keeps __Host- cookies host-only so Electron accepts them", () => {
137 const file = encodeBinaryCookies([
138 { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 },
139 ]);
140
141 expect(parseBinaryCookies(file)[0]).toMatchObject({
142 url: "https://example.test/",
143 name: "__Host-id",
144 domain: undefined,
145 });
146 });
147
148 it("brackets IPv6 hosts in the cookie URL", () => {
149 const file = encodeBinaryCookies([
150 { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 },
151 ]);
152
153 expect(parseBinaryCookies(file)[0]).toMatchObject({
154 url: "http://[::1]/",
155 domain: undefined,
156 });
157 });
158
159 it("reads cookies spread across multiple pages", () => {
160 // Safari pages its cookie file, and a single-page reader would silently
161 // return only the first slice.
162 const first = encodeBinaryCookies([
163 { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
164 ]);
165 const second = encodeBinaryCookies([
166 { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
167 ]);
168 // Splice the two single-page files into one two-page file.
169 const firstPage = first.subarray(12);
170 const secondPage = second.subarray(12);
171 const header = Buffer.alloc(16);
172 header.write("cook", 0, "latin1");
173 header.writeUInt32BE(2, 4);
174 header.writeUInt32BE(firstPage.length, 8);
175 header.writeUInt32BE(secondPage.length, 12);
176
177 const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]));
178
179 expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]);
180 });
181
182 it("rejects a page that runs past the end of the file", () => {
183 // `Buffer.subarray` clamps rather than throwing, so an overlong first page
184 // swallows the second one's bytes and advances the cursor past the end.
185 // Every cookie after the boundary then vanishes from a "successful" import.
186 const first = encodeBinaryCookies([
187 { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
188 ]);
189 const second = encodeBinaryCookies([
190 { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
191 ]);
192 const firstPage = first.subarray(12);
193 const secondPage = second.subarray(12);
194 const header = Buffer.alloc(16);
195 header.write("cook", 0, "latin1");
196 header.writeUInt32BE(2, 4);
197 // Declares more bytes for page one than the file holds in total.
198 header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8);
199 header.writeUInt32BE(secondPage.length, 12);
200
201 expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow(
202 SafariCookieReadError,
203 );
204 });
205
206 it("rejects a record whose declared size runs past its page", () => {
207 const valid = encodeBinaryCookies([
208 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
209 ]);
210 // The record's own length is what bounds its string offsets; an inflated
211 // one lets them read the following record's bytes as this cookie's value.
212 const pageStart = 8 + 4;
213 const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
214 const corrupt = Buffer.from(valid);
215 corrupt.writeUInt32LE(0xffff, recordStart);
216
217 expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError);
218 });
219
220 it("rejects records truncated inside the 56-byte header", () => {
221 const valid = encodeBinaryCookies([
222 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
223 ]);
224 const pageStart = 8 + 4;
225 const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
226
227 for (let size = 48; size < 56; size += 1) {
228 const corrupt = Buffer.from(valid);
229 corrupt.writeUInt32LE(size, recordStart);
230 expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow(
231 SafariCookieReadError,
232 );
233 }
234 });
235
236 it("rejects record offsets that point into the page header or an earlier record", () => {
237 const valid = encodeBinaryCookies([
238 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
239 { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 },
240 ]);
241 const pageStart = 8 + 4;
242 const firstRecord = valid.readUInt32LE(pageStart + 8);
243
244 // Pointing the second offset at the page's offset table would let those
245 // table bytes parse as a fabricated record.
246 const intoTable = Buffer.from(valid);
247 intoTable.writeUInt32LE(4, pageStart + 12);
248 expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError);
249
250 // Pointing it back at the first record makes the same bytes count twice.
251 const overlapping = Buffer.from(valid);
252 overlapping.writeUInt32LE(firstRecord, pageStart + 12);
253 expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError);
254
255 // And a well-formed two-record page still parses.
256 expect(parseBinaryCookies(valid)).toHaveLength(2);
257 });
258
259 it("rejects string offsets that point into the record header", () => {
260 const valid = encodeBinaryCookies([
261 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
262 ]);
263 const pageStart = 8 + 4;
264 const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
265
266 for (const offsetField of [16, 20, 24, 28]) {
267 const corrupt = Buffer.from(valid);
268 corrupt.writeUInt32LE(55, recordStart + offsetField);
269 expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow(
270 SafariCookieReadError,
271 );
272 }
273 });
274
275 it("accepts the checksum and property-list trailer Safari writes", () => {
276 const file = encodeBinaryCookies([
277 { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 },
278 ]);
279 const checksum = Buffer.alloc(8);
280 const plist = Buffer.from("bplist00 stub");
281 const plistLength = Buffer.alloc(4);
282 plistLength.writeUInt32BE(plist.length, 0);
283
284 expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1);
285 expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1);
286 });
287
288 it("rejects a jar whose page table stops short of its contents", () => {
289 // A second, undeclared page after the first would be silently dropped —
290 // the cookies it holds vanish from the import with no error — so a file
291 // the header does not fully describe is refused instead.
292 const first = encodeBinaryCookies([
293 { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 },
294 ]);
295 const extraPage = encodeBinaryCookies([
296 { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 },
297 ]).subarray(12);
298
299 expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow(
300 SafariCookieReadError,
301 );
302 // A trailer that claims a property list it doesn't contain is refused too.
303 const badLength = Buffer.alloc(4);
304 badLength.writeUInt32BE(99, 0);
305 expect(() =>
306 parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])),
307 ).toThrow(SafariCookieReadError);
308 });
309
310 it("rejects a file that is not binarycookies", () => {
311 expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow(
312 SafariCookieReadError,
313 );
314 });
315});
316
317describe("readSafariCookies", () => {
318 it.effect("adds the cookie path and parser cause to malformed jar failures", () =>
319 Effect.gen(function* () {
320 const fileSystem = yield* FileSystem.FileSystem;
321 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
322 const jar = `${directory}/Cookies.binarycookies`;
323 yield* fileSystem.writeFileString(jar, "not a cookie jar");
324
325 const error = yield* readSafariCookies(jar).pipe(Effect.flip);
326
327 assert.equal(error.reason, "readFailed");
328 assert.equal(error.cookieDatabasePath, jar);
329 assert.instanceOf(error.cause, SafariCookieReadError);
330 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
331 );
332
333 it.effect("reports a TCC denial as a permission the user can grant", () =>
334 Effect.gen(function* () {
335 // What Full Disk Access actually looks like: the file is there, the read
336 // is refused with EPERM. Effect tags that `Unknown`, not
337 // `PermissionDenied`, so the reader has to look at the errno. Reporting
338 // it as a generic failure would send the user looking for a missing
339 // browser instead of a checkbox.
340 const denied = PlatformError.systemError({
341 _tag: "Unknown",
342 module: "FileSystem",
343 method: "readFile",
344 pathOrDescriptor: "/protected/Cookies.binarycookies",
345 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
346 });
347
348 const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe(
349 Effect.flip,
350 Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })),
351 );
352
353 assert.equal(error.reason, "needsFullDiskAccess");
354 }),
355 );
356
357 it.effect("reports an ordinary permission failure as a plain read failure", () =>
358 Effect.gen(function* () {
359 const fileSystem = yield* FileSystem.FileSystem;
360 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
361 const jar = `${directory}/Cookies.binarycookies`;
362 yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b]));
363 // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix
364 // it, so it must not be routed to that grant.
365 yield* fileSystem.chmod(jar, 0o000);
366
367 const error = yield* readSafariCookies(jar).pipe(Effect.flip);
368
369 assert.equal(error.reason, "readFailed");
370 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
371 );
372
373 it.effect("reports a missing jar as a plain read failure", () =>
374 Effect.gen(function* () {
375 const fileSystem = yield* FileSystem.FileSystem;
376 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
377
378 const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip);
379
380 assert.equal(error.reason, "readFailed");
381 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
382 );
383});
384
385describe("safariAccessDenied", () => {
386 const eperm = PlatformError.systemError({
387 _tag: "Unknown",
388 module: "FileSystem",
389 method: "open",
390 pathOrDescriptor: "/protected/Cookies.binarycookies",
391 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
392 });
393 const denied = (error: PlatformError.PlatformError) =>
394 FileSystem.layerNoop({ open: () => Effect.fail(error) });
395
396 it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () =>
397 Effect.gen(function* () {
398 // `stat` finds the jar without the grant, so only an open tells the
399 // listing whether the import would actually be allowed.
400 assert.isTrue(
401 yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe(
402 Effect.provide(denied(eperm)),
403 ),
404 );
405 }),
406 );
407
408 it.effect("does not read a readable jar, or any other failure, as denied", () =>
409 Effect.gen(function* () {
410 const fileSystem = yield* FileSystem.FileSystem;
411 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
412 const jar = `${directory}/Cookies.binarycookies`;
413 yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b]));
414 assert.isFalse(yield* safariAccessDenied(jar));
415 // Missing entirely is "not installed", not "denied".
416 assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`));
417 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
418 );
419});
420
421describe("isPermissionDenied", () => {
422 // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified
423 // against Safari's TCC-protected jar, whose denial is EPERM, tagged
424 // `Unknown` rather than `PermissionDenied`.
425 const platformError = (reasonTag: string, code: string): PlatformError.PlatformError =>
426 ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never;
427
428 it("treats a TCC EPERM denial as permission denied", () => {
429 // The regression: EPERM is tagged `Unknown`, so checking the tag alone
430 // reported Safari's Full Disk Access refusal as a generic read failure.
431 expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true);
432 });
433
434 it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => {
435 // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk
436 // Access, so it stays a plain read failure; only TCC's EPERM routes there.
437 expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false);
438 });
439
440 it("does not treat an unrelated failure as permission denied", () => {
441 expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false);
442 });
443});
apps/desktop/src/preview/BrowserImport/SafariCookies.ts created+263
......@@ -0,0 +1,263 @@
1/**
2 * Safari cookie extraction.
3 *
4 * Safari does not encrypt its cookies; it stores them in a proprietary
5 * `Cookies.binarycookies` file inside its app container. The protection is
6 * TCC, not cryptography — the file lives under a path only apps with Full Disk
7 * Access may read, so the gate is a permission the user grants in System
8 * Settings rather than a key to obtain.
9 *
10 * The format, big-endian throughout except the page bodies:
11 *
12 * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
13 * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
14 * then each cookie:
15 * u32le size, u32le unknown, u32le flags, u32le unknown,
16 * u32le urlOffset, nameOffset, pathOffset, valueOffset,
17 * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
18 * strings at the offsets above (relative to the cookie start).
19 *
20 * @module SafariCookies
21 */
22import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
24import * as PlatformError from "effect/PlatformError";
25import * as Schema from "effect/Schema";
26
27import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts";
28
29/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
30const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
31
32/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
33const COOKIE_PAGE_HEADER_SIZE = 12;
34/** Through the `f64 creation` field; string bytes follow. */
35const COOKIE_RECORD_HEADER_SIZE = 56;
36
37const FLAG_SECURE = 0x1;
38const FLAG_HTTP_ONLY = 0x4;
39
40export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
41export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
42
43export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
44 "SafariCookieReadError",
45 {
46 reason: SafariCookieReadFailure,
47 /**
48 * Which jar the read was for. The parser raises this before a path is in
49 * hand, so it is optional rather than required.
50 */
51 cookieDatabasePath: Schema.optional(Schema.String),
52 /** Kept for the log; never surfaced to the user. */
53 cause: Schema.optional(Schema.Defect()),
54 },
55) {
56 override get message(): string {
57 return this.cookieDatabasePath === undefined
58 ? `Could not read Safari cookies: ${this.reason}.`
59 : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
60 }
61}
62
63const isSafariCookieReadError = Schema.is(SafariCookieReadError);
64
65/** Reads a NUL-terminated ASCII string at an offset. */
66function readCString(buffer: Buffer, start: number): string {
67 const end = buffer.indexOf(0, start);
68 return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
69}
70
71export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
72 if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
73 throw new SafariCookieReadError({ reason: "readFailed" });
74 }
75
76 const pageCount = buffer.readUInt32BE(4);
77 // Every declared structure is bounds-checked against what the file actually
78 // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
79 // so accepting a short page or an overlong record would return a cookie set
80 // that is quietly missing entries or carrying fields read out of the next
81 // record — a partial import the user has no way to notice.
82 if (8 + pageCount * 4 > buffer.length) {
83 throw new SafariCookieReadError({ reason: "readFailed" });
84 }
85 const pageSizes: number[] = [];
86 for (let index = 0; index < pageCount; index += 1) {
87 pageSizes.push(buffer.readUInt32BE(8 + index * 4));
88 }
89
90 const cookies: ImportedCookie[] = [];
91 let pageStart = 8 + pageCount * 4;
92
93 for (const pageSize of pageSizes) {
94 if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
95 throw new SafariCookieReadError({ reason: "readFailed" });
96 }
97 const page = buffer.subarray(pageStart, pageStart + pageSize);
98 pageStart += pageSize;
99
100 // Page bodies switch to little-endian after the big-endian header.
101 const cookieCount = page.readUInt32LE(4);
102 const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4;
103 if (offsetTableEnd > page.length) {
104 throw new SafariCookieReadError({ reason: "readFailed" });
105 }
106 // Every record accepted so far, so a later offset cannot point back into
107 // one of them: the page header, the offset table, and earlier records are
108 // all bytes that would otherwise parse as a fabricated cookie.
109 const accepted: Array<readonly [start: number, end: number]> = [];
110 for (let index = 0; index < cookieCount; index += 1) {
111 const cookieStart = page.readUInt32LE(8 + index * 4);
112 if (cookieStart < offsetTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
113 throw new SafariCookieReadError({ reason: "readFailed" });
114 }
115 // Bounded by the record's own length so a string offset cannot run past
116 // it into the following record's bytes.
117 const recordSize = page.readUInt32LE(cookieStart);
118 const cookieEnd = cookieStart + recordSize;
119 if (
120 recordSize < COOKIE_RECORD_HEADER_SIZE ||
121 cookieEnd > page.length ||
122 accepted.some(([start, end]) => cookieStart < end && cookieEnd > start)
123 ) {
124 throw new SafariCookieReadError({ reason: "readFailed" });
125 }
126 accepted.push([cookieStart, cookieEnd]);
127 const cookie = page.subarray(cookieStart, cookieEnd);
128
129 const flags = cookie.readUInt32LE(8);
130 const urlOffset = cookie.readUInt32LE(16);
131 const nameOffset = cookie.readUInt32LE(20);
132 const pathOffset = cookie.readUInt32LE(24);
133 const valueOffset = cookie.readUInt32LE(28);
134 const expiry = cookie.readDoubleLE(40);
135
136 // Offsets are relative to the record; one pointing outside it would
137 // otherwise read a neighbouring cookie's bytes as this one's value.
138 if (
139 [urlOffset, nameOffset, pathOffset, valueOffset].some(
140 (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length,
141 )
142 ) {
143 throw new SafariCookieReadError({ reason: "readFailed" });
144 }
145 const domain = readCString(cookie, urlOffset);
146 const name = readCString(cookie, nameOffset);
147 const path = readCString(cookie, pathOffset);
148 const value = readCString(cookie, valueOffset);
149 if (domain === "" || name === "") continue;
150
151 const secure = (flags & FLAG_SECURE) !== 0;
152 const expirationDate =
153 expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
154
155 cookies.push({
156 // Safari marks domain cookies with a leading dot like the other
157 // engines, so the shared scope rule applies: host-only cookies keep
158 // `domain` undefined, or Electron widens them to every subdomain.
159 ...cookieScope(domain, path || "/", secure),
160 name,
161 value,
162 path: path || "/",
163 secure,
164 httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
165 expirationDate,
166 // Bits 3–5 of the flags carry something SameSite-shaped, but no public
167 // description of them agrees and real jars do not match any of them
168 // cleanly. Lax is the modern browser default; claiming "none" would
169 // widen every imported cookie's scope.
170 sameSite: "lax",
171 });
172 }
173 }
174
175 // Safari writes an 8-byte checksum after the pages, then an optional
176 // length-prefixed property list. Anything else past the declared pages —
177 // in particular whole extra pages — means the page table does not describe
178 // the file, and a jar the header lies about is refused rather than
179 // imported with cookies silently missing.
180 const trailer = buffer.length - pageStart;
181 // Legal shapes: nothing, the 8-byte checksum alone, or checksum + u32
182 // length + exactly that many property-list bytes.
183 const validTrailer =
184 trailer === 0 ||
185 trailer === 8 ||
186 (trailer >= 12 && trailer === 8 + 4 + buffer.readUInt32BE(pageStart + 8));
187 if (!validTrailer) {
188 throw new SafariCookieReadError({ reason: "readFailed" });
189 }
190
191 return cookies;
192}
193
194/**
195 * Whether a filesystem error is the OS refusing access.
196 *
197 * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than
198 * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked
199 * too — otherwise a Full Disk Access refusal is reported as a generic read
200 * failure and the user is never told what to grant.
201 */
202export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => {
203 // TCC denies with EPERM, which Effect tags `Unknown` rather than
204 // `PermissionDenied` — so the errno is what identifies it. EACCES (and the
205 // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or
206 // ACL failure that granting Full Disk Access cannot fix, so it stays a plain
207 // read failure rather than sending the user to a grant that won't help.
208 const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
209 return code === "EPERM";
210};
211
212/**
213 * Whether reading the jar is refused by TCC. `stat` succeeds on the jar
214 * inside Safari's container even without Full Disk Access — that is what lets
215 * the listing find it — so presence alone cannot tell granted from denied.
216 * Opening it for read is what TCC gates: EPERM means the grant is missing.
217 * Anything else (including a missing jar) is not a permission answer.
218 */
219export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) {
220 const fileSystem = yield* FileSystem.FileSystem;
221 return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe(
222 Effect.as(false),
223 Effect.catch((cause) => Effect.succeed(isPermissionDenied(cause))),
224 Effect.scoped,
225 );
226});
227
228export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
229 cookiePath: string,
230) {
231 const fileSystem = yield* FileSystem.FileSystem;
232 const contents = yield* fileSystem.readFile(cookiePath).pipe(
233 Effect.mapError((cause) => {
234 // TCC denies the read even though the file exists — a permission the user
235 // grants in System Settings rather than a missing browser. macOS never
236 // prompts for Full Disk Access, so there is no dialog to wait on; the
237 // read just fails, and it fails with EPERM, which Effect surfaces as an
238 // `Unknown` system error rather than `PermissionDenied` (that is EACCES).
239 return new SafariCookieReadError({
240 reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed",
241 cookieDatabasePath: cookiePath,
242 cause,
243 });
244 }),
245 );
246 // The parser throws on a malformed jar; catch it here so callers see a typed
247 // failure rather than a defect.
248 return yield* Effect.try({
249 try: () => parseBinaryCookies(Buffer.from(contents)),
250 catch: (cause) =>
251 isSafariCookieReadError(cause)
252 ? new SafariCookieReadError({
253 reason: cause.reason,
254 cookieDatabasePath: cookiePath,
255 cause,
256 })
257 : new SafariCookieReadError({
258 reason: "readFailed",
259 cookieDatabasePath: cookiePath,
260 cause,
261 }),
262 });
263});
apps/desktop/src/preview/BrowserImport/Sources.test.ts+103
......@@ -1093,3 +1093,106 @@ describe("listSourceProfiles hardening", () => {
10931093 ),
10941094 );
10951095});
1096
1097describe("Safari profiles", () => {
1098 const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!;
1099 const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD";
1100 const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD";
1101
1102 const fixture = Effect.fnUntraced(function* () {
1103 const context = yield* withSourceHome();
1104 const fileSystem = yield* FileSystem.FileSystem;
1105 const root = safari.userDataDirectory(context)!;
1106 const library = context.path.dirname(root);
1107 yield* fileSystem.makeDirectory(root, { recursive: true });
1108 yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default");
1109 const store = (uuid: string) =>
1110 context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies");
1111 for (const uuid of [workUuid, otherUuid]) {
1112 yield* fileSystem.makeDirectory(store(uuid), { recursive: true });
1113 yield* fileSystem.writeFileString(
1114 context.path.join(store(uuid), "Cookies.binarycookies"),
1115 uuid,
1116 );
1117 }
1118 yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true });
1119 const metadata = context.path.join(library, "Safari", "SafariTabs.db");
1120 return { context, root, store, metadata };
1121 });
1122
1123 it.effect("discovers named profiles and resolves only the selected profile's cookies", () =>
1124 run(
1125 Effect.gen(function* () {
1126 const { context, root, store, metadata } = yield* fixture();
1127 yield* Effect.sync(() => {
1128 const database = new NodeSqlite.DatabaseSync(metadata);
1129 try {
1130 database.exec(`CREATE TABLE bookmarks (
1131 title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0,
1132 type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2,
1133 deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0
1134 )`);
1135 const insert = database.prepare(
1136 "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)",
1137 );
1138 insert.run("", "DefaultProfile", 0);
1139 insert.run("Ping", workUuid, 0);
1140 insert.run("Deleted", otherUuid, 1);
1141 insert.run("Unsafe", "../../outside", 0);
1142 database.exec(
1143 "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)",
1144 );
1145 } finally {
1146 database.close();
1147 }
1148 });
1149 const profiles = yield* listSourceProfiles(safari, context);
1150 assert.deepEqual(profiles, [
1151 { directory: ".", name: "Personal" },
1152 { directory: store(workUuid), name: "Ping" },
1153 ]);
1154 assert.strictEqual(
1155 yield* resolveCookieDatabase(safari, context, "."),
1156 context.path.join(root, "Cookies.binarycookies"),
1157 );
1158 const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory);
1159 assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies"));
1160 const fileSystem = yield* FileSystem.FileSystem;
1161 assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid);
1162 yield* fileSystem.remove(selected!);
1163 assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory));
1164 assert.deepEqual(yield* listSourceProfiles(safari, context), profiles);
1165 }),
1166 ),
1167 );
1168
1169 for (const metadataState of ["missing", "corrupt"] as const) {
1170 it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () =>
1171 run(
1172 Effect.gen(function* () {
1173 const { context, store, metadata } = yield* fixture();
1174 const fileSystem = yield* FileSystem.FileSystem;
1175 if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid");
1176 yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies"));
1177 assert.deepEqual(yield* listSourceProfiles(safari, context), [
1178 { directory: ".", name: "Safari" },
1179 { directory: store(workUuid), name: workUuid.toLowerCase() },
1180 ]);
1181 assert.isTrue(yield* isSourceInstalled(safari, context));
1182 }),
1183 ),
1184 );
1185 }
1186
1187 it.effect("keeps Safari without profiles available", () =>
1188 run(
1189 Effect.gen(function* () {
1190 const context = yield* withSourceHome();
1191 assert.deepEqual(yield* listSourceProfiles(safari, context), [
1192 { directory: ".", name: "Safari" },
1193 ]);
1194 assert.isFalse(yield* isSourceInstalled(safari, context));
1195 }),
1196 ),
1197 );
1198});
apps/desktop/src/preview/BrowserImport/Sources.ts+93-3
......@@ -1,10 +1,11 @@
11/**
22 * Importable browser sources.
33 *
4 * Two engines are modelled. Chromium-family browsers keep cookies in an
4 * Chromium-family browsers keep cookies in an
55 * encrypted SQLite database whose key lives in an OS credential store; Firefox
66 * keeps them in plain SQLite with no key at all, so it needs no keychain and
7 * works the same on every platform.
7 * works the same on every platform. Safari uses binary cookie files, with
8 * separate WebKit data stores for named profiles.
89 *
910 * Each entry pins its own paths and credential-store coordinates rather than
1011 * deriving them, because the forks do not agree. macOS uses service/account
......@@ -31,7 +32,7 @@ import * as Stream from "effect/Stream";
3132import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3233import * as SqlClient from "effect/unstable/sql/SqlClient";
3334
34export type BrowserImportEngine = "chromium" | "firefox";
35export type BrowserImportEngine = "chromium" | "firefox" | "safari";
3536
3637/**
3738 * Directory roots a definition builds its paths from. Passed in rather than
......@@ -177,6 +178,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
177178 // Helium retains Chromium's libsecret application name on Linux.
178179 linuxSecretApplication: "chromium",
179180 }),
181 {
182 // The default jar lives here; named profiles use WebKit data stores
183 // alongside this directory inside the same app container.
184 id: "safari",
185 name: "Safari",
186 engine: "safari",
187 platforms: ["darwin"],
188 userDataDirectory: (context) =>
189 context.platform === "darwin"
190 ? context.path.join(
191 context.home,
192 "Library",
193 "Containers",
194 "com.apple.Safari",
195 "Data",
196 "Library",
197 "Cookies",
198 )
199 : undefined,
200 },
180201 {
181202 id: "firefox",
182203 name: "Firefox",
......@@ -220,6 +241,9 @@ export const cookieDatabaseCandidatePaths = (
220241 if (definition.engine === "firefox") {
221242 return [context.path.join(profilePath, "cookies.sqlite")];
222243 }
244 if (definition.engine === "safari") {
245 return [context.path.join(profilePath, "Cookies.binarycookies")];
246 }
223247 // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade
224248 // leaves the legacy file behind, so prefer the current one and fall back.
225249 return [
......@@ -386,6 +410,64 @@ const withCookieCounts = (
386410 ),
387411 );
388412
413const SafariProfileRows = Schema.Array(
414 Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }),
415);
416const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows);
417const isSafariProfileUuid = (value: string) =>
418 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
419
420const listSafariProfiles = Effect.fnUntraced(function* (
421 context: BrowserImportPathContext,
422 root: string,
423) {
424 const fileSystem = yield* FileSystem.FileSystem;
425 const library = context.path.dirname(root);
426 const metadata = context.path.join(library, "Safari", "SafariTabs.db");
427 const declared = yield* Effect.gen(function* () {
428 const sql = yield* SqlClient.SqlClient;
429 return yield* decodeSafariProfiles(
430 yield* sql`
431 select title, external_uuid from bookmarks
432 where parent = 0 and type = 1 and subtype = 2 and deleted = 0
433 order by order_index
434 `,
435 );
436 }).pipe(
437 Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })),
438 Effect.orElseSucceed(() => []),
439 );
440 const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile");
441 const profiles: Array<BrowserImportSourceProfile> = [
442 {
443 directory: ".",
444 name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari",
445 },
446 ];
447 const stores = context.path.join(library, "WebKit", "WebsiteDataStore");
448 const profileDirectory = (uuid: string) =>
449 context.path.join(stores, uuid.toLowerCase(), "Cookies");
450 for (const profile of declared) {
451 if (!isSafariProfileUuid(profile.external_uuid)) continue;
452 profiles.push({
453 directory: profileDirectory(profile.external_uuid),
454 name: profile.title?.trim() || profile.external_uuid,
455 });
456 }
457 // If Safari's metadata is unavailable, recover stores that have cookies.
458 // With readable metadata, avoid resurrecting deleted profiles left on disk.
459 if (declared.length === 0) {
460 const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => []));
461 for (const entry of entries.filter(isSafariProfileUuid).sort()) {
462 const directory = context.path.join(stores, entry, "Cookies");
463 if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) {
464 profiles.push({ directory, name: entry });
465 }
466 }
467 }
468 return profiles;
469});
470
389471/**
390472 * Profiles the source browser knows about.
391473 *
......@@ -403,6 +485,10 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* (
403485 const root = definition.userDataDirectory(context);
404486 if (root === undefined) return [];
405487
488 if (definition.engine === "safari") {
489 return yield* listSafariProfiles(context, root);
490 }
491
406492 if (definition.engine === "firefox") {
407493 const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
408494 Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),
......@@ -773,11 +859,15 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
773859 const root = definition.userDataDirectory(context);
774860 if (root === undefined) return false;
775861 // Probe the source's own lock state rather than scanning the process table.
862 // Safari keeps no lock and writes its jar atomically, so a running instance
863 // is not a hazard there.
864 //
776865 // Chromium exposes its lock through the cookie jar on Windows and through a
777866 // user-data SingletonLock on POSIX. Firefox keeps its locks inside each
778867 // profile under three names across platforms (`lock` on macOS and Linux,
779868 // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's
780869 // at the root finds nothing and reports a running browser as importable.
870 if (definition.engine === "safari") return false;
781871 if (definition.engine !== "firefox") {
782872 if (context.platform === "win32") {
783873 return yield* windowsChromiumCookiesAreHeld(definition, context);
apps/desktop/src/window/DesktopWindow.test.ts+2
......@@ -280,6 +280,7 @@ function makeTestLayer(input: {
280280 input.openedExternalUrls?.push(url);
281281 return true;
282282 }),
283 openSystemSettings: () => Effect.succeed(true),
283284 copyText: () => Effect.void,
284285 } satisfies ElectronShell.ElectronShell["Service"]),
285286 electronThemeLayer,
......@@ -380,6 +381,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
380381 electronMenuLayer,
381382 Layer.succeed(ElectronShell.ElectronShell, {
382383 openExternal: () => Effect.succeed(true),
384 openSystemSettings: () => Effect.succeed(true),
383385 copyText: () => Effect.void,
384386 } satisfies ElectronShell.ElectronShell["Service"]),
385387 electronThemeLayer,
apps/web/src/components/settings/BrowserImportWizard.tsx+81-7
......@@ -23,6 +23,7 @@ import {
2323 canCloseWizard,
2424 isRetryableReason,
2525 formatSkippedDomains,
26 fullDiskAccessRecheckStep,
2627 outcomeToStep,
2728 refreshedSourceProfileDirectory,
2829 refreshedSourceStep,
......@@ -55,6 +56,8 @@ interface BrowserImportWizardProps {
5556 }) => Promise<ImportOutcome>;
5657 /** Re-checks the source's availability after the user quits the browser. */
5758 readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
59 /** Opens the OS setting that grants access to a protected cookie store. */
60 readonly onOpenFullDiskAccessSettings: () => void;
5861 readonly onClose: () => void;
5962}
6063
......@@ -72,6 +75,7 @@ export function BrowserImportWizard({
7275 canCreateProfile,
7376 onImport,
7477 onRefreshSource,
78 onOpenFullDiskAccessSettings,
7579 onClose,
7680}: BrowserImportWizardProps) {
7781 const [source, setSource] = useState(initialSource);
......@@ -111,8 +115,13 @@ export function BrowserImportWizard({
111115 });
112116 };
113117
114 const recheckAfterQuit = () => {
115 setStep({ step: "checking" });
118 // Re-lists the source after the user did something outside the app (quit the
119 // browser, granted access) and routes to wherever the refreshed source says.
120 const recheckSource = (
121 check: "browser" | "fullDiskAccess",
122 nextStep: (refreshed: BrowserImportSource | undefined) => WizardStep,
123 ) => {
124 setStep({ step: "checking", check });
116125 void onRefreshSource()
117126 .then((refreshed) => {
118127 if (refreshed) {
......@@ -121,20 +130,30 @@ export function BrowserImportWizard({
121130 refreshedSourceProfileDirectory(current, refreshed),
122131 );
123132 }
124 setStep(refreshedSourceStep(refreshed));
133 setStep(nextStep(refreshed));
125134 })
126135 .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
127136 };
137 const recheckAfterQuit = () => recheckSource("browser", refreshedSourceStep);
138 const recheckFullDiskAccess = () => recheckSource("fullDiskAccess", fullDiskAccessRecheckStep);
128139
129140 return (
130141 <Dialog open onOpenChange={(open) => (open || !canCloseWizard(step) ? undefined : onClose())}>
131142 <DialogPopup className="max-w-lg" showCloseButton={canCloseWizard(step)}>
132143 {step.step === "quit" ? (
133144 <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
145 ) : step.step === "fullDiskAccess" ? (
146 <FullDiskAccessStep
147 source={source}
148 onCancel={onClose}
149 onOpenSettings={onOpenFullDiskAccessSettings}
150 onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess}
151 stillRequired={step.checked === true}
152 />
134153 ) : step.step === "importing" ? (
135154 <ImportingStep />
136155 ) : step.step === "checking" ? (
137 <CheckingStep sourceName={source.name} />
156 <CheckingStep sourceName={source.name} check={step.check} />
138157 ) : step.step === "done" ? (
139158 <DoneStep
140159 {...step}
......@@ -223,6 +242,49 @@ type ConfigureStepProps = {
223242 readonly onImport: () => void;
224243};
225244
245function FullDiskAccessStep({
246 source,
247 onCancel,
248 onOpenSettings,
249 onGranted,
250 stillRequired,
251}: {
252 readonly source: BrowserImportSource;
253 readonly onCancel: () => void;
254 readonly onOpenSettings: () => void;
255 readonly onGranted: () => void;
256 readonly stillRequired: boolean;
257}) {
258 return (
259 <>
260 <DialogHeader>
261 <DialogTitle>Let T3 Code read {source.name}&rsquo;s cookies</DialogTitle>
262 <DialogDescription>
263 To import cookies from {source.name}, T3 Code needs Full Disk Access. Turn it on in System
264 Settings, then come back to finish the import — you can revoke it again once the import is
265 done.
266 </DialogDescription>
267 </DialogHeader>
268 {stillRequired ? (
269 <DialogPanel>
270 <p role="status" className="text-sm text-muted-foreground">
271 Full Disk Access is still required. If you just turned it on, quit and reopen T3 Code,
272 then try again.
273 </p>
274 </DialogPanel>
275 ) : null}
276 <DialogFooter>
277 <Button variant="outline" onClick={onCancel}>
278 Cancel
279 </Button>
280 <Button variant="outline" onClick={onOpenSettings}>
281 Open System Settings
282 </Button>
283 <Button onClick={onGranted}>I&rsquo;ve turned it on</Button>
284 </DialogFooter>
285 </>
286 );
287}
226288function ConfigureStep({
227289 source,
228290 destinationEnvironmentName,
......@@ -382,16 +444,28 @@ function ImportingStep() {
382444 );
383445}
384446
385function CheckingStep({ sourceName }: { readonly sourceName: string }) {
447function CheckingStep({
448 sourceName,
449 check,
450}: {
451 readonly sourceName: string;
452 readonly check: "browser" | "fullDiskAccess";
453}) {
386454 return (
387455 <>
388456 <DialogHeader>
389457 <DialogTitle>Checking {sourceName}</DialogTitle>
390 <DialogDescription>Checking whether the browser has closed.</DialogDescription>
458 <DialogDescription>
459 {check === "fullDiskAccess"
460 ? "Checking Full Disk Access."
461 : "Checking whether the browser has closed."}
462 </DialogDescription>
391463 </DialogHeader>
392464 <DialogPanel className="flex items-center gap-3 py-6">
393465 <Spinner className="size-4 text-muted-foreground" />
394 <span className="text-sm text-muted-foreground">Checking…</span>
466 <span className="text-sm text-muted-foreground">
467 {check === "fullDiskAccess" ? "Checking access…" : "Checking…"}
468 </span>
395469 </DialogPanel>
396470 </>
397471 );
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+1
......@@ -85,6 +85,7 @@ const failure = (reason: string) => ({
8585
8686describe("importFailureReason", () => {
8787 it("recovers the reason token from the flattened message", () => {
88 expect(importFailureReason(failure("needsFullDiskAccess"))).toBe("needsFullDiskAccess");
8889 expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");
8990 expect(importFailureReason(failure("readFailed"))).toBe("readFailed");
9091 expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable");
apps/web/src/components/settings/IntegrationsSettings.tsx+15
......@@ -55,6 +55,8 @@ import {
5555 MenuSeparator,
5656 MenuTrigger,
5757} from "../ui/menu";
58import { readLocalApi } from "~/localApi";
59
5860import { toastManager } from "../ui/toast";
5961import {
6062 AlertDialog,
......@@ -1168,6 +1170,19 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
11681170 runWizardImport(importSession.source, importSession.environmentId, input)
11691171 }
11701172 onRefreshSource={() => refreshImportSource(importSession.source.id)}
1173 onOpenFullDiskAccessSettings={() => {
1174 // Rejects outside the desktop shell (and on shells that predate the
1175 // method), so the one toast covers every way the link can fail.
1176 void readLocalApi()
1177 ?.shell.openSystemSettings("full-disk-access")
1178 .catch(() => {
1179 toastManager.add({
1180 type: "error",
1181 title: "Could not open System Settings",
1182 description: "Open Privacy & Security → Full Disk Access manually.",
1183 });
1184 });
1185 }}
11711186 onClose={() => setImportSession(null)}
11721187 />
11731188 ) : null}
apps/web/src/components/settings/browserImportWizard.logic.test.ts+32
......@@ -7,6 +7,7 @@ import {
77 initialTargetSelection,
88 isRetryableReason,
99 formatSkippedDomains,
10 fullDiskAccessRecheckStep,
1011 outcomeToStep,
1112 refreshedSourceProfileDirectory,
1213 refreshedSourceStep,
......@@ -51,6 +52,15 @@ describe("initialWizardStep", () => {
5152 expect(initialWizardStep(source())).toEqual({ step: "configure" });
5253 });
5354
55 it("asks for Full Disk Access before choosing an import target", () => {
56 expect(initialWizardStep(source({ profiles: [], unavailable: "needsFullDiskAccess" }))).toEqual(
57 {
58 step: "fullDiskAccess",
59 resume: "configure",
60 },
61 );
62 });
63
5464 it("blocks on a reason nothing local can fix", () => {
5565 expect(initialWizardStep(source({ unavailable: "unsupportedPlatform" }))).toEqual({
5666 step: "blocked",
......@@ -103,6 +113,14 @@ describe("outcomeToStep", () => {
103113 expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" });
104114 });
105115
116 it("routes a Full Disk Access refusal to its own screen", () => {
117 expect(outcomeToStep({ kind: "blocked", reason: "needsFullDiskAccess" })).toEqual({
118 step: "fullDiskAccess",
119 resume: "import",
120 checked: true,
121 });
122 });
123
106124 it("surfaces every other failure on the blocked screen", () => {
107125 expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({
108126 step: "blocked",
......@@ -134,6 +152,20 @@ describe("refreshedSourceStep", () => {
134152 });
135153});
136154
155describe("fullDiskAccessRecheckStep", () => {
156 it("marks a still-denied access check for visible feedback", () => {
157 expect(fullDiskAccessRecheckStep(source({ unavailable: "needsFullDiskAccess" }))).toEqual({
158 step: "fullDiskAccess",
159 resume: "configure",
160 checked: true,
161 });
162 });
163
164 it("moves on once access reveals the source profiles", () => {
165 expect(fullDiskAccessRecheckStep(source())).toEqual({ step: "configure" });
166 });
167});
168
137169describe("refreshedSourceProfileDirectory", () => {
138170 const refreshed = source({
139171 profiles: [
apps/web/src/components/settings/browserImportWizard.logic.ts+20-1
......@@ -58,8 +58,13 @@ export type ImportOutcome =
5858 */
5959export type WizardStep =
6060 | { readonly step: "quit" }
61 | {
62 readonly step: "fullDiskAccess";
63 readonly resume: "configure" | "import";
64 readonly checked?: boolean;
65 }
6166 | { readonly step: "configure" }
62 | { readonly step: "checking" }
67 | { readonly step: "checking"; readonly check: "browser" | "fullDiskAccess" }
6368 | { readonly step: "importing" }
6469 | {
6570 readonly step: "done";
......@@ -82,6 +87,9 @@ export function canCloseWizard(step: WizardStep): boolean {
8287 */
8388export function initialWizardStep(source: BrowserImportSource): WizardStep {
8489 if (source.unavailable === "browserRunning") return { step: "quit" };
90 if (source.unavailable === "needsFullDiskAccess") {
91 return { step: "fullDiskAccess", resume: "configure" };
92 }
8593 if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable };
8694 if (source.profiles.length === 0) return { step: "blocked", reason: "unknownSourceProfile" };
8795 return { step: "configure" };
......@@ -102,6 +110,11 @@ export function outcomeToStep(outcome: ImportOutcome): WizardStep {
102110 // other failure surfaces on the blocked screen, which offers a retry when
103111 // one could help.
104112 if (outcome.reason === "browserRunning") return { step: "quit" };
113 if (outcome.reason === "needsFullDiskAccess") {
114 // The failed import already checked access, so explain that it is still
115 // denied instead of returning to an indistinguishable permission screen.
116 return { step: "fullDiskAccess", resume: "import", checked: true };
117 }
105118 return { step: "blocked", reason: outcome.reason };
106119}
107120
......@@ -111,6 +124,12 @@ export function refreshedSourceStep(source: BrowserImportSource | undefined): Wi
111124 return initialWizardStep(source);
112125}
113126
127/** A denied FDA recheck returns to the permission step with visible feedback. */
128export function fullDiskAccessRecheckStep(source: BrowserImportSource | undefined): WizardStep {
129 const next = refreshedSourceStep(source);
130 return next.step === "fullDiskAccess" ? { ...next, checked: true } : next;
131}
132
114133/** Preserve the chosen source profile when a post-quit refresh still lists it. */
115134export function refreshedSourceProfileDirectory(
116135 currentDirectory: string,
apps/web/src/localApi.test.ts+8
......@@ -113,6 +113,14 @@ describe("LocalApi", () => {
113113 await expect(createLocalApi().dialogs.confirm("Delete this thread?")).resolves.toBe(false);
114114 });
115115
116 it("rejects opening System Settings when the desktop bridge is unavailable", async () => {
117 const { createLocalApi } = await import("./localApi");
118
119 await expect(createLocalApi().shell.openSystemSettings("full-disk-access")).rejects.toThrow(
120 "Unable to open System Settings.",
121 );
122 });
123
116124 it("delegates host capabilities and persistence to the desktop bridge", async () => {
117125 const showContextMenu = vi.fn().mockResolvedValue("delete");
118126 const pickFolder = vi.fn().mockResolvedValue("/tmp/project");
apps/web/src/localApi.ts+11
......@@ -29,6 +29,17 @@ function createBrowserLocalApi(): LocalApi {
2929
3030 window.open(url, "_blank", "noopener,noreferrer");
3131 },
32 // Only the desktop shell can reach the OS; the web build (and older
33 // desktop shells that predate this method) have nothing to open.
34 openSystemSettings: async (pane) => {
35 if (!window.desktopBridge?.openSystemSettings) {
36 throw new Error("Unable to open System Settings.");
37 }
38 const opened = await window.desktopBridge.openSystemSettings(pane);
39 if (!opened) {
40 throw new Error("Unable to open System Settings.");
41 }
42 },
3243 },
3344 contextMenu: {
3445 show: async <T extends string>(
docs/user/browser-import.md+6
......@@ -10,6 +10,12 @@ unlock prompt if one appears.
1010This is a one-time copy. Later login changes stay separate between the two browsers, and some
1111sites may still require you to sign in again.
1212
13On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than
14a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the
15right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can
16revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies
17kept by additional Safari profiles are not.
18
1319On Windows, import supports Firefox and Helium profiles that use standard profile encryption.
1420Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies
1521are skipped on all platforms.
packages/contracts/src/browserImport.ts+4
......@@ -25,6 +25,7 @@ export const BROWSER_IMPORT_SOURCE_IDS = [
2525 "arc",
2626 "helium",
2727 "firefox",
28 "safari",
2829] as const;
2930
3031export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS);
......@@ -42,6 +43,7 @@ export const BrowserImportUnavailableReason = Schema.Literals([
4243 "notInstalled",
4344 "needsKeychainApproval",
4445 "keychainItemMissing",
46 "needsFullDiskAccess",
4547 "browserRunning",
4648 "unsupportedPlatform",
4749]);
......@@ -144,6 +146,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly<
144146 needsKeychainApproval: "Needs Keychain access to read its cookies.",
145147 keychainItemMissing:
146148 "No encryption key in your Keychain — sign in to that browser once, then retry.",
149 needsFullDiskAccess:
150 "Give T3 Code Full Disk Access in System Settings → Privacy & Security, then retry.",
147151 browserRunning: "Quit the browser first so its cookie database can be read.",
148152 unsupportedPlatform: "Importing from this browser isn't possible on this platform.",
149153};
packages/contracts/src/ipc.ts+14
......@@ -1052,6 +1052,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
10521052 input: PreviewAutomationWaitForInput,
10531053});
10541054
1055/**
1056 * A System Settings pane the app can deep-link to. The identifier crosses IPC
1057 * rather than a URL, so the renderer can only reach these known destinations.
1058 */
1059export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]);
1060export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;
1061
10551062export interface DesktopBridge {
10561063 getAppBranding: () => DesktopAppBranding | null;
10571064 /** The desktop client's OS platform, read from Electron's preload process. */
......@@ -1119,6 +1126,11 @@ export interface DesktopBridge {
11191126 position?: { x: number; y: number },
11201127 ) => Promise<T | null>;
11211128 openExternal: (url: string) => Promise<boolean>;
1129 /**
1130 * Open a System Settings pane by identifier. Optional: older desktop builds
1131 * lack it, and callers no-op when it is missing.
1132 */
1133 openSystemSettings?: (pane: SystemSettingsPane) => Promise<boolean>;
11221134 /**
11231135 * Probe this desktop machine for installed remote-capable editor CLIs
11241136 * (used for remote open-in-editor deep links). Optional: older desktop
......@@ -1267,6 +1279,8 @@ export interface LocalApi {
12671279 };
12681280 shell: {
12691281 openExternal: (url: string) => Promise<void>;
1282 /** Opens a known System Settings pane; no-ops outside the desktop app. */
1283 openSystemSettings: (pane: SystemSettingsPane) => Promise<void>;
12701284 };
12711285 contextMenu: {
12721286 show: <T extends string>(