diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..17f3e06039b6 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,20 @@ describe("ElectronShell", () => { }).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); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfebd0f..0ac4f8f9cc6a 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -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 Option from "effect/Option"; 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 = { + "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 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + /** Opens a known System Settings pane by identifier, not by URL. */ + readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -59,6 +79,13 @@ export const make = ElectronShell.of({ ), ), }), + openSystemSettings: (pane) => + Effect.promise(() => + Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( + () => true, + () => false, + ), + ), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2cdffbefb7ad..3e30083064af 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getSystemLocale, getWindowFullscreenState, openExternal, + openSystemSettings, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -94,6 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" 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); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 81b50d165d24..5b2c815eaa42 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; 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"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..61de1361a311 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,6 +9,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; @@ -298,6 +299,16 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +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, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..74001dd785d3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(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) => { diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e92f2f05e05c..386b3ef6f813 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -27,6 +27,7 @@ import * as BrowserSession from "../BrowserSession.ts"; 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 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* 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 @@ export const make = Effect.gen(function* BrowserImportMake() { 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 === "firefox" - ? readFirefoxCookies(databasePath).pipe( + definition.engine === "safari" + ? readSafariCookies(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, - }); + : 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, + }); const result = yield* read.pipe( Effect.scoped, @@ -289,6 +303,12 @@ export const make = Effect.gen(function* BrowserImportMake() { 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 }), + ), }), ); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts new file mode 100644 index 000000000000..f5d07f765943 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -0,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): 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); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts new file mode 100644 index 000000000000..88aa856b83e9 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -0,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", + { + 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 { + 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 = []; + 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, + }), + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index a83290448a4a..a867f78497b4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -1093,3 +1093,106 @@ describe("listSourceProfiles hardening", () => { ), ); }); + +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)); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 702933a432b3..6075f0ad56a3 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -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 * as Stream from "effect/Stream"; 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 @@ -177,6 +178,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray + context.platform === "darwin" + ? context.path.join( + context.home, + "Library", + "Containers", + "com.apple.Safari", + "Data", + "Library", + "Cookies", + ) + : undefined, + }, { id: "firefox", name: "Firefox", @@ -220,6 +241,9 @@ export const cookieDatabaseCandidatePaths = ( 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. return [ @@ -386,6 +410,64 @@ const withCookieCounts = ( ), ); +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 = [ + { + 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 listSourceProfilesInDirectory = Effect.fnUntraced(function* ( 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 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") 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); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index abf6f220eca4..bdd03865c7bf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -280,6 +280,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, @@ -380,6 +381,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index a18cb9173ff6..a02bb5dab24e 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -23,6 +23,7 @@ import { canCloseWizard, isRetryableReason, formatSkippedDomains, + fullDiskAccessRecheckStep, outcomeToStep, refreshedSourceProfileDirectory, refreshedSourceStep, @@ -55,6 +56,8 @@ interface BrowserImportWizardProps { }) => Promise; /** Re-checks the source's availability after the user quits the browser. */ readonly onRefreshSource: () => Promise; + /** Opens the OS setting that grants access to a protected cookie store. */ + readonly onOpenFullDiskAccessSettings: () => void; readonly onClose: () => void; } @@ -72,6 +75,7 @@ export function BrowserImportWizard({ canCreateProfile, onImport, onRefreshSource, + onOpenFullDiskAccessSettings, onClose, }: BrowserImportWizardProps) { const [source, setSource] = useState(initialSource); @@ -111,8 +115,13 @@ export function BrowserImportWizard({ }); }; - const recheckAfterQuit = () => { - setStep({ step: "checking" }); + // 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, + ) => { + setStep({ step: "checking", check }); void onRefreshSource() .then((refreshed) => { if (refreshed) { @@ -121,20 +130,30 @@ export function BrowserImportWizard({ 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 ( (open || !canCloseWizard(step) ? undefined : onClose())}> {step.step === "quit" ? ( + ) : step.step === "fullDiskAccess" ? ( + ) : step.step === "importing" ? ( ) : step.step === "checking" ? ( - + ) : step.step === "done" ? ( void; }; +function FullDiskAccessStep({ + source, + onCancel, + onOpenSettings, + onGranted, + stillRequired, +}: { + readonly source: BrowserImportSource; + readonly onCancel: () => void; + readonly onOpenSettings: () => void; + readonly onGranted: () => void; + readonly stillRequired: boolean; +}) { + return ( + <> + + Let T3 Code read {source.name}’s cookies + + 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. + + + {stillRequired ? ( + +

+ Full Disk Access is still required. If you just turned it on, quit and reopen T3 Code, + then try again. +

+
+ ) : null} + + + + + + + ); +} function ConfigureStep({ source, destinationEnvironmentName, @@ -382,16 +444,28 @@ function ImportingStep() { ); } -function CheckingStep({ sourceName }: { readonly sourceName: string }) { +function CheckingStep({ + sourceName, + check, +}: { + readonly sourceName: string; + readonly check: "browser" | "fullDiskAccess"; +}) { return ( <> Checking {sourceName} - Checking whether the browser has closed. + + {check === "fullDiskAccess" + ? "Checking Full Disk Access." + : "Checking whether the browser has closed."} + - Checking… + + {check === "fullDiskAccess" ? "Checking access…" : "Checking…"} + ); diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts index 1c839f0cfdf8..c6d091b59e39 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -85,6 +85,7 @@ const failure = (reason: string) => ({ 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"); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 5b1d9ba5e21e..a3e96107b29c 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -55,6 +55,8 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; +import { readLocalApi } from "~/localApi"; + import { toastManager } from "../ui/toast"; import { AlertDialog, @@ -1168,6 +1170,19 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { 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} diff --git a/apps/web/src/components/settings/browserImportWizard.logic.test.ts b/apps/web/src/components/settings/browserImportWizard.logic.test.ts index 64bd94f635c4..3df4a95d6c33 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.test.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -7,6 +7,7 @@ import { initialTargetSelection, isRetryableReason, formatSkippedDomains, + fullDiskAccessRecheckStep, outcomeToStep, refreshedSourceProfileDirectory, refreshedSourceStep, @@ -51,6 +52,15 @@ describe("initialWizardStep", () => { 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 @@ describe("outcomeToStep", () => { 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("refreshedSourceStep", () => { }); }); +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: [ diff --git a/apps/web/src/components/settings/browserImportWizard.logic.ts b/apps/web/src/components/settings/browserImportWizard.logic.ts index 5479e307b13a..3b71345b9d5a 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.ts @@ -58,8 +58,13 @@ export type ImportOutcome = */ export type WizardStep = | { readonly step: "quit" } + | { + readonly step: "fullDiskAccess"; + readonly resume: "configure" | "import"; + readonly checked?: boolean; + } | { readonly step: "configure" } - | { readonly step: "checking" } + | { readonly step: "checking"; readonly check: "browser" | "fullDiskAccess" } | { readonly step: "importing" } | { readonly step: "done"; @@ -82,6 +87,9 @@ export function canCloseWizard(step: WizardStep): boolean { */ 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 @@ export function outcomeToStep(outcome: ImportOutcome): WizardStep { // 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 @@ export function refreshedSourceStep(source: BrowserImportSource | undefined): Wi 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, diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9220252cb20e..33c53a86c4e2 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -113,6 +113,14 @@ describe("LocalApi", () => { 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"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 8f55f65e40de..cafd04f9c048 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -29,6 +29,17 @@ function createBrowserLocalApi(): LocalApi { 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 ( diff --git a/docs/user/browser-import.md b/docs/user/browser-import.md index 9aa4c6ca0e7f..93f2fad73741 100644 --- a/docs/user/browser-import.md +++ b/docs/user/browser-import.md @@ -10,6 +10,12 @@ unlock prompt if one appears. 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. diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 4448ae37b472..2c7eb24821d3 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -25,6 +25,7 @@ export const BROWSER_IMPORT_SOURCE_IDS = [ "arc", "helium", "firefox", + "safari", ] as const; export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); @@ -42,6 +43,7 @@ export const BrowserImportUnavailableReason = Schema.Literals([ "notInstalled", "needsKeychainApproval", "keychainItemMissing", + "needsFullDiskAccess", "browserRunning", "unsupportedPlatform", ]); @@ -144,6 +146,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< needsKeychainApproval: "Needs Keychain access to read its cookies.", keychainItemMissing: "No encryption key in your Keychain — sign in to that browser once, then retry.", + needsFullDiskAccess: + "Give T3 Code Full Disk Access in System Settings → Privacy & Security, then retry.", browserRunning: "Quit the browser first so its cookie database can be read.", unsupportedPlatform: "Importing from this browser isn't possible on this platform.", }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 798d5a777d5d..a14a4ed0a08a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1052,6 +1052,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ input: PreviewAutomationWaitForInput, }); +/** + * A System Settings pane the app can deep-link to. The identifier crosses IPC + * rather than a URL, so the renderer can only reach these known destinations. + */ +export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); +export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; /** The desktop client's OS platform, read from Electron's preload process. */ @@ -1119,6 +1126,11 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** + * Open a System Settings pane by identifier. Optional: older desktop builds + * lack it, and callers no-op when it is missing. + */ + openSystemSettings?: (pane: SystemSettingsPane) => Promise; /** * Probe this desktop machine for installed remote-capable editor CLIs * (used for remote open-in-editor deep links). Optional: older desktop @@ -1267,6 +1279,8 @@ export interface LocalApi { }; shell: { openExternal: (url: string) => Promise; + /** Opens a known System Settings pane; no-ops outside the desktop app. */ + openSystemSettings: (pane: SystemSettingsPane) => Promise; }; contextMenu: { show: (