diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 169a48c3b62..6b69aab6f2a 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -29,6 +29,7 @@ import { type CookieReadResult, } from "./ChromiumCookies.ts"; import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; +import { readSafariCookies, SafariCookieReadError } from "./SafariCookies.ts"; import { BROWSER_IMPORT_SOURCES, cookieDatabasePath, @@ -177,22 +178,26 @@ export const make = Effect.gen(function* BrowserImportMake() { // engine — Firefox stores plaintext, so nothing there is ever unreadable. 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, - // Only Windows reads it; the other platforms take their key from a - // credential store and ignore the path entirely. - localStatePath: localStatePath(definition, pathContext) ?? "", - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - platform, - }); + : definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + // Only Windows reads it; the other platforms take their key from a + // credential store and ignore the path entirely. + localStatePath: localStatePath(definition, pathContext) ?? "", + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + platform, + }); const result = yield* read.pipe( Effect.scoped, @@ -209,6 +214,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/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index ed5a9157e30..70a8ac58f28 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same +// OSCrypt primitives the module under test decrypts. import { describe, expect, it } from "@effect/vitest"; import * as NodeCrypto from "node:crypto"; 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 00000000000..03e8b4f2ec2 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -0,0 +1,251 @@ +// @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, + 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", + domain: "example.test", + path: "/app", + secure: false, + httpOnly: false, + expirationDate: undefined, + sameSite: "lax", + }, + ]); + }); + + 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 a file that is not binarycookies", () => { + expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( + SafariCookieReadError, + ); + }); +}); + +describe("readSafariCookies", () => { + it.effect("reports a TCC denial as a permission the user can grant", () => + 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])); + // What Full Disk Access actually looks like: the file is there, the read + // is refused. Reporting that as a generic failure would send the user + // looking for a missing browser instead of a checkbox. + yield* fileSystem.chmod(jar, 0o000); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "needsFullDiskAccess"); + }).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("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("treats an EACCES denial as permission denied", () => { + expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(true); + }); + + 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 00000000000..a714380ced9 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -0,0 +1,208 @@ +/** + * 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 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 = 48; + +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); + if (COOKIE_PAGE_HEADER_SIZE + cookieCount * 4 > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + for (let index = 0; index < cookieCount; index += 1) { + const cookieStart = page.readUInt32LE(8 + index * 4); + if (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); + if (recordSize < COOKIE_RECORD_HEADER_SIZE || cookieStart + recordSize > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const cookie = page.subarray(cookieStart, cookieStart + recordSize); + + 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.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 host = domain.startsWith(".") ? domain.slice(1) : domain; + const expirationDate = + expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; + + cookies.push({ + url: `${secure ? "https" : "http"}://${host}${path || "/"}`, + name, + value, + domain, + path: path || "/", + secure, + httpOnly: (flags & FLAG_HTTP_ONLY) !== 0, + expirationDate, + // The format predates SameSite and carries no equivalent field. Lax is + // the modern browser default; claiming "none" would widen every + // imported cookie's scope. + sameSite: "lax", + }); + } + } + + 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 => { + if (error.reason._tag === "PermissionDenied") return true; + const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; + return code === "EPERM" || code === "EACCES"; +}; + +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) + ? cause + : new SafariCookieReadError({ + reason: "readFailed", + cookieDatabasePath: cookiePath, + cause, + }), + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index b35129bf0de..d8cb4298f11 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -23,7 +23,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; 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 @@ -167,6 +167,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", @@ -196,7 +216,12 @@ export const cookieDatabasePath = ( ): string | undefined => { const root = definition.userDataDirectory(context); if (root === undefined) return undefined; - const fileName = definition.engine === "firefox" ? "cookies.sqlite" : "Cookies"; + const fileName = + definition.engine === "firefox" + ? "cookies.sqlite" + : definition.engine === "safari" + ? "Cookies.binarycookies" + : "Cookies"; return context.path.isAbsolute(profileDirectory) ? context.path.join(profileDirectory, fileName) : context.path.join(root, profileDirectory, fileName); @@ -362,6 +387,11 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const root = definition.userDataDirectory(context); if (root === undefined) return []; + if (definition.engine === "safari") { + // One jar, no profiles: the directory is the profile. + return [{ directory: ".", name: "Safari" }]; + } + if (definition.engine === "firefox") { const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( Effect.map(parseFirefoxProfiles), @@ -421,14 +451,17 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") ): Effect.fn.Return { const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Both engines leave a lock file for as long as an instance holds a profile, - // which is far cheaper and more targeted than scanning the process table. + // Both engines that hold a lock leave one for as long as an instance holds a + // profile, which is far cheaper and more targeted than scanning the process + // table. Safari keeps none, and unlike the others writes its jar atomically, + // so a running instance is not a hazard there. // - // They differ in where: Chromium keeps one `SingletonLock` for the whole - // user-data directory, 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. + // Chromium and Firefox differ in where: Chromium keeps one `SingletonLock` + // for the whole user-data directory, 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") { return yield* entryExists(context.path.join(root, "SingletonLock")); } diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index f7b58ce0d6a..8f9670e7444 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -57,6 +57,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; } @@ -73,6 +75,7 @@ export function BrowserImportWizard({ canCreateProfile, onImport, onRefreshSource, + onOpenFullDiskAccessSettings, onClose, }: BrowserImportWizardProps) { const [source, setSource] = useState(initialSource); @@ -121,6 +124,13 @@ export function BrowserImportWizard({ {step.step === "quit" ? ( + ) : step.step === "fullDiskAccess" ? ( + ) : step.step === "importing" ? ( ) : step.step === "done" ? ( @@ -196,9 +206,39 @@ type ConfigureStepProps = { readonly onImport: () => void; }; -// TEMP: an in-dialog layout switcher for comparing directions live — the ui.sh -// picker can't load under the app's CSP. Collapse to the chosen variant and -// delete this switcher before merge. +function FullDiskAccessStep({ + source, + onCancel, + onOpenSettings, + onGranted, +}: { + readonly source: BrowserImportSource; + readonly onCancel: () => void; + readonly onOpenSettings: () => void; + readonly onGranted: () => void; +}) { + return ( + <> + + Let T3 Code read {source.name}’s cookies + + {source.name} keeps its cookies somewhere only apps with Full Disk Access can reach. Turn + that on for T3 Code in System Settings, then come back and finish the import. + + + + + + + + + ); +} + function ConfigureStep({ source, targetProfiles, diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts new file mode 100644 index 00000000000..6a9a52680bb --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { importFailureReason } from "./IntegrationsSettings"; + +// Mirrors `BrowserImportFailedError.message`, which IPC flattens to a string +// before the renderer sees it. +const failure = (reason: string) => ({ + message: `Importing cookies from safari failed: ${reason}.`, +}); + +describe("importFailureReason", () => { + it("recovers the reason token from the flattened message", () => { + // The whole import error path — including the Full Disk Access dialog — + // depends on this token surviving the trip through IPC. + expect(importFailureReason(failure("needsFullDiskAccess"))).toBe("needsFullDiskAccess"); + expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning"); + expect(importFailureReason(failure("readFailed"))).toBe("readFailed"); + }); + + it("falls back to readFailed for anything it cannot classify", () => { + expect(importFailureReason(new Error("something else entirely"))).toBe("readFailed"); + expect(importFailureReason(undefined)).toBe("readFailed"); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 72632c48a5b..34d7a190062 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -49,6 +49,8 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; +import { readLocalApi } from "~/localApi"; + import { toastManager } from "../ui/toast"; import { AlertDialog, @@ -114,7 +116,7 @@ const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; * it. Anything unrecognised reads as a plain read failure rather than leaking * the raw message into a toast. */ -const importFailureReason = (cause: unknown): BrowserImportFailureReason => { +export const importFailureReason = (cause: unknown): BrowserImportFailureReason => { const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); return ( BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ?? @@ -517,6 +519,14 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode * files, and the answer changes while the app is running (quitting the browser * clears `browserRunning`), so a value cached at mount would go stale. */ +/** + * Opens System Settings → Privacy & Security → Full Disk Access. The scheme is + * unchanged from the old System Preferences and still resolves on Ventura and + * later. + */ +const FULL_DISK_ACCESS_SETTINGS_URL = + "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFilesAccess"; + function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const userProfiles = useClientSettings((settings) => settings.browserProfiles); const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); @@ -842,6 +852,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { canCreateProfile={!atProfileLimit} onImport={(input) => runWizardImport(importSource, input)} onRefreshSource={() => refreshImportSource(importSource.id)} + onOpenFullDiskAccessSettings={() => + void readLocalApi() + ?.shell.openExternal(FULL_DISK_ACCESS_SETTINGS_URL) + .catch(() => undefined) + } onClose={() => setImportSource(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 f8a7cd461dd..9924fdf127a 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.test.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -56,6 +56,12 @@ 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", + }); + }); + it("surfaces every other failure on the blocked screen", () => { expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({ step: "blocked", diff --git a/apps/web/src/components/settings/browserImportWizard.logic.ts b/apps/web/src/components/settings/browserImportWizard.logic.ts index c61e2a978a6..2f4984ede1e 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.ts @@ -21,6 +21,7 @@ export type ImportOutcome = */ export type WizardStep = | { readonly step: "quit" } + | { readonly step: "fullDiskAccess" } | { readonly step: "configure" } | { readonly step: "importing" } | { @@ -58,6 +59,7 @@ 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") return { step: "fullDiskAccess" }; return { step: "blocked", reason: outcome.reason }; } diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index f9804ae94c8..ac8bec427c7 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); @@ -43,6 +44,7 @@ export const BrowserImportUnavailableReason = Schema.Literals([ "needsKeychainApproval", "keychainItemMissing", "appBoundEncryption", + "needsFullDiskAccess", "browserRunning", "unsupportedPlatform", ]); @@ -134,6 +136,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< keychainItemMissing: "No encryption key in your Keychain — sign in to that browser once, then retry.", appBoundEncryption: "This browser binds its cookie key to itself, so no other app can read it.", + 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.", };