# Files changed · PR \#7262 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/7262/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(desktop\): import cookies from Safari](https://git.cafe/pingdotgg/t3code/pull/7262.md)

Version: 61

Head commit: c6cb164bc007a02da724bb446fa2d1efceb1b8b5

Base commit: 5e1c23d769b182018f5bd6d3623a7edec13d26bc

Comparison base: 764946502390845919d974490bc85e87b788c996

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/desktop/src/electron/ElectronShell.test.ts

Status: modified; +14; −0

```diff
@@ -35,7 +35,21 @@
       assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]);
     }).pipe(Effect.provide(ElectronShell.layer)),
   );
+
+  it.effect("opens the Full Disk Access settings anchor", () =>
+    Effect.gen(function* () {
+      openExternalMock.mockResolvedValue(undefined);
+
+      const electronShell = yield* ElectronShell.ElectronShell;
+      const result = yield* electronShell.openSystemSettings("full-disk-access");
 
+      assert.equal(result, true);
+      assert.deepEqual(openExternalMock.mock.calls, [
+        ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"],
+      ]);
+    }).pipe(Effect.provide(ElectronShell.layer)),
+  );
+
   it.effect("opens remote SSH editor URLs", () =>
     Effect.gen(function* () {
       openExternalMock.mockResolvedValue(undefined);
```

## apps/desktop/src/electron/ElectronShell.ts

Status: modified; +28; −1

```diff
@@ -1,4 +1,8 @@
-import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
+import {
+  REMOTE_CAPABLE_EDITOR_IDS,
+  remoteSchemeForEditor,
+  type SystemSettingsPane,
+} from "@t3tools/contracts";
 import * as Context from "effect/Context";
 import * as Effect from "effect/Effect";
 import * as Layer from "effect/Layer";
@@ -6,6 +10,20 @@
 
 import * as Electron from "electron";
 
+/**
+ * Deep links to individual System Settings panes. These are app-fixed, not
+ * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep
+ * arbitrary link schemes from reaching the OS handler — and open through their
+ * own path below. The pane rather than the URL crosses the IPC boundary, so a
+ * renderer can only ask for one of these known destinations.
+ *
+ * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor.
+ */
+const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
+  "full-disk-access":
+    "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
+};
+
 // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
 // must reach the OS handler; every other non-web scheme stays blocked.
 const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
@@ -43,6 +61,8 @@
   ElectronShell,
   {
     readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
+    /** Opens a known System Settings pane by identifier, not by URL. */
+    readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect<boolean>;
     readonly copyText: (text: string) => Effect.Effect<void>;
   }
 >()("@t3tools/desktop/electron/ElectronShell") {}
@@ -59,6 +79,13 @@
           ),
         ),
     }),
+  openSystemSettings: (pane) =>
+    Effect.promise(() =>
+      Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then(
+        () => true,
+        () => false,
+      ),
+    ),
   copyText: (text) =>
     Effect.sync(() => {
       Electron.clipboard.writeText(text);
```

## apps/desktop/src/ipc/DesktopIpcHandlers.ts

Status: modified; +2; −0

```diff
@@ -38,6 +38,7 @@
   getSystemLocale,
   getWindowFullscreenState,
   openExternal,
+  openSystemSettings,
   probeRemoteEditors,
   pickFolder,
   pickProjectFavicon,
@@ -94,6 +95,7 @@
   yield* ipc.handle(setTheme);
   yield* ipc.handle(showContextMenu);
   yield* ipc.handle(openExternal);
+  yield* ipc.handle(openSystemSettings);
   yield* ipc.handle(probeRemoteEditors);
   yield* ipc.handle(getUpdateState);
   yield* ipc.handle(setUpdateChannel);
```

## apps/desktop/src/ipc/channels.ts

Status: modified; +1; −0

```diff
@@ -4,6 +4,7 @@
 export const SET_THEME_CHANNEL = "desktop:set-theme";
 export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
 export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
+export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
 export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
 export const MENU_ACTION_CHANNEL = "desktop:menu-action";
 export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
```

## apps/desktop/src/ipc/methods/window.ts

Status: modified; +11; −0

```diff
@@ -9,6 +9,7 @@
   PickFolderOptionsSchema,
   PRIMARY_LOCAL_ENVIRONMENT_ID,
   REMOTE_CAPABLE_EDITOR_IDS,
+  SystemSettingsPaneSchema,
   type DesktopEnvironmentBootstrap,
   type PickedThemeFile,
 } from "@t3tools/contracts";
@@ -298,6 +299,16 @@
   }),
 });
 
+export const openSystemSettings = DesktopIpc.makeIpcMethod({
+  channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL,
+  payload: SystemSettingsPaneSchema,
+  result: Schema.Boolean,
+  handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) {
+    const shell = yield* ElectronShell.ElectronShell;
+    return yield* shell.openSystemSettings(pane);
+  }),
+});
+
 export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
   channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
   payload: Schema.Undefined,
```

## apps/desktop/src/preload.ts

Status: modified; +2; −0

```diff
@@ -116,6 +116,8 @@
       ...(position === undefined ? {} : { position }),
     }),
   openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
+  openSystemSettings: (pane: string) =>
+    ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
   probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
   onMenuAction: (listener) => {
     const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
```

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

Status: modified; +35; −15

```diff
@@ -27,6 +27,7 @@
 import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
 import type { CookieReadResult } from "./CookieDatabase.ts";
 import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
+import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts";
 import {
   BROWSER_IMPORT_SOURCES,
   resolveCookieDatabase,
@@ -92,6 +93,15 @@
   if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
   if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
   if (yield* isSourceRunning(definition, context)) return "browserRunning";
+  // Safari's jar is found by `stat`, which TCC permits without Full Disk
+  // Access — so a Safari that lists as ready may still refuse the read. Probe
+  // the grant here, so the wizard can open on the permission step and a
+  // post-grant recheck can tell granted from still-denied, rather than only
+  // discovering it by attempting the import.
+  if (definition.engine === "safari") {
+    const jar = yield* resolveCookieDatabase(definition, context, ".");
+    if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess";
+  }
   return undefined;
 });
 
@@ -254,25 +264,29 @@
     const userDataDirectory = definition.userDataDirectory(pathContext);
     const read: Effect.Effect<
       CookieReadResult,
-      ChromiumCookieReadError | FirefoxCookieReadError,
+      ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError,
       FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
     > =
+      definition.engine === "safari"
-      definition.engine === "firefox"
+        ? readSafariCookies(databasePath).pipe(
-        ? readFirefoxCookies(databasePath).pipe(
             Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
           )
-        : readChromiumCookies({
-            cookieDatabasePath: databasePath,
-            keychainService: definition.keychainService,
-            keychainAccount: definition.keychainAccount,
-            linuxSecretApplication: definition.linuxSecretApplication,
-            ...(platform === "win32" && userDataDirectory !== undefined
-              ? {
-                  windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
-                }
+        : definition.engine === "firefox"
+          ? readFirefoxCookies(databasePath).pipe(
+              Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
+            )
+          : readChromiumCookies({
+              cookieDatabasePath: databasePath,
+              keychainService: definition.keychainService,
+              keychainAccount: definition.keychainAccount,
+              linuxSecretApplication: definition.linuxSecretApplication,
+              ...(platform === "win32" && userDataDirectory !== undefined
+                ? {
+                    windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
+                  }
-              : {}),
-            platform,
-          });
+                : {}),
+              platform,
+            });
 
     const result = yield* read.pipe(
       Effect.scoped,
@@ -289,6 +303,12 @@
           Effect.fail(
             new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }),
           ),
+        // Safari's reasons are already user-facing: a TCC refusal is the Full
+        // Disk Access prompt, anything else is a read failure.
+        SafariCookieReadError: (cause) =>
+          Effect.fail(
+            new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
+          ),
       }),
     );
 
```

## apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts

Status: added; +443; −0

```diff
@@ -1,0 +1,443 @@
+// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar
+// format byte by byte.
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+
+import {
+  isPermissionDenied,
+  parseBinaryCookies,
+  readSafariCookies,
+  safariAccessDenied,
+  SafariCookieReadError,
+} from "./SafariCookies.ts";
+
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+interface FixtureCookie {
+  readonly domain: string;
+  readonly name: string;
+  readonly path: string;
+  readonly value: string;
+  readonly flags: number;
+  /** Seconds since 2001-01-01, as Safari stores them. */
+  readonly expiry: number;
+}
+
+/** Encodes one cookie exactly as Safari lays it out. */
+function encodeCookie(cookie: FixtureCookie): Buffer {
+  const strings = [cookie.domain, cookie.name, cookie.path, cookie.value];
+  const headerSize = 56;
+  const offsets: number[] = [];
+  let cursor = headerSize;
+  for (const value of strings) {
+    offsets.push(cursor);
+    cursor += Buffer.byteLength(value) + 1;
+  }
+  const size = cursor;
+
+  const buffer = Buffer.alloc(size);
+  buffer.writeUInt32LE(size, 0);
+  buffer.writeUInt32LE(0, 4);
+  buffer.writeUInt32LE(cookie.flags, 8);
+  buffer.writeUInt32LE(0, 12);
+  buffer.writeUInt32LE(offsets[0]!, 16);
+  buffer.writeUInt32LE(offsets[1]!, 20);
+  buffer.writeUInt32LE(offsets[2]!, 24);
+  buffer.writeUInt32LE(offsets[3]!, 28);
+  buffer.writeUInt32LE(0, 32);
+  buffer.writeUInt32LE(0, 36);
+  buffer.writeDoubleLE(cookie.expiry, 40);
+  buffer.writeDoubleLE(0, 48);
+  strings.forEach((value, index) => {
+    buffer.write(value, offsets[index]!, "utf8");
+  });
+  return buffer;
+}
+
+/** Builds a single-page `Cookies.binarycookies` file. */
+function encodeBinaryCookies(cookies: ReadonlyArray<FixtureCookie>): Buffer {
+  const encoded = cookies.map(encodeCookie);
+  const headerSize = 12 + encoded.length * 4;
+  const offsets: number[] = [];
+  let cursor = headerSize;
+  for (const cookie of encoded) {
+    offsets.push(cursor);
+    cursor += cookie.length;
+  }
+
+  const page = Buffer.alloc(cursor);
+  page.writeUInt32BE(0x0000_0100, 0);
+  page.writeUInt32LE(encoded.length, 4);
+  offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4));
+  encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!));
+
+  const header = Buffer.alloc(8 + 4);
+  header.write("cook", 0, "latin1");
+  header.writeUInt32BE(1, 4);
+  header.writeUInt32BE(page.length, 8);
+  return Buffer.concat([header, page]);
+}
+
+describe("parseBinaryCookies", () => {
+  it("reads Safari's format and rebases its 2001 epoch", () => {
+    const file = encodeBinaryCookies([
+      {
+        domain: ".apple.com",
+        name: "session",
+        path: "/",
+        value: "abc",
+        // secure | httpOnly
+        flags: 0x1 | 0x4,
+        expiry: 800_000_000,
+      },
+      {
+        domain: "example.test",
+        name: "plain",
+        path: "/app",
+        value: "v",
+        flags: 0,
+        expiry: 0,
+      },
+    ]);
+
+    expect(parseBinaryCookies(file)).toEqual([
+      {
+        url: "https://apple.com/",
+        name: "session",
+        value: "abc",
+        domain: ".apple.com",
+        path: "/",
+        secure: true,
+        httpOnly: true,
+        // Safari counts from 2001-01-01, Electron from 1970.
+        expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS,
+        // The format predates SameSite; Lax is the safe modern default.
+        sameSite: "lax",
+      },
+      {
+        url: "http://example.test/app",
+        name: "plain",
+        value: "v",
+        // Host-only: no leading dot in the jar, so no `domain` for Electron,
+        // which would otherwise re-add the dot and widen it to subdomains.
+        domain: undefined,
+        path: "/app",
+        secure: false,
+        httpOnly: false,
+        expirationDate: undefined,
+        sameSite: "lax",
+      },
+    ]);
+  });
+
+  it("keeps __Host- cookies host-only so Electron accepts them", () => {
+    const file = encodeBinaryCookies([
+      { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 },
+    ]);
+
+    expect(parseBinaryCookies(file)[0]).toMatchObject({
+      url: "https://example.test/",
+      name: "__Host-id",
+      domain: undefined,
+    });
+  });
+
+  it("brackets IPv6 hosts in the cookie URL", () => {
+    const file = encodeBinaryCookies([
+      { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 },
+    ]);
+
+    expect(parseBinaryCookies(file)[0]).toMatchObject({
+      url: "http://[::1]/",
+      domain: undefined,
+    });
+  });
+
+  it("reads cookies spread across multiple pages", () => {
+    // Safari pages its cookie file, and a single-page reader would silently
+    // return only the first slice.
+    const first = encodeBinaryCookies([
+      { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
+    ]);
+    const second = encodeBinaryCookies([
+      { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
+    ]);
+    // Splice the two single-page files into one two-page file.
+    const firstPage = first.subarray(12);
+    const secondPage = second.subarray(12);
+    const header = Buffer.alloc(16);
+    header.write("cook", 0, "latin1");
+    header.writeUInt32BE(2, 4);
+    header.writeUInt32BE(firstPage.length, 8);
+    header.writeUInt32BE(secondPage.length, 12);
+
+    const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]));
+
+    expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]);
+  });
+
+  it("rejects a page that runs past the end of the file", () => {
+    // `Buffer.subarray` clamps rather than throwing, so an overlong first page
+    // swallows the second one's bytes and advances the cursor past the end.
+    // Every cookie after the boundary then vanishes from a "successful" import.
+    const first = encodeBinaryCookies([
+      { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
+    ]);
+    const second = encodeBinaryCookies([
+      { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
+    ]);
+    const firstPage = first.subarray(12);
+    const secondPage = second.subarray(12);
+    const header = Buffer.alloc(16);
+    header.write("cook", 0, "latin1");
+    header.writeUInt32BE(2, 4);
+    // Declares more bytes for page one than the file holds in total.
+    header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8);
+    header.writeUInt32BE(secondPage.length, 12);
+
+    expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow(
+      SafariCookieReadError,
+    );
+  });
+
+  it("rejects a record whose declared size runs past its page", () => {
+    const valid = encodeBinaryCookies([
+      { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
+    ]);
+    // The record's own length is what bounds its string offsets; an inflated
+    // one lets them read the following record's bytes as this cookie's value.
+    const pageStart = 8 + 4;
+    const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
+    const corrupt = Buffer.from(valid);
+    corrupt.writeUInt32LE(0xffff, recordStart);
+
+    expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError);
+  });
+
+  it("rejects records truncated inside the 56-byte header", () => {
+    const valid = encodeBinaryCookies([
+      { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
+    ]);
+    const pageStart = 8 + 4;
+    const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
+
+    for (let size = 48; size < 56; size += 1) {
+      const corrupt = Buffer.from(valid);
+      corrupt.writeUInt32LE(size, recordStart);
+      expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow(
+        SafariCookieReadError,
+      );
+    }
+  });
+
+  it("rejects record offsets that point into the page header or an earlier record", () => {
+    const valid = encodeBinaryCookies([
+      { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
+      { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 },
+    ]);
+    const pageStart = 8 + 4;
+    const firstRecord = valid.readUInt32LE(pageStart + 8);
+
+    // Pointing the second offset at the page's offset table would let those
+    // table bytes parse as a fabricated record.
+    const intoTable = Buffer.from(valid);
+    intoTable.writeUInt32LE(4, pageStart + 12);
+    expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError);
+
+    // Pointing it back at the first record makes the same bytes count twice.
+    const overlapping = Buffer.from(valid);
+    overlapping.writeUInt32LE(firstRecord, pageStart + 12);
+    expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError);
+
+    // And a well-formed two-record page still parses.
+    expect(parseBinaryCookies(valid)).toHaveLength(2);
+  });
+
+  it("rejects string offsets that point into the record header", () => {
+    const valid = encodeBinaryCookies([
+      { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
+    ]);
+    const pageStart = 8 + 4;
+    const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
+
+    for (const offsetField of [16, 20, 24, 28]) {
+      const corrupt = Buffer.from(valid);
+      corrupt.writeUInt32LE(55, recordStart + offsetField);
+      expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow(
+        SafariCookieReadError,
+      );
+    }
+  });
+
+  it("accepts the checksum and property-list trailer Safari writes", () => {
+    const file = encodeBinaryCookies([
+      { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 },
+    ]);
+    const checksum = Buffer.alloc(8);
+    const plist = Buffer.from("bplist00 stub");
+    const plistLength = Buffer.alloc(4);
+    plistLength.writeUInt32BE(plist.length, 0);
+
+    expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1);
+    expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1);
+  });
+
+  it("rejects a jar whose page table stops short of its contents", () => {
+    // A second, undeclared page after the first would be silently dropped —
+    // the cookies it holds vanish from the import with no error — so a file
+    // the header does not fully describe is refused instead.
+    const first = encodeBinaryCookies([
+      { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 },
+    ]);
+    const extraPage = encodeBinaryCookies([
+      { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 },
+    ]).subarray(12);
+
+    expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow(
+      SafariCookieReadError,
+    );
+    // A trailer that claims a property list it doesn't contain is refused too.
+    const badLength = Buffer.alloc(4);
+    badLength.writeUInt32BE(99, 0);
+    expect(() =>
+      parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])),
+    ).toThrow(SafariCookieReadError);
+  });
+
+  it("rejects a file that is not binarycookies", () => {
+    expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow(
+      SafariCookieReadError,
+    );
+  });
+});
+
+describe("readSafariCookies", () => {
+  it.effect("adds the cookie path and parser cause to malformed jar failures", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
+      const jar = `${directory}/Cookies.binarycookies`;
+      yield* fileSystem.writeFileString(jar, "not a cookie jar");
+
+      const error = yield* readSafariCookies(jar).pipe(Effect.flip);
+
+      assert.equal(error.reason, "readFailed");
+      assert.equal(error.cookieDatabasePath, jar);
+      assert.instanceOf(error.cause, SafariCookieReadError);
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+
+  it.effect("reports a TCC denial as a permission the user can grant", () =>
+    Effect.gen(function* () {
+      // What Full Disk Access actually looks like: the file is there, the read
+      // is refused with EPERM. Effect tags that `Unknown`, not
+      // `PermissionDenied`, so the reader has to look at the errno. Reporting
+      // it as a generic failure would send the user looking for a missing
+      // browser instead of a checkbox.
+      const denied = PlatformError.systemError({
+        _tag: "Unknown",
+        module: "FileSystem",
+        method: "readFile",
+        pathOrDescriptor: "/protected/Cookies.binarycookies",
+        cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
+      });
+
+      const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe(
+        Effect.flip,
+        Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })),
+      );
+
+      assert.equal(error.reason, "needsFullDiskAccess");
+    }),
+  );
+
+  it.effect("reports an ordinary permission failure as a plain read failure", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
+      const jar = `${directory}/Cookies.binarycookies`;
+      yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b]));
+      // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix
+      // it, so it must not be routed to that grant.
+      yield* fileSystem.chmod(jar, 0o000);
+
+      const error = yield* readSafariCookies(jar).pipe(Effect.flip);
+
+      assert.equal(error.reason, "readFailed");
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+
+  it.effect("reports a missing jar as a plain read failure", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
+
+      const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip);
+
+      assert.equal(error.reason, "readFailed");
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+});
+
+describe("safariAccessDenied", () => {
+  const eperm = PlatformError.systemError({
+    _tag: "Unknown",
+    module: "FileSystem",
+    method: "open",
+    pathOrDescriptor: "/protected/Cookies.binarycookies",
+    cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
+  });
+  const denied = (error: PlatformError.PlatformError) =>
+    FileSystem.layerNoop({ open: () => Effect.fail(error) });
+
+  it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () =>
+    Effect.gen(function* () {
+      // `stat` finds the jar without the grant, so only an open tells the
+      // listing whether the import would actually be allowed.
+      assert.isTrue(
+        yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe(
+          Effect.provide(denied(eperm)),
+        ),
+      );
+    }),
+  );
+
+  it.effect("does not read a readable jar, or any other failure, as denied", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
+      const jar = `${directory}/Cookies.binarycookies`;
+      yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b]));
+      assert.isFalse(yield* safariAccessDenied(jar));
+      // Missing entirely is "not installed", not "denied".
+      assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`));
+    }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+  );
+});
+
+describe("isPermissionDenied", () => {
+  // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified
+  // against Safari's TCC-protected jar, whose denial is EPERM, tagged
+  // `Unknown` rather than `PermissionDenied`.
+  const platformError = (reasonTag: string, code: string): PlatformError.PlatformError =>
+    ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never;
+
+  it("treats a TCC EPERM denial as permission denied", () => {
+    // The regression: EPERM is tagged `Unknown`, so checking the tag alone
+    // reported Safari's Full Disk Access refusal as a generic read failure.
+    expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true);
+  });
+
+  it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => {
+    // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk
+    // Access, so it stays a plain read failure; only TCC's EPERM routes there.
+    expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false);
+  });
+
+  it("does not treat an unrelated failure as permission denied", () => {
+    expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false);
+  });
+});
```

## apps/desktop/src/preview/BrowserImport/SafariCookies.ts

Status: added; +263; −0

```diff
@@ -1,0 +1,263 @@
+/**
+ * Safari cookie extraction.
+ *
+ * Safari does not encrypt its cookies; it stores them in a proprietary
+ * `Cookies.binarycookies` file inside its app container. The protection is
+ * TCC, not cryptography — the file lives under a path only apps with Full Disk
+ * Access may read, so the gate is a permission the user grants in System
+ * Settings rather than a key to obtain.
+ *
+ * The format, big-endian throughout except the page bodies:
+ *
+ *   magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page:
+ *     u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount],
+ *     then each cookie:
+ *       u32le size, u32le unknown, u32le flags, u32le unknown,
+ *       u32le urlOffset, nameOffset, pathOffset, valueOffset,
+ *       u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated
+ *       strings at the offsets above (relative to the cookie start).
+ *
+ * @module SafariCookies
+ */
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as PlatformError from "effect/PlatformError";
+import * as Schema from "effect/Schema";
+
+import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts";
+
+/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */
+const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
+
+/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */
+const COOKIE_PAGE_HEADER_SIZE = 12;
+/** Through the `f64 creation` field; string bytes follow. */
+const COOKIE_RECORD_HEADER_SIZE = 56;
+
+const FLAG_SECURE = 0x1;
+const FLAG_HTTP_ONLY = 0x4;
+
+export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]);
+export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type;
+
+export class SafariCookieReadError extends Schema.TaggedErrorClass<SafariCookieReadError>()(
+  "SafariCookieReadError",
+  {
+    reason: SafariCookieReadFailure,
+    /**
+     * Which jar the read was for. The parser raises this before a path is in
+     * hand, so it is optional rather than required.
+     */
+    cookieDatabasePath: Schema.optional(Schema.String),
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return this.cookieDatabasePath === undefined
+      ? `Could not read Safari cookies: ${this.reason}.`
+      : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+const isSafariCookieReadError = Schema.is(SafariCookieReadError);
+
+/** Reads a NUL-terminated ASCII string at an offset. */
+function readCString(buffer: Buffer, start: number): string {
+  const end = buffer.indexOf(0, start);
+  return buffer.toString("utf8", start, end === -1 ? buffer.length : end);
+}
+
+export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie> {
+  if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  const pageCount = buffer.readUInt32BE(4);
+  // Every declared structure is bounds-checked against what the file actually
+  // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently,
+  // so accepting a short page or an overlong record would return a cookie set
+  // that is quietly missing entries or carrying fields read out of the next
+  // record — a partial import the user has no way to notice.
+  if (8 + pageCount * 4 > buffer.length) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+  const pageSizes: number[] = [];
+  for (let index = 0; index < pageCount; index += 1) {
+    pageSizes.push(buffer.readUInt32BE(8 + index * 4));
+  }
+
+  const cookies: ImportedCookie[] = [];
+  let pageStart = 8 + pageCount * 4;
+
+  for (const pageSize of pageSizes) {
+    if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    const page = buffer.subarray(pageStart, pageStart + pageSize);
+    pageStart += pageSize;
+
+    // Page bodies switch to little-endian after the big-endian header.
+    const cookieCount = page.readUInt32LE(4);
+    const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4;
+    if (offsetTableEnd > page.length) {
+      throw new SafariCookieReadError({ reason: "readFailed" });
+    }
+    // Every record accepted so far, so a later offset cannot point back into
+    // one of them: the page header, the offset table, and earlier records are
+    // all bytes that would otherwise parse as a fabricated cookie.
+    const accepted: Array<readonly [start: number, end: number]> = [];
+    for (let index = 0; index < cookieCount; index += 1) {
+      const cookieStart = page.readUInt32LE(8 + index * 4);
+      if (cookieStart < offsetTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      // Bounded by the record's own length so a string offset cannot run past
+      // it into the following record's bytes.
+      const recordSize = page.readUInt32LE(cookieStart);
+      const cookieEnd = cookieStart + recordSize;
+      if (
+        recordSize < COOKIE_RECORD_HEADER_SIZE ||
+        cookieEnd > page.length ||
+        accepted.some(([start, end]) => cookieStart < end && cookieEnd > start)
+      ) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      accepted.push([cookieStart, cookieEnd]);
+      const cookie = page.subarray(cookieStart, cookieEnd);
+
+      const flags = cookie.readUInt32LE(8);
+      const urlOffset = cookie.readUInt32LE(16);
+      const nameOffset = cookie.readUInt32LE(20);
+      const pathOffset = cookie.readUInt32LE(24);
+      const valueOffset = cookie.readUInt32LE(28);
+      const expiry = cookie.readDoubleLE(40);
+
+      // Offsets are relative to the record; one pointing outside it would
+      // otherwise read a neighbouring cookie's bytes as this one's value.
+      if (
+        [urlOffset, nameOffset, pathOffset, valueOffset].some(
+          (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length,
+        )
+      ) {
+        throw new SafariCookieReadError({ reason: "readFailed" });
+      }
+      const domain = readCString(cookie, urlOffset);
+      const name = readCString(cookie, nameOffset);
+      const path = readCString(cookie, pathOffset);
+      const value = readCString(cookie, valueOffset);
+      if (domain === "" || name === "") continue;
+
+      const secure = (flags & FLAG_SECURE) !== 0;
+      const expirationDate =
+        expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined;
+
+      cookies.push({
+        // Safari marks domain cookies with a leading dot like the other
+        // engines, so the shared scope rule applies: host-only cookies keep
+        // `domain` undefined, or Electron widens them to every subdomain.
+        ...cookieScope(domain, path || "/", secure),
+        name,
+        value,
+        path: path || "/",
+        secure,
+        httpOnly: (flags & FLAG_HTTP_ONLY) !== 0,
+        expirationDate,
+        // Bits 3–5 of the flags carry something SameSite-shaped, but no public
+        // description of them agrees and real jars do not match any of them
+        // cleanly. Lax is the modern browser default; claiming "none" would
+        // widen every imported cookie's scope.
+        sameSite: "lax",
+      });
+    }
+  }
+
+  // Safari writes an 8-byte checksum after the pages, then an optional
+  // length-prefixed property list. Anything else past the declared pages —
+  // in particular whole extra pages — means the page table does not describe
+  // the file, and a jar the header lies about is refused rather than
+  // imported with cookies silently missing.
+  const trailer = buffer.length - pageStart;
+  // Legal shapes: nothing, the 8-byte checksum alone, or checksum + u32
+  // length + exactly that many property-list bytes.
+  const validTrailer =
+    trailer === 0 ||
+    trailer === 8 ||
+    (trailer >= 12 && trailer === 8 + 4 + buffer.readUInt32BE(pageStart + 8));
+  if (!validTrailer) {
+    throw new SafariCookieReadError({ reason: "readFailed" });
+  }
+
+  return cookies;
+}
+
+/**
+ * Whether a filesystem error is the OS refusing access.
+ *
+ * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than
+ * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked
+ * too — otherwise a Full Disk Access refusal is reported as a generic read
+ * failure and the user is never told what to grant.
+ */
+export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => {
+  // TCC denies with EPERM, which Effect tags `Unknown` rather than
+  // `PermissionDenied` — so the errno is what identifies it. EACCES (and the
+  // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or
+  // ACL failure that granting Full Disk Access cannot fix, so it stays a plain
+  // read failure rather than sending the user to a grant that won't help.
+  const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
+  return code === "EPERM";
+};
+
+/**
+ * Whether reading the jar is refused by TCC. `stat` succeeds on the jar
+ * inside Safari's container even without Full Disk Access — that is what lets
+ * the listing find it — so presence alone cannot tell granted from denied.
+ * Opening it for read is what TCC gates: EPERM means the grant is missing.
+ * Anything else (including a missing jar) is not a permission answer.
+ */
+export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe(
+    Effect.as(false),
+    Effect.catch((cause) => Effect.succeed(isPermissionDenied(cause))),
+    Effect.scoped,
+  );
+});
+
+export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
+  cookiePath: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const contents = yield* fileSystem.readFile(cookiePath).pipe(
+    Effect.mapError((cause) => {
+      // TCC denies the read even though the file exists — a permission the user
+      // grants in System Settings rather than a missing browser. macOS never
+      // prompts for Full Disk Access, so there is no dialog to wait on; the
+      // read just fails, and it fails with EPERM, which Effect surfaces as an
+      // `Unknown` system error rather than `PermissionDenied` (that is EACCES).
+      return new SafariCookieReadError({
+        reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed",
+        cookieDatabasePath: cookiePath,
+        cause,
+      });
+    }),
+  );
+  // The parser throws on a malformed jar; catch it here so callers see a typed
+  // failure rather than a defect.
+  return yield* Effect.try({
+    try: () => parseBinaryCookies(Buffer.from(contents)),
+    catch: (cause) =>
+      isSafariCookieReadError(cause)
+        ? new SafariCookieReadError({
+            reason: cause.reason,
+            cookieDatabasePath: cookiePath,
+            cause,
+          })
+        : new SafariCookieReadError({
+            reason: "readFailed",
+            cookieDatabasePath: cookiePath,
+            cause,
+          }),
+  });
+});
```

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

Status: modified; +103; −0

```diff
@@ -1093,3 +1093,106 @@
     ),
   );
 });
+
+describe("Safari profiles", () => {
+  const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!;
+  const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD";
+  const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD";
+
+  const fixture = Effect.fnUntraced(function* () {
+    const context = yield* withSourceHome();
+    const fileSystem = yield* FileSystem.FileSystem;
+    const root = safari.userDataDirectory(context)!;
+    const library = context.path.dirname(root);
+    yield* fileSystem.makeDirectory(root, { recursive: true });
+    yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default");
+    const store = (uuid: string) =>
+      context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies");
+    for (const uuid of [workUuid, otherUuid]) {
+      yield* fileSystem.makeDirectory(store(uuid), { recursive: true });
+      yield* fileSystem.writeFileString(
+        context.path.join(store(uuid), "Cookies.binarycookies"),
+        uuid,
+      );
+    }
+    yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true });
+    const metadata = context.path.join(library, "Safari", "SafariTabs.db");
+    return { context, root, store, metadata };
+  });
+
+  it.effect("discovers named profiles and resolves only the selected profile's cookies", () =>
+    run(
+      Effect.gen(function* () {
+        const { context, root, store, metadata } = yield* fixture();
+        yield* Effect.sync(() => {
+          const database = new NodeSqlite.DatabaseSync(metadata);
+          try {
+            database.exec(`CREATE TABLE bookmarks (
+            title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0,
+            type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2,
+            deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0
+          )`);
+            const insert = database.prepare(
+              "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)",
+            );
+            insert.run("", "DefaultProfile", 0);
+            insert.run("Ping", workUuid, 0);
+            insert.run("Deleted", otherUuid, 1);
+            insert.run("Unsafe", "../../outside", 0);
+            database.exec(
+              "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)",
+            );
+          } finally {
+            database.close();
+          }
+        });
+        const profiles = yield* listSourceProfiles(safari, context);
+        assert.deepEqual(profiles, [
+          { directory: ".", name: "Personal" },
+          { directory: store(workUuid), name: "Ping" },
+        ]);
+        assert.strictEqual(
+          yield* resolveCookieDatabase(safari, context, "."),
+          context.path.join(root, "Cookies.binarycookies"),
+        );
+        const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory);
+        assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies"));
+        const fileSystem = yield* FileSystem.FileSystem;
+        assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid);
+        yield* fileSystem.remove(selected!);
+        assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory));
+        assert.deepEqual(yield* listSourceProfiles(safari, context), profiles);
+      }),
+    ),
+  );
+
+  for (const metadataState of ["missing", "corrupt"] as const) {
+    it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () =>
+      run(
+        Effect.gen(function* () {
+          const { context, store, metadata } = yield* fixture();
+          const fileSystem = yield* FileSystem.FileSystem;
+          if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid");
+          yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies"));
+          assert.deepEqual(yield* listSourceProfiles(safari, context), [
+            { directory: ".", name: "Safari" },
+            { directory: store(workUuid), name: workUuid.toLowerCase() },
+          ]);
+          assert.isTrue(yield* isSourceInstalled(safari, context));
+        }),
+      ),
+    );
+  }
+
+  it.effect("keeps Safari without profiles available", () =>
+    run(
+      Effect.gen(function* () {
+        const context = yield* withSourceHome();
+        assert.deepEqual(yield* listSourceProfiles(safari, context), [
+          { directory: ".", name: "Safari" },
+        ]);
+        assert.isFalse(yield* isSourceInstalled(safari, context));
+      }),
+    ),
+  );
+});
```

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

Status: modified; +93; −3

```diff
@@ -1,10 +1,11 @@
 /**
  * Importable browser sources.
  *
- * Two engines are modelled. Chromium-family browsers keep cookies in an
+ * Chromium-family browsers keep cookies in an
  * encrypted SQLite database whose key lives in an OS credential store; Firefox
  * keeps them in plain SQLite with no key at all, so it needs no keychain and
- * works the same on every platform.
+ * works the same on every platform. Safari uses binary cookie files, with
+ * separate WebKit data stores for named profiles.
  *
  * Each entry pins its own paths and credential-store coordinates rather than
  * deriving them, because the forks do not agree. macOS uses service/account
@@ -31,7 +32,7 @@
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
 import * as SqlClient from "effect/unstable/sql/SqlClient";
 
-export type BrowserImportEngine = "chromium" | "firefox";
+export type BrowserImportEngine = "chromium" | "firefox" | "safari";
 
 /**
  * Directory roots a definition builds its paths from. Passed in rather than
@@ -178,6 +179,26 @@
     linuxSecretApplication: "chromium",
   }),
   {
+    // The default jar lives here; named profiles use WebKit data stores
+    // alongside this directory inside the same app container.
+    id: "safari",
+    name: "Safari",
+    engine: "safari",
+    platforms: ["darwin"],
+    userDataDirectory: (context) =>
+      context.platform === "darwin"
+        ? context.path.join(
+            context.home,
+            "Library",
+            "Containers",
+            "com.apple.Safari",
+            "Data",
+            "Library",
+            "Cookies",
+          )
+        : undefined,
+  },
+  {
     id: "firefox",
     name: "Firefox",
     engine: "firefox",
@@ -219,6 +240,9 @@
     : context.path.join(root, profileDirectory);
   if (definition.engine === "firefox") {
     return [context.path.join(profilePath, "cookies.sqlite")];
+  }
+  if (definition.engine === "safari") {
+    return [context.path.join(profilePath, "Cookies.binarycookies")];
   }
   // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade
   // leaves the legacy file behind, so prefer the current one and fall back.
@@ -386,6 +410,64 @@
     ),
   );
 
+const SafariProfileRows = Schema.Array(
+  Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }),
+);
+const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows);
+const isSafariProfileUuid = (value: string) =>
+  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
+
+const listSafariProfiles = Effect.fnUntraced(function* (
+  context: BrowserImportPathContext,
+  root: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const library = context.path.dirname(root);
+  const metadata = context.path.join(library, "Safari", "SafariTabs.db");
+  const declared = yield* Effect.gen(function* () {
+    const sql = yield* SqlClient.SqlClient;
+    return yield* decodeSafariProfiles(
+      yield* sql`
+      select title, external_uuid from bookmarks
+      where parent = 0 and type = 1 and subtype = 2 and deleted = 0
+      order by order_index
+    `,
+    );
+  }).pipe(
+    Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })),
+    Effect.orElseSucceed(() => []),
+  );
+  const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile");
+  const profiles: Array<BrowserImportSourceProfile> = [
+    {
+      directory: ".",
+      name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari",
+    },
+  ];
+  const stores = context.path.join(library, "WebKit", "WebsiteDataStore");
+  const profileDirectory = (uuid: string) =>
+    context.path.join(stores, uuid.toLowerCase(), "Cookies");
+  for (const profile of declared) {
+    if (!isSafariProfileUuid(profile.external_uuid)) continue;
+    profiles.push({
+      directory: profileDirectory(profile.external_uuid),
+      name: profile.title?.trim() || profile.external_uuid,
+    });
+  }
+  // If Safari's metadata is unavailable, recover stores that have cookies.
+  // With readable metadata, avoid resurrecting deleted profiles left on disk.
+  if (declared.length === 0) {
+    const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => []));
+    for (const entry of entries.filter(isSafariProfileUuid).sort()) {
+      const directory = context.path.join(stores, entry, "Cookies");
+      if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) {
+        profiles.push({ directory, name: entry });
+      }
+    }
+  }
+  return profiles;
+});
+
 /**
  * Profiles the source browser knows about.
  *
@@ -403,6 +485,10 @@
   const root = definition.userDataDirectory(context);
   if (root === undefined) return [];
 
+  if (definition.engine === "safari") {
+    return yield* listSafariProfiles(context, root);
+  }
+
   if (definition.engine === "firefox") {
     const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
       Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),
@@ -773,11 +859,15 @@
   const root = definition.userDataDirectory(context);
   if (root === undefined) return false;
   // Probe the source's own lock state rather than scanning the process table.
+  // Safari keeps no lock and writes its jar atomically, so a running instance
+  // is not a hazard there.
+  //
   // Chromium exposes its lock through the cookie jar on Windows and through a
   // user-data SingletonLock on POSIX. Firefox keeps its locks inside each
   // profile under three names across platforms (`lock` on macOS and Linux,
   // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's
   // at the root finds nothing and reports a running browser as importable.
+  if (definition.engine === "safari") return false;
   if (definition.engine !== "firefox") {
     if (context.platform === "win32") {
       return yield* windowsChromiumCookiesAreHeld(definition, context);
```

## apps/desktop/src/window/DesktopWindow.test.ts

Status: modified; +2; −0

```diff
@@ -280,6 +280,7 @@
               input.openedExternalUrls?.push(url);
               return true;
             }),
+          openSystemSettings: () => Effect.succeed(true),
           copyText: () => Effect.void,
         } satisfies ElectronShell.ElectronShell["Service"]),
         electronThemeLayer,
@@ -380,6 +381,7 @@
           electronMenuLayer,
           Layer.succeed(ElectronShell.ElectronShell, {
             openExternal: () => Effect.succeed(true),
+            openSystemSettings: () => Effect.succeed(true),
             copyText: () => Effect.void,
           } satisfies ElectronShell.ElectronShell["Service"]),
           electronThemeLayer,
```

## apps/web/src/components/settings/BrowserImportWizard.tsx

Status: modified; +81; −7

```diff
@@ -23,6 +23,7 @@
   canCloseWizard,
   isRetryableReason,
   formatSkippedDomains,
+  fullDiskAccessRecheckStep,
   outcomeToStep,
   refreshedSourceProfileDirectory,
   refreshedSourceStep,
@@ -55,6 +56,8 @@
   }) => Promise<ImportOutcome>;
   /** Re-checks the source's availability after the user quits the browser. */
   readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
+  /** Opens the OS setting that grants access to a protected cookie store. */
+  readonly onOpenFullDiskAccessSettings: () => void;
   readonly onClose: () => void;
 }
 
@@ -72,6 +75,7 @@
   canCreateProfile,
   onImport,
   onRefreshSource,
+  onOpenFullDiskAccessSettings,
   onClose,
 }: BrowserImportWizardProps) {
   const [source, setSource] = useState(initialSource);
@@ -111,8 +115,13 @@
       });
   };
 
+  // Re-lists the source after the user did something outside the app (quit the
+  // browser, granted access) and routes to wherever the refreshed source says.
+  const recheckSource = (
+    check: "browser" | "fullDiskAccess",
+    nextStep: (refreshed: BrowserImportSource | undefined) => WizardStep,
-  const recheckAfterQuit = () => {
-    setStep({ step: "checking" });
+  ) => {
+    setStep({ step: "checking", check });
     void onRefreshSource()
       .then((refreshed) => {
         if (refreshed) {
@@ -121,20 +130,30 @@
             refreshedSourceProfileDirectory(current, refreshed),
           );
         }
-        setStep(refreshedSourceStep(refreshed));
+        setStep(nextStep(refreshed));
       })
       .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
   };
+  const recheckAfterQuit = () => recheckSource("browser", refreshedSourceStep);
+  const recheckFullDiskAccess = () => recheckSource("fullDiskAccess", fullDiskAccessRecheckStep);
 
   return (
     <Dialog open onOpenChange={(open) => (open || !canCloseWizard(step) ? undefined : onClose())}>
       <DialogPopup className="max-w-lg" showCloseButton={canCloseWizard(step)}>
         {step.step === "quit" ? (
           <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
+        ) : step.step === "fullDiskAccess" ? (
+          <FullDiskAccessStep
+            source={source}
+            onCancel={onClose}
+            onOpenSettings={onOpenFullDiskAccessSettings}
+            onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess}
+            stillRequired={step.checked === true}
+          />
         ) : step.step === "importing" ? (
           <ImportingStep />
         ) : step.step === "checking" ? (
-          <CheckingStep sourceName={source.name} />
+          <CheckingStep sourceName={source.name} check={step.check} />
         ) : step.step === "done" ? (
           <DoneStep
             {...step}
@@ -223,6 +242,49 @@
   readonly onImport: () => void;
 };
 
+function FullDiskAccessStep({
+  source,
+  onCancel,
+  onOpenSettings,
+  onGranted,
+  stillRequired,
+}: {
+  readonly source: BrowserImportSource;
+  readonly onCancel: () => void;
+  readonly onOpenSettings: () => void;
+  readonly onGranted: () => void;
+  readonly stillRequired: boolean;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Let T3 Code read {source.name}&rsquo;s cookies</DialogTitle>
+        <DialogDescription>
+          To import cookies from {source.name}, T3 Code needs Full Disk Access. Turn it on in System
+          Settings, then come back to finish the import — you can revoke it again once the import is
+          done.
+        </DialogDescription>
+      </DialogHeader>
+      {stillRequired ? (
+        <DialogPanel>
+          <p role="status" className="text-sm text-muted-foreground">
+            Full Disk Access is still required. If you just turned it on, quit and reopen T3 Code,
+            then try again.
+          </p>
+        </DialogPanel>
+      ) : null}
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel}>
+          Cancel
+        </Button>
+        <Button variant="outline" onClick={onOpenSettings}>
+          Open System Settings
+        </Button>
+        <Button onClick={onGranted}>I&rsquo;ve turned it on</Button>
+      </DialogFooter>
+    </>
+  );
+}
 function ConfigureStep({
   source,
   destinationEnvironmentName,
@@ -382,16 +444,28 @@
   );
 }
 
+function CheckingStep({
+  sourceName,
+  check,
+}: {
+  readonly sourceName: string;
-function CheckingStep({ sourceName }: { readonly sourceName: string }) {
+  readonly check: "browser" | "fullDiskAccess";
+}) {
   return (
     <>
       <DialogHeader>
         <DialogTitle>Checking {sourceName}</DialogTitle>
+        <DialogDescription>
+          {check === "fullDiskAccess"
+            ? "Checking Full Disk Access."
+            : "Checking whether the browser has closed."}
+        </DialogDescription>
-        <DialogDescription>Checking whether the browser has closed.</DialogDescription>
       </DialogHeader>
       <DialogPanel className="flex items-center gap-3 py-6">
         <Spinner className="size-4 text-muted-foreground" />
+        <span className="text-sm text-muted-foreground">
+          {check === "fullDiskAccess" ? "Checking access…" : "Checking…"}
-        <span className="text-sm text-muted-foreground">Checking…</span>
+        </span>
       </DialogPanel>
     </>
   );
```

## apps/web/src/components/settings/IntegrationsSettings.logic.test.ts

Status: modified; +1; −0

```diff
@@ -85,6 +85,7 @@
 
 describe("importFailureReason", () => {
   it("recovers the reason token from the flattened message", () => {
+    expect(importFailureReason(failure("needsFullDiskAccess"))).toBe("needsFullDiskAccess");
     expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");
     expect(importFailureReason(failure("readFailed"))).toBe("readFailed");
     expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable");
```

## apps/web/src/components/settings/IntegrationsSettings.tsx

Status: modified; +15; −0

```diff
@@ -55,6 +55,8 @@
   MenuSeparator,
   MenuTrigger,
 } from "../ui/menu";
+import { readLocalApi } from "~/localApi";
+
 import { toastManager } from "../ui/toast";
 import {
   AlertDialog,
@@ -1168,6 +1170,19 @@
             runWizardImport(importSession.source, importSession.environmentId, input)
           }
           onRefreshSource={() => refreshImportSource(importSession.source.id)}
+          onOpenFullDiskAccessSettings={() => {
+            // Rejects outside the desktop shell (and on shells that predate the
+            // method), so the one toast covers every way the link can fail.
+            void readLocalApi()
+              ?.shell.openSystemSettings("full-disk-access")
+              .catch(() => {
+                toastManager.add({
+                  type: "error",
+                  title: "Could not open System Settings",
+                  description: "Open Privacy & Security → Full Disk Access manually.",
+                });
+              });
+          }}
           onClose={() => setImportSession(null)}
         />
       ) : null}
```

## apps/web/src/components/settings/browserImportWizard.logic.test.ts

Status: modified; +32; −0

```diff
@@ -7,6 +7,7 @@
   initialTargetSelection,
   isRetryableReason,
   formatSkippedDomains,
+  fullDiskAccessRecheckStep,
   outcomeToStep,
   refreshedSourceProfileDirectory,
   refreshedSourceStep,
@@ -51,6 +52,15 @@
     expect(initialWizardStep(source())).toEqual({ step: "configure" });
   });
 
+  it("asks for Full Disk Access before choosing an import target", () => {
+    expect(initialWizardStep(source({ profiles: [], unavailable: "needsFullDiskAccess" }))).toEqual(
+      {
+        step: "fullDiskAccess",
+        resume: "configure",
+      },
+    );
+  });
+
   it("blocks on a reason nothing local can fix", () => {
     expect(initialWizardStep(source({ unavailable: "unsupportedPlatform" }))).toEqual({
       step: "blocked",
@@ -103,6 +113,14 @@
     expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" });
   });
 
+  it("routes a Full Disk Access refusal to its own screen", () => {
+    expect(outcomeToStep({ kind: "blocked", reason: "needsFullDiskAccess" })).toEqual({
+      step: "fullDiskAccess",
+      resume: "import",
+      checked: true,
+    });
+  });
+
   it("surfaces every other failure on the blocked screen", () => {
     expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({
       step: "blocked",
@@ -134,6 +152,20 @@
   });
 });
 
+describe("fullDiskAccessRecheckStep", () => {
+  it("marks a still-denied access check for visible feedback", () => {
+    expect(fullDiskAccessRecheckStep(source({ unavailable: "needsFullDiskAccess" }))).toEqual({
+      step: "fullDiskAccess",
+      resume: "configure",
+      checked: true,
+    });
+  });
+
+  it("moves on once access reveals the source profiles", () => {
+    expect(fullDiskAccessRecheckStep(source())).toEqual({ step: "configure" });
+  });
+});
+
 describe("refreshedSourceProfileDirectory", () => {
   const refreshed = source({
     profiles: [
```

## apps/web/src/components/settings/browserImportWizard.logic.ts

Status: modified; +20; −1

```diff
@@ -58,8 +58,13 @@
  */
 export type WizardStep =
   | { readonly step: "quit" }
+  | {
+      readonly step: "fullDiskAccess";
+      readonly resume: "configure" | "import";
+      readonly checked?: boolean;
+    }
   | { readonly step: "configure" }
+  | { readonly step: "checking"; readonly check: "browser" | "fullDiskAccess" }
-  | { readonly step: "checking" }
   | { readonly step: "importing" }
   | {
       readonly step: "done";
@@ -82,6 +87,9 @@
  */
 export function initialWizardStep(source: BrowserImportSource): WizardStep {
   if (source.unavailable === "browserRunning") return { step: "quit" };
+  if (source.unavailable === "needsFullDiskAccess") {
+    return { step: "fullDiskAccess", resume: "configure" };
+  }
   if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable };
   if (source.profiles.length === 0) return { step: "blocked", reason: "unknownSourceProfile" };
   return { step: "configure" };
@@ -102,6 +110,11 @@
   // other failure surfaces on the blocked screen, which offers a retry when
   // one could help.
   if (outcome.reason === "browserRunning") return { step: "quit" };
+  if (outcome.reason === "needsFullDiskAccess") {
+    // The failed import already checked access, so explain that it is still
+    // denied instead of returning to an indistinguishable permission screen.
+    return { step: "fullDiskAccess", resume: "import", checked: true };
+  }
   return { step: "blocked", reason: outcome.reason };
 }
 
@@ -111,6 +124,12 @@
   return initialWizardStep(source);
 }
 
+/** A denied FDA recheck returns to the permission step with visible feedback. */
+export function fullDiskAccessRecheckStep(source: BrowserImportSource | undefined): WizardStep {
+  const next = refreshedSourceStep(source);
+  return next.step === "fullDiskAccess" ? { ...next, checked: true } : next;
+}
+
 /** Preserve the chosen source profile when a post-quit refresh still lists it. */
 export function refreshedSourceProfileDirectory(
   currentDirectory: string,
```

## apps/web/src/localApi.test.ts

Status: modified; +8; −0

```diff
@@ -113,6 +113,14 @@
     await expect(createLocalApi().dialogs.confirm("Delete this thread?")).resolves.toBe(false);
   });
 
+  it("rejects opening System Settings when the desktop bridge is unavailable", async () => {
+    const { createLocalApi } = await import("./localApi");
+
+    await expect(createLocalApi().shell.openSystemSettings("full-disk-access")).rejects.toThrow(
+      "Unable to open System Settings.",
+    );
+  });
+
   it("delegates host capabilities and persistence to the desktop bridge", async () => {
     const showContextMenu = vi.fn().mockResolvedValue("delete");
     const pickFolder = vi.fn().mockResolvedValue("/tmp/project");
```

## apps/web/src/localApi.ts

Status: modified; +11; −0

```diff
@@ -29,6 +29,17 @@
 
         window.open(url, "_blank", "noopener,noreferrer");
       },
+      // Only the desktop shell can reach the OS; the web build (and older
+      // desktop shells that predate this method) have nothing to open.
+      openSystemSettings: async (pane) => {
+        if (!window.desktopBridge?.openSystemSettings) {
+          throw new Error("Unable to open System Settings.");
+        }
+        const opened = await window.desktopBridge.openSystemSettings(pane);
+        if (!opened) {
+          throw new Error("Unable to open System Settings.");
+        }
+      },
     },
     contextMenu: {
       show: async <T extends string>(
```

## docs/user/browser-import.md

Status: modified; +6; −0

```diff
@@ -10,6 +10,12 @@
 This is a one-time copy. Later login changes stay separate between the two browsers, and some
 sites may still require you to sign in again.
 
+On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than
+a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the
+right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can
+revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies
+kept by additional Safari profiles are not.
+
 On Windows, import supports Firefox and Helium profiles that use standard profile encryption.
 Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies
 are skipped on all platforms.
```

[Next page](https://git.cafe/pingdotgg/t3code/pull/7262/diffs?after=ImRvY3MvdXNlci9icm93c2VyLWltcG9ydC5tZCI&expectedVersion=61&format=markdown)
