History

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

Julius Marminge committed 2 months ago

Commit ed2bdbb1 parent caf498122 files · +1189 −27Browse files

Unified · Split

22 files
  1. apps/desktop/src/electron/ElectronShell.test.ts +14 −0
  2. apps/desktop/src/electron/ElectronShell.ts +28 −1
  3. apps/desktop/src/ipc/DesktopIpcHandlers.ts +2 −0
  4. apps/desktop/src/ipc/channels.ts +1 −0
  5. apps/desktop/src/ipc/methods/window.ts +11 −0
  6. apps/desktop/src/preload.ts +2 −0
  7. apps/desktop/src/preview/BrowserImport/BrowserImport.ts +35 −15
  8. apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +443 −0
  9. apps/desktop/src/preview/BrowserImport/SafariCookies.ts +263 −0
  10. apps/desktop/src/preview/BrowserImport/Sources.test.ts +103 −0
  11. apps/desktop/src/preview/BrowserImport/Sources.ts +93 −3
  12. apps/desktop/src/window/DesktopWindow.test.ts +2 −0
  13. apps/web/src/components/settings/BrowserImportWizard.tsx +81 −7
  14. apps/web/src/components/settings/IntegrationsSettings.logic.test.ts +1 −0
  15. apps/web/src/components/settings/IntegrationsSettings.tsx +15 −0
  16. apps/web/src/components/settings/browserImportWizard.logic.test.ts +32 −0
  17. apps/web/src/components/settings/browserImportWizard.logic.ts +20 −1
  18. apps/web/src/localApi.test.ts +8 −0
  19. apps/web/src/localApi.ts +11 −0
  20. docs/user/browser-import.md +6 −0
  21. packages/contracts/src/browserImport.ts +4 −0
  22. packages/contracts/src/ipc.ts +14 −0
apps/desktop/src/electron/ElectronShell.test.ts+14−0
@@ -36,6 +36,20 @@ describe("ElectronShell", () => { · 35 unmodified lines
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 @@
1−import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
1+import {
2+ REMOTE_CAPABLE_EDITOR_IDS,
3+ remoteSchemeForEditor,
4+ type SystemSettingsPane,
5+} from "@t3tools/contracts";
26 import * as Context from "effect/Context";
37 import * as Effect from "effect/Effect";
48 import * as Layer from "effect/Layer";
@@ -6,6 +10,20 @@ import * as Option from "effect/Option"; · 1 unmodified line
610
711 import * 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+ */
22+const 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.
1129 const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
@@ -43,6 +61,8 @@ export class ElectronShell extends Context.Service< · 31 unmodified lines
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({ · 10 unmodified lines
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−0
@@ -38,6 +38,7 @@ import { · 37 unmodified lines
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" · 50 unmodified lines
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−0
@@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; · 3 unmodified lines
44 export const SET_THEME_CHANNEL = "desktop:set-theme";
55 export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
66 export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
7+export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
78 export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
89 export const MENU_ACTION_CHANNEL = "desktop:menu-action";
910 export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
apps/desktop/src/ipc/methods/window.ts+11−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
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({ · 283 unmodified lines
298299 }),
299300 });
300301
302+export 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+
301312 export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
302313 channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
303314 payload: Schema.Undefined,
apps/desktop/src/preload.ts+2−0
@@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 115 unmodified lines
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"; · 26 unmodified lines
2727 import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
2828 import type { CookieReadResult } from "./CookieDatabase.ts";
2929 import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
30+import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts";
3031 import {
3132 BROWSER_IMPORT_SOURCES,
3233 resolveCookieDatabase,
@@ -92,6 +93,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* · 59 unmodified lines
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() { · 156 unmodified lines
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() { · 10 unmodified lines
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+443−0
@@ -0,0 +1,443 @@
1+// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar
2+// format byte by byte.
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import { assert, describe, expect, it } from "@effect/vitest";
5+import * as Effect from "effect/Effect";
6+import * as FileSystem from "effect/FileSystem";
7+import * as PlatformError from "effect/PlatformError";
8+
9+import {
10+ isPermissionDenied,
11+ parseBinaryCookies,
12+ readSafariCookies,
13+ safariAccessDenied,
14+ SafariCookieReadError,
15+} from "./SafariCookies.ts";
16+
17+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
18+
19+interface 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. */
30+function 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. */
61+function 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+
84+describe("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+
317+describe("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+
385+describe("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+
421+describe("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+263−0
@@ -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+ */
22+import * as Effect from "effect/Effect";
23+import * as FileSystem from "effect/FileSystem";
24+import * as PlatformError from "effect/PlatformError";
25+import * as Schema from "effect/Schema";
26+
27+import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts";
28+
29+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
30+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
31+
32+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
33+const COOKIE_PAGE_HEADER_SIZE = 12;
34+/** Through the `f64 creation` field; string bytes follow. */
35+const COOKIE_RECORD_HEADER_SIZE = 56;
36+
37+const FLAG_SECURE = 0x1;
38+const FLAG_HTTP_ONLY = 0x4;
39+
40+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
41+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
42+
43+export 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+
63+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
64+
65+/** Reads a NUL-terminated ASCII string at an offset. */
66+function 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+
71+export 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+ */
202+export 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+ */
219+export 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+
228+export 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−0
@@ -1093,3 +1093,106 @@ describe("listSourceProfiles hardening", () => { · 1092 unmodified lines
10931093 ),
10941094 );
10951095 });
1096+
1097+describe("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"; · 20 unmodified lines
3132 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3233 import * as SqlClient from "effect/unstable/sql/SqlClient";
3334
34−export type BrowserImportEngine = "chromium" | "firefox";
35+export 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 · 139 unmodified lines
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 = ( · 37 unmodified lines
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 = ( · 160 unmodified lines
386410 ),
387411 );
388412
413+const SafariProfileRows = Schema.Array(
414+ Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }),
415+);
416+const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows);
417+const 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+
420+const 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* ( · 11 unmodified lines
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") · 364 unmodified lines
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−0
@@ -280,6 +280,7 @@ function makeTestLayer(input: { · 279 unmodified lines
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 · 94 unmodified lines
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 { · 22 unmodified lines
2323 canCloseWizard,
2424 isRetryableReason,
2525 formatSkippedDomains,
26+ fullDiskAccessRecheckStep,
2627 outcomeToStep,
2728 refreshedSourceProfileDirectory,
2829 refreshedSourceStep,
@@ -55,6 +56,8 @@ interface BrowserImportWizardProps { · 26 unmodified lines
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({ · 11 unmodified lines
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({ · 33 unmodified lines
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({ · 2 unmodified lines
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 = { · 82 unmodified lines
223242 readonly onImport: () => void;
224243 };
225244
245+function 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+}
226288 function ConfigureStep({
227289 source,
228290 destinationEnvironmentName,
@@ -382,16 +444,28 @@ function ImportingStep() { · 153 unmodified lines
382444 );
383445 }
384446
385−function CheckingStep({ sourceName }: { readonly sourceName: string }) {
447+function 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−0
@@ -85,6 +85,7 @@ const failure = (reason: string) => ({ · 84 unmodified lines
8585
8686 describe("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−0
@@ -55,6 +55,8 @@ import { · 54 unmodified lines
5555 MenuSeparator,
5656 MenuTrigger,
5757 } from "../ui/menu";
58+import { readLocalApi } from "~/localApi";
59+
5860 import { toastManager } from "../ui/toast";
5961 import {
6062 AlertDialog,
@@ -1168,6 +1170,19 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { · 1107 unmodified lines
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−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 initialTargetSelection,
88 isRetryableReason,
99 formatSkippedDomains,
10+ fullDiskAccessRecheckStep,
1011 outcomeToStep,
1112 refreshedSourceProfileDirectory,
1213 refreshedSourceStep,
@@ -51,6 +52,15 @@ describe("initialWizardStep", () => { · 38 unmodified lines
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", () => { · 46 unmodified lines
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", () => { · 25 unmodified lines
134152 });
135153 });
136154
155+describe("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+
137169 describe("refreshedSourceProfileDirectory", () => {
138170 const refreshed = source({
139171 profiles: [
apps/web/src/components/settings/browserImportWizard.logic.ts+20−1
@@ -58,8 +58,13 @@ export type ImportOutcome = · 57 unmodified lines
5858 */
5959 export 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 { · 16 unmodified lines
8287 */
8388 export 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 { · 14 unmodified lines
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 · 3 unmodified lines
111124 return initialWizardStep(source);
112125 }
113126
127+/** A denied FDA recheck returns to the permission step with visible feedback. */
128+export 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. */
115134 export function refreshedSourceProfileDirectory(
116135 currentDirectory: string,
apps/web/src/localApi.test.ts+8−0
@@ -113,6 +113,14 @@ describe("LocalApi", () => { · 112 unmodified lines
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−0
@@ -29,6 +29,17 @@ function createBrowserLocalApi(): LocalApi { · 28 unmodified lines
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−0
@@ -10,6 +10,12 @@ unlock prompt if one appears. · 9 unmodified lines
1010 This is a one-time copy. Later login changes stay separate between the two browsers, and some
1111 sites may still require you to sign in again.
1212
13+On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than
14+a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the
15+right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can
16+revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies
17+kept by additional Safari profiles are not.
18+
1319 On Windows, import supports Firefox and Helium profiles that use standard profile encryption.
1420 Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies
1521 are skipped on all platforms.
packages/contracts/src/browserImport.ts+4−0
@@ -25,6 +25,7 @@ export const BROWSER_IMPORT_SOURCE_IDS = [ · 24 unmodified lines
2525 "arc",
2626 "helium",
2727 "firefox",
28+ "safari",
2829 ] as const;
2930
3031 export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS);
@@ -42,6 +43,7 @@ export const BrowserImportUnavailableReason = Schema.Literals([ · 11 unmodified lines
4243 "notInstalled",
4344 "needsKeychainApproval",
4445 "keychainItemMissing",
46+ "needsFullDiskAccess",
4547 "browserRunning",
4648 "unsupportedPlatform",
4749 ]);
@@ -144,6 +146,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< · 96 unmodified lines
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−0
@@ -1052,6 +1052,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ · 1051 unmodified lines
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+ */
1059+export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]);
1060+export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type;
1061+
10551062 export 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 { · 61 unmodified lines
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 { · 142 unmodified lines
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>(