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

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

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

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

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