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