diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a34a55f16ac..71ae451f975 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "@clerk/electron": "catalog:", "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", + "@napi-rs/keyring": "^1.3.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 37fd873a1b0..5af68a1f50a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -95,4 +95,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } + yield* ipc.handle(PreviewIpc.listBrowserImportSources); + yield* ipc.handle(PreviewIpc.importBrowserCookies); }); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 02f9ad0df36..f6ebbef0e9b 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -58,6 +58,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; +export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources"; +export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies"; export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme"; export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index a930a056095..a158b4c22c9 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -14,7 +14,10 @@ import { DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetColorSchemeInputSchema, + BrowserImportResult, + BrowserImportSource, DesktopPreviewClearDataInputSchema, + DesktopPreviewImportCookiesInputSchema, DesktopPreviewCreateTabInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, @@ -29,6 +32,7 @@ import * as Schema from "effect/Schema"; import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; @@ -266,6 +270,37 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ }), }); +/** + * Registered separately from `methods`: these carry `BrowserImport` in their + * context and their own failure type, so they do not unify with the + * manager-backed handlers the shared loop iterates. + */ +export const listBrowserImportSources = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL, + payload: Schema.Void, + result: Schema.Array(BrowserImportSource), + handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () { + const browserImport = yield* BrowserImport.BrowserImport; + return yield* browserImport.listSources; + }), +}); + +export const importBrowserCookies = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, + payload: DesktopPreviewImportCookiesInputSchema, + result: BrowserImportResult, + handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({ + environmentId, + ...importInput + }) { + const browserImport = yield* BrowserImport.BrowserImport; + // Derived in main from the same helper the webview config uses, so cookies + // land in exactly the partition the profile's tabs attach to. + const { scope, persistent } = resolvePartitionScope(environmentId, importInput.targetProfileId); + return yield* browserImport.importCookies({ input: importInput, scope, persistent }); + }), +}); + export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, payload: DesktopPreviewAnnotationThemeInputSchema, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a..0620ccc0fba 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; @@ -148,6 +149,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( ); const desktopPreviewLayer = PreviewManager.layer.pipe( + // Merged rather than provided so the IPC handlers can reach the import + // service alongside the manager; both sit on the same BrowserSession. + Layer.provideMerge(BrowserImport.layer), Layer.provideMerge(BrowserSession.layer), Layer.provideMerge(desktopFoundationLayer), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ad7918f1f77..ce138de9e57 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -185,6 +185,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), + listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), + importBrowserCookies: (input) => + ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input), clearCookies: (environmentId, profileId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }), clearCache: (environmentId, profileId) => diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts new file mode 100644 index 00000000000..f7d1b02f6ca --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -0,0 +1,115 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as BrowserSession from "../BrowserSession.ts"; +import * as BrowserImport from "./BrowserImport.ts"; +import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; + +/** + * Dies if the import reaches session work: every case here covers a request + * that must be rejected before a cookie is read or written. + */ +const rejectedBeforeSession = Layer.succeed(BrowserSession.BrowserSession, { + derivePartition: () => Effect.die("derivePartition must not be reached"), + getSession: () => Effect.die("getSession must not be reached"), + clearStorage: () => Effect.die("clearStorage must not be reached"), + clearCache: () => Effect.die("clearCache must not be reached"), +} as unknown as BrowserSession.BrowserSession["Service"]); + +/** + * Builds the service against a scratch home containing an installed, closed + * copy of the source browser. + */ +const withImporter = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); + const environment = Layer.succeed(HostProcessEnvironment, { HOME: home }); + const paths = yield* sourcePaths.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + ); + yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, { + recursive: true, + }); + // The cookie database is what marks a source as installed, so a fixture + // without one is reported as absent before any other check runs. + yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db"); + + const importer = yield* BrowserImport.BrowserImport.pipe( + Effect.provide( + BrowserImport.layer.pipe( + Layer.provide(rejectedBeforeSession), + Layer.provide(environment), + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")), + Layer.provide(NodeServices.layer), + ), + ), + ); + return { importer, home, paths }; +}); + +describe("BrowserImport.importCookies", () => { + it.effect("rejects a source profile the browser never reported", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { importer, home } = yield* withImporter(); + + // A cookie database reachable on disk but outside the browser's + // user-data directory — the payoff a traversal would be after. + yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); + + const error = yield* importer + .importCookies({ + input: { + sourceId: "helium", + sourceProfileDirectory: "../../../../secrets", + targetProfileId: "default", + }, + scope: "persist:t3code-preview-test", + persistent: true, + }) + .pipe(Effect.flip); + + assert.instanceOf(error, BrowserImport.BrowserImportFailedError); + assert.equal(error.reason, "unknownSourceProfile"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("refuses to import while the source browser holds its profile", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { importer, paths } = yield* withImporter(); + // The lock Chromium leaves while it is running, dangling target and + // all. This must stop the import before it ever asks the keychain. + yield* fileSystem.symlink( + "host-that-does-not-exist-1234", + `${helium.userDataDirectory(paths)}/SingletonLock`, + ); + + const error = yield* importer + .importCookies({ + input: { + sourceId: "helium", + sourceProfileDirectory: "Default", + targetProfileId: "default", + }, + scope: "persist:t3code-preview-test", + persistent: true, + }) + .pipe(Effect.flip); + + assert.equal(error.reason, "browserRunning"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts new file mode 100644 index 00000000000..cd4ba0392da --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -0,0 +1,228 @@ +/** + * Browser import service - lists importable sources and writes their cookies + * into a T3 Code browser profile's Electron partition. + * + * @module BrowserImport + */ +import type { + BrowserImportInput, + BrowserImportResult, + BrowserImportSource, + BrowserImportUnavailableReason, +} from "@t3tools/contracts"; +import { BrowserImportFailureReason } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as BrowserSession from "../BrowserSession.ts"; +import { readChromiumCookies } from "./ChromiumCookies.ts"; +import { + BROWSER_IMPORT_SOURCES, + cookieDatabasePath, + isSourceInstalled, + isSourceRunning, + listSourceProfiles, + sourcePaths, + type BrowserImportSourceDefinition, + type SourcePaths, +} from "./Sources.ts"; + +export class BrowserImportFailedError extends Schema.TaggedErrorClass()( + "BrowserImportFailedError", + { + sourceId: Schema.String, + reason: BrowserImportFailureReason, + /** Kept for the log; the user only ever sees the reason's copy. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + // The reason token is part of the message on purpose: IPC flattens the error + // to its message, and the renderer maps that token back to user-facing copy. + override get message(): string { + return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`; + } +} + +export class BrowserImport extends Context.Service< + BrowserImport, + { + readonly listSources: Effect.Effect>; + readonly importCookies: (input: { + readonly input: BrowserImportInput; + /** Partition scope of the target profile, derived by the caller in main. */ + readonly scope: string; + readonly persistent: boolean; + }) => Effect.Effect; + } +>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} + +const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( + definition: BrowserImportSourceDefinition, + platform: NodeJS.Platform, + paths: SourcePaths, +): Effect.fn.Return { + if (!definition.platforms.includes(platform)) return "unsupportedPlatform"; + if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled"; + if (yield* isSourceRunning(definition, paths)) return "browserRunning"; + return undefined; +}); + +/** The host a constructed cookie URL points at, for naming what was skipped. */ +const cookieHost = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + +export const make = Effect.gen(function* BrowserImportMake() { + const browserSession = yield* BrowserSession.BrowserSession; + const platform = yield* HostProcessPlatform; + const executablePath = yield* HostProcessExecutablePath; + // Captured here so the service's methods stay free of a requirements + // channel: the layer is built where NodeServices is already in scope. + const platformServices = yield* Effect.context(); + const paths = yield* sourcePaths; + + const listSources: Effect.Effect> = Effect.forEach( + BROWSER_IMPORT_SOURCES, + Effect.fnUntraced(function* (definition) { + const unavailable = yield* unavailableReason(definition, platform, paths); + return { + id: definition.id, + name: definition.name, + // Listing profiles touches the source's own files, so skip it when the + // source is unusable anyway. + profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [], + ...(unavailable === undefined ? {} : { unavailable }), + } satisfies BrowserImportSource; + }), + ).pipe(Effect.provide(platformServices)); + + const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { + readonly input: BrowserImportInput; + readonly scope: string; + readonly persistent: boolean; + }) { + const definition = BROWSER_IMPORT_SOURCES.find( + (candidate) => candidate.id === input.input.sourceId, + ); + if (!definition) { + return yield* new BrowserImportFailedError({ + sourceId: input.input.sourceId, + reason: "unknownSource", + }); + } + + const blocked = yield* unavailableReason(definition, platform, paths).pipe( + Effect.provide(platformServices), + ); + if (blocked !== undefined) { + return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); + } + + // macOS attributes the Keychain prompt and the resulting ACL grant to the + // executable that asks, so record which one that was — in a packaged build + // it is the signed app, in dev whatever binary hosts the main process. + yield* Effect.logInfo("Reading browser cookie key from the keychain", { + sourceId: definition.id, + executablePath, + }); + + // The profile directory arrives over IPC, so it is only honoured when the + // source itself reported it. Forwarding it unchecked would let `..` + // segments walk out of the browser's user-data directory and read any + // cookie database reachable on disk. + const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe( + Effect.provide(platformServices), + ); + const requestedProfile = sourceProfiles.find( + (profile) => profile.directory === input.input.sourceProfileDirectory, + ); + if (requestedProfile === undefined) { + return yield* new BrowserImportFailedError({ + sourceId: definition.id, + reason: "unknownSourceProfile", + }); + } + + const read = yield* readChromiumCookies({ + cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory), + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + platform, + }).pipe( + Effect.scoped, + Effect.provide(platformServices), + Effect.mapError( + (cause) => + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), + ); + + const session = yield* browserSession.getSession(input.scope, input.persistent).pipe( + Effect.mapError( + (cause) => + new BrowserImportFailedError({ + sourceId: definition.id, + reason: "sessionUnavailable", + cause, + }), + ), + ); + + // Written one at a time rather than in parallel: Chromium's cookie store + // serialises writes anyway, and a rejected cookie should only cost itself. + let imported = 0; + // Rows the reader could not decrypt are already lost cookies, so they + // count as skipped rather than vanishing from the tally. + let skipped = read.undecryptable; + const skippedDomains = new Set(read.undecryptableHosts); + for (const cookie of read.cookies) { + const written = yield* Effect.tryPromise({ + try: () => + session.cookies.set({ + url: cookie.url, + name: cookie.name, + value: cookie.value, + // Omitted for host-only cookies: Electron reads any `domain` as a + // domain cookie and re-adds the leading dot, which would widen the + // scope of every host-only cookie the source had. + ...(cookie.domain === undefined ? {} : { domain: cookie.domain }), + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + ...(cookie.expirationDate === undefined + ? {} + : { expirationDate: cookie.expirationDate }), + }), + catch: () => undefined, + }).pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ); + if (written) { + imported += 1; + } else { + skipped += 1; + skippedDomains.add(cookieHost(cookie.url)); + } + } + + // Capped: a broken key can skip thousands, and the user only needs a sense + // of which sites didn't come over, not an exhaustive list. + return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; + }); + + return BrowserImport.of({ listSources, importCookies }); +}); + +export const layer = Layer.effect(BrowserImport, make); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts new file mode 100644 index 00000000000..46e0c02f239 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { cookieScope } from "./ChromiumCookies.ts"; + +describe("cookieScope", () => { + it("keeps a host-only cookie host-only", () => { + // Chromium stores a host-only cookie without a leading dot. Passing any + // `domain` to Electron makes it a domain cookie and re-adds the dot, which + // would expose the cookie to every subdomain it was never scoped to. + expect(cookieScope("example.test", "/", true)).toEqual({ + url: "https://example.test/", + domain: undefined, + }); + }); + + it("preserves a domain cookie's leading dot", () => { + expect(cookieScope(".example.test", "/app", true)).toEqual({ + url: "https://example.test/app", + domain: ".example.test", + }); + }); + + it("matches the scheme to the secure flag", () => { + expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts new file mode 100644 index 00000000000..d1462afc481 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -0,0 +1,352 @@ +// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the +// OSCrypt primitives Chromium uses; Effect has no equivalent. +/** + * Chromium cookie extraction. + * + * Reads a Chromium-family browser's cookie database and decrypts it with the + * key the OS keychain hands us, which is the mechanism the browser itself + * uses. macOS mediates that with a per-app consent prompt, so the user + * explicitly approves T3 Code reading it. + * + * Deliberately no fallback when the keychain says no: the alternative + * techniques exist to defeat that consent, and this feature is not worth + * shipping them. + * + * @module ChromiumCookies + */ +import * as Keyring from "@napi-rs/keyring"; +import * as NodeCrypto from "node:crypto"; + +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */ +const MAC_KEY_ITERATIONS = 1003; +const MAC_KEY_SALT = "saltysalt"; +const MAC_KEY_LENGTH = 16; +/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */ +const AES_IV = Buffer.alloc(16, 0x20); +const V10_PREFIX = "v10"; + +export interface ChromiumCookie { + readonly url: string; + readonly name: string; + readonly value: string; + /** + * Set only for domain cookies, which Chromium stores with a leading dot. + * A host-only cookie leaves this undefined: Electron treats any `domain` it + * is given as a domain cookie and re-adds the dot, which would widen the + * cookie to every subdomain of the host it was scoped to. + */ + readonly domain: string | undefined; + readonly path: string; + readonly secure: boolean; + readonly httpOnly: boolean; + /** Seconds since the UNIX epoch, or undefined for a session cookie. */ + readonly expirationDate: number | undefined; + readonly sameSite: "no_restriction" | "lax" | "strict"; +} + +export const ChromiumCookieReadReason = Schema.Literals([ + "needsKeychainApproval", + "keychainItemMissing", + "browserRunning", + "unsupportedPlatform", + "readFailed", +]); +export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; + +export class ChromiumCookieReadError extends Schema.TaggedErrorClass()( + "ChromiumCookieReadError", + { + reason: ChromiumCookieReadReason, + /** + * Which database the read was for. Without it every `readFailed` and + * keychain failure logs identically, and a user with several browsers + * installed has no way to tell which one refused. + */ + cookieDatabasePath: Schema.String, + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + } +} + +/** Row shape of the cookie table, decoded rather than cast. */ +const CookieRow = Schema.Struct({ + host_key: Schema.String, + name: Schema.String, + encrypted_value: Schema.Uint8Array, + path: Schema.String, + expires_seconds: Schema.Number, + is_secure: Schema.Number, + is_httponly: Schema.Number, + samesite: Schema.Number, +}); + +const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); + +/** + * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in + * modern Chromium, so it maps there rather than to `no_restriction`, which + * would widen the cookie's scope on import. + */ +const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => { + if (value === 0) return "no_restriction"; + if (value === 2) return "strict"; + return "lax"; +}; + +/** + * Chromium timestamps count microseconds from 1601-01-01; Electron wants + * seconds from the UNIX epoch. + * + * The microsecond value overflows JavaScript's safe integer range, and + * `node:sqlite` refuses to narrow it, so the division happens in SQL and this + * only ever sees seconds. + */ +const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; +const toUnixSeconds = (webkitSeconds: number): number | undefined => { + if (webkitSeconds <= 0) return undefined; + return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; +}; + +/** + * Reads the OSCrypt key from the login keychain. + * + * Uses the in-process Keychain API rather than shelling out to + * `/usr/bin/security`, because the keychain attributes both the consent prompt + * and the resulting ACL entry to the binary that asks. Via the CLI the prompt + * says "security" and "Always Allow" grants trust to a tool every process on + * the machine can invoke; in-process it names this app and the grant belongs + * to it. (In an unsigned dev build the name is the dev Electron binary rather + * than the shipped app identity.) + * + * Deliberately untimed: macOS answers this with a modal, and a timeout racing + * the user means the prompt can be approved while nothing is left listening — + * which reads as "approving did nothing". + */ +const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* ( + service: string, + account: string, + cookieDatabasePath: string, +) { + const password = yield* Effect.try({ + try: () => new Keyring.Entry(service, account).getPassword(), + catch: (cause) => { + const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); + // Distinguish the causes rather than telling the user to approve a + // prompt when approving cannot fix the failure. + const missing = /no (matching )?entry|not found/i.test(message); + return new ChromiumCookieReadError({ + reason: missing ? "keychainItemMissing" : "needsKeychainApproval", + cookieDatabasePath, + cause, + }); + }, + }); + if (password === null || password === "") { + return yield* new ChromiumCookieReadError({ + reason: "keychainItemMissing", + cookieDatabasePath, + }); + } + return password; +}); + +/** + * Chromium keeps the cookie DB open with WAL, and reading it in place can + * observe a torn state. Copying first — including the sidecars — gives a + * consistent snapshot without touching the browser's own files. + * + * Scoped: the temp directory is removed when the caller's scope closes. + */ +const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(function* ( + cookiePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-cookie-import-" }); + const target = path.join(directory, "Cookies"); + yield* fileSystem.copyFile(cookiePath, target); + // A sidecar only exists while the browser holds the database open, so an + // absent one is normal. Anything else — a permission error, a partial read — + // is not: SQLite would then open the snapshot without the write-ahead log + // and quietly return a cookie set missing its most recent transactions. + yield* Effect.forEach(["-wal", "-shm"], (suffix) => + fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.void, + ), + ), + ); + return target; +}); + +/** + * The URL and domain Electron should register a stored row under. + * + * Chromium marks a domain cookie with a leading dot on `host_key`. Electron + * matches on a URL, so the dot comes off for that; `domain` is passed through + * only for domain cookies, because supplying it at all makes Electron treat + * the cookie as one and re-add the dot — widening a host-only cookie to every + * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, + * which require it to be absent. + */ +export const cookieScope = ( + hostKey: string, + path: string, + secure: boolean, +): { readonly url: string; readonly domain: string | undefined } => { + const isDomainCookie = hostKey.startsWith("."); + const host = isDomainCookie ? hostKey.slice(1) : hostKey; + return { + url: `${secure ? "https" : "http"}://${host}${path}`, + ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }), + }; +}; + +/** The host without Chromium's domain-cookie leading dot, for display. */ +const bareHost = (hostKey: string): string => + hostKey.startsWith(".") ? hostKey.slice(1) : hostKey; + +const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => { + const buffer = Buffer.from(encrypted); + if (buffer.length === 0) return ""; + if (buffer.subarray(0, 3).toString("latin1") !== V10_PREFIX) return null; + + try { + const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV); + decipher.setAutoPadding(true); + let plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]); + // Chromium >= 127 prefixes the plaintext with SHA-256 of the host key to + // bind a cookie to its domain; strip it when present. + const domainHash = NodeCrypto.createHash("sha256").update(domain).digest(); + if (plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)) { + plaintext = plaintext.subarray(32); + } + return plaintext.toString("utf8"); + } catch { + return null; + } +}; + +/** + * What a reader produces: the cookies it could recover, and how many stored + * rows it could not. The count reaches the user as part of the skipped total + * rather than disappearing. + */ +export interface CookieReadResult { + readonly cookies: ReadonlyArray; + readonly undecryptable: number; + /** Distinct hosts of the rows that could not be decrypted. */ + readonly undecryptableHosts: ReadonlyArray; +} + +export interface ChromiumCookieSource { + readonly cookieDatabasePath: string; + readonly keychainService: string; + readonly keychainAccount: string; + /** Supplied by the caller from `HostProcessPlatform` rather than read here. */ + readonly platform: NodeJS.Platform; +} + +export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( + source: ChromiumCookieSource, +) { + if (source.platform !== "darwin") { + // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on + // current Chrome) each need their own key path; only macOS is implemented. + return yield* new ChromiumCookieReadError({ + reason: "unsupportedPlatform", + cookieDatabasePath: source.cookieDatabasePath, + }); + } + + const password = yield* readMacKeychainPassword( + source.keychainService, + source.keychainAccount, + source.cookieDatabasePath, + ); + const key = NodeCrypto.pbkdf2Sync( + password, + MAC_KEY_SALT, + MAC_KEY_ITERATIONS, + MAC_KEY_LENGTH, + "sha1", + ); + + const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); + + const rows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const raw = yield* sql` + select host_key, name, encrypted_value, path, + expires_utc / 1000000 as expires_seconds, + is_secure, is_httponly, samesite + from cookies + `; + return yield* decodeCookieRows(raw); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), + ); + + const cookies: ChromiumCookie[] = []; + // Counted, not swallowed. A row we hold no usable key for is a cookie the + // user does not get, and reporting an import that quietly dropped most of + // its rows as a clean success is the worst of the options. + let undecryptable = 0; + const undecryptableHosts = new Set(); + for (const row of rows) { + const value = decryptValue(row.encrypted_value, key, row.host_key); + if (value === null) { + undecryptable += 1; + undecryptableHosts.add(bareHost(row.host_key)); + continue; + } + const secure = row.is_secure === 1; + const scope = cookieScope(row.host_key, row.path, secure); + cookies.push({ + url: scope.url, + name: row.name, + value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.is_httponly === 1, + expirationDate: toUnixSeconds(row.expires_seconds), + sameSite: sameSiteFromColumn(row.samesite), + }); + } + return { + cookies, + undecryptable, + undecryptableHosts: [...undecryptableHosts], + } satisfies CookieReadResult; +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts new file mode 100644 index 00000000000..34d04fca1fc --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -0,0 +1,220 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie +// table with the same native bindings the source reads. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as NodeSqlite from "node:sqlite"; + +import { + BROWSER_IMPORT_SOURCES, + cookieDatabasePath, + isSourceInstalled, + isSourceRunning, + listSourceProfiles, + sourcePaths, +} from "./Sources.ts"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; + +/** A scratch home with the source's user-data directory already created. */ +const withSourceHome = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); + const paths = yield* sourcePaths.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + ); + yield* fileSystem.makeDirectory(helium.userDataDirectory(paths), { recursive: true }); + return paths; +}); + +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +/** Writes a Chromium-shaped cookie table with `count` rows. */ +const writeCookieDatabase = (file: string, count: number) => + Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(file); + database.exec("create table cookies (host_key text, name text)"); + const insert = database.prepare("insert into cookies (host_key, name) values (?, ?)"); + for (let index = 0; index < count; index += 1) insert.run("example.test", `c${index}`); + database.close(); + }); + +describe("isSourceRunning", () => { + it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + assert.isFalse(yield* isSourceRunning(helium, paths)); + + // Chromium points the lock at `-`, a target that never + // exists on disk. A check that follows the link reports a running + // browser as closed, letting an import read a live, mid-write database. + yield* fileSystem.symlink( + "host-that-does-not-exist-1234", + `${helium.userDataDirectory(paths)}/SingletonLock`, + ); + + assert.isTrue(yield* isSourceRunning(helium, paths)); + }), + ), + ); +}); + +describe("isSourceInstalled", () => { + it.effect("ignores a user-data directory that holds no cookie database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + + // Installers for native messaging hosts create an empty user-data + // directory for every Chromium fork they know about, so treating the + // directory as evidence lists browsers the user does not have. + yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); + assert.isFalse(yield* isSourceInstalled(helium, paths)); + + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, paths)); + + // A real install whose cookies live outside `Default` still counts: + // reporting it as absent hides the source from the menu entirely. + yield* fileSystem.remove(`${root}/Default`, { recursive: true }); + yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); + assert.isTrue(yield* isSourceInstalled(helium, paths)); + + yield* fileSystem.remove(root, { recursive: true }); + assert.isFalse(yield* isSourceInstalled(helium, paths)); + }), + ), + ); +}); + +describe("listSourceProfiles", () => { + it.effect("discovers profiles by their cookie database when Local State is absent", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + // Assuming `Default` would report a browser whose cookies live in + // `Profile 1` as having nothing to import, and it is then hidden. + yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); + yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); + + assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + { directory: "Profile 1", name: "Profile 1" }, + ]); + }), + ), + ); + + it.effect("reads the profile names the browser shows", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + yield* fileSystem.writeFileString( + `${helium.userDataDirectory(paths)}/Local State`, + `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`, + ); + + assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + { directory: "Default", name: "You" }, + // Blank display name falls back to the directory rather than + // rendering an empty row. + { directory: "Profile 2", name: "Profile 2" }, + ]); + }), + ), + ); + + it.effect("scans for profiles when Local State is malformed", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json"); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); + + assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + { directory: "Default", name: "Default" }, + ]); + }), + ), + ); + + it.effect("reports nothing when no directory holds a cookie database", () => + run( + Effect.gen(function* () { + const paths = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(helium, paths), []); + }), + ), + ); + + it.effect("counts a profile's cookies without decrypting them", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + const root = helium.userDataDirectory(paths); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* writeCookieDatabase(`${root}/Default/Cookies`, 3); + + const [profile] = yield* listSourceProfiles(helium, paths); + assert.equal(profile?.cookieCount, 3); + }), + ), + ); +}); + +describe("cookieDatabasePath", () => { + it.effect("places the database under the requested source profile", () => + run( + Effect.gen(function* () { + const paths = yield* withSourceHome(); + assert.equal( + cookieDatabasePath(helium, paths, "Profile 1"), + `${paths.home}/Library/Application Support/net.imput.helium/Profile 1/Cookies`, + ); + }), + ), + ); +}); + +describe("listSourceProfiles hardening", () => { + it.effect("drops profile directories that are not a single plain segment", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + // `Local State` is writable by anything running as the user, so a + // crafted key must not reach `cookieDatabasePath` and read a database + // outside the browser's user-data directory. + yield* fileSystem.writeFileString( + `${helium.userDataDirectory(paths)}/Local State`, + `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, + ); + + const profiles = yield* listSourceProfiles(helium, paths); + + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["Default"], + ); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts new file mode 100644 index 00000000000..206800b5f82 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -0,0 +1,234 @@ +/** + * Importable browser sources. + * + * Each entry pins its own on-disk and keychain coordinates rather than + * deriving them: Chromium forks do not agree on the convention. Helium, for + * instance, uses the keychain service "Helium Storage Key" / account "Helium" + * where Chrome and its closer relatives use " Safe Storage" / "". + * + * @module BrowserImportSources + */ +import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Where a source's files live, resolved once per call rather than read from + * the ambient process so the registry stays testable. + */ +export interface SourcePaths { + readonly path: Path.Path; + readonly home: string; +} + +export const sourcePaths = Effect.gen(function* () { + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths; +}); + +export interface BrowserImportSourceDefinition { + readonly id: BrowserImportSourceId; + readonly name: string; + /** Platforms the definition's paths are valid for. */ + readonly platforms: ReadonlyArray; + readonly userDataDirectory: (paths: SourcePaths) => string; + readonly keychainService: string; + readonly keychainAccount: string; +} + +export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ + { + id: "helium", + name: "Helium", + platforms: ["darwin"], + userDataDirectory: ({ path, home }) => + path.join(home, "Library", "Application Support", "net.imput.helium"), + keychainService: "Helium Storage Key", + keychainAccount: "Helium", + }, +]; + +export const cookieDatabasePath = ( + definition: BrowserImportSourceDefinition, + paths: SourcePaths, + profileDirectory: string, +): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies"); + +/** Shape of the slice of Chromium's `Local State` that names its profiles. */ +const LocalState = Schema.Struct({ + profile: Schema.optional( + Schema.Struct({ + info_cache: Schema.optional( + Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })), + ), + }), + ), +}); +const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); + +/** A single plain path segment: no separators, no `.`/`..`, not empty. */ +const isSafeProfileDirectory = (directory: string): boolean => + directory.length > 0 && + directory !== "." && + directory !== ".." && + !/[\\/]/.test(directory) && + !directory.includes("\u0000"); + +const CookieCountRow = Schema.Struct({ count: Schema.Number }); +const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); + +/** + * How many cookies a profile holds, counted without decrypting anything — a + * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium + * database (Firefox's table is named differently, Safari's is not SQL) yields + * `undefined` rather than failing the listing. + */ +const countProfileCookies = Effect.fnUntraced(function* ( + definition: BrowserImportSourceDefinition, + paths: SourcePaths, + directory: string, +): Effect.fn.Return { + return yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql`select count(*) as count from cookies`; + const [row] = yield* decodeCookieCount(rows); + return row?.count; + }).pipe( + Effect.provide( + NodeSqliteClient.layer({ + filename: cookieDatabasePath(definition, paths, directory), + readonly: true, + }), + ), + Effect.orElseSucceed(() => undefined), + ); +}); + +const withCookieCounts = ( + definition: BrowserImportSourceDefinition, + paths: SourcePaths, + profiles: ReadonlyArray, +) => + Effect.forEach(profiles, (profile) => + countProfileCookies(definition, paths, profile.directory).pipe( + Effect.map((cookieCount) => + cookieCount === undefined ? profile : { ...profile, cookieCount }, + ), + ), + ); + +/** + * Profiles the source browser knows about, read from its `Local State`. + * + * When that file is missing, unreadable or malformed, the user-data directory + * is scanned for directories that hold a cookie database. Assuming `Default` + * instead would report a browser whose cookies live in `Profile 1` as having + * nothing to import — and it is then left out of the menu entirely. + */ +export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( + definition: BrowserImportSourceDefinition, + paths: SourcePaths, +) { + const fileSystem = yield* FileSystem.FileSystem; + const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State"); + + const root = definition.userDataDirectory(paths); + const declared = yield* fileSystem.readFileString(localStatePath).pipe( + Effect.flatMap(decodeLocalState), + Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), + // The keys are directory names from the browser's own metadata file, which + // is writable by anything running as the user. Anything but a single plain + // segment is dropped: `..` or a path separator would otherwise be handed + // to `cookieDatabasePath` and read a database outside the user-data + // directory. + Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), + Effect.map((entries) => + entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared); + + // `Local State` is missing, unreadable or malformed. Scanning for + // directories that hold a cookie database finds the profiles anyway; + // assuming `Default` would report a browser whose cookies live in + // `Profile 1` as having nothing to import, and it is then hidden entirely. + const entries = yield* fileSystem + .readDirectory(root) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const candidates = entries.filter(isSafeProfileDirectory); + const found = yield* Effect.forEach(candidates, (directory) => + entryExists(cookieDatabasePath(definition, paths, directory)).pipe( + Effect.map((exists) => (exists ? { directory, name: directory } : undefined)), + ), + ); + return yield* withCookieCounts( + definition, + paths, + found.filter((profile) => profile !== undefined), + ); +}); + +/** + * Whether a directory entry exists, without following it or opening it. + * + * `stat` resolves symlinks and the locks below deliberately dangle, so + * `readLink` is the probe that answers for the entry itself. + */ +const entryExists = Effect.fnUntraced(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.stat(path).pipe( + Effect.catchCause(() => fileSystem.readLink(path)), + Effect.as(true), + Effect.orElseSucceed(() => false), + ); +}); + +/** Whether the browser is running, which leaves its cookie DB mid-write. */ +export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( + definition: BrowserImportSourceDefinition, + paths: SourcePaths, +) { + const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock"); + // Chromium writes a `SingletonLock` symlink for as long as an instance holds + // the profile. Its presence is a far cheaper and more targeted signal than + // scanning the process table for a name. + // + // The link points at `-`, a target that never exists, and both + // `stat` and `exists` follow links — so they report every running browser as + // closed, which would let an import read a live, mid-write database. + // `readLink` is the one probe that answers for the entry itself. + return yield* entryExists(lock); +}); + +/** + * Whether the source has cookies to import. + * + * Keyed off the cookie database rather than the user-data directory, because + * that directory is not evidence the browser exists: installers for native + * messaging hosts create an empty one for every Chromium fork they know about, + * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as + * present. The database is the thing an import actually needs, so its absence + * is the honest answer either way. + * + * Existence is checked without opening the file, which matters for Safari: TCC + * permits `stat` on the jar inside its container but refuses a read, so this + * still sees it and the user gets the Full Disk Access prompt rather than + * having Safari disappear. + */ +export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( + definition: BrowserImportSourceDefinition, + paths: SourcePaths, +) { + const profiles = yield* listSourceProfiles(definition, paths); + const found = yield* Effect.forEach(profiles, (profile) => + entryExists(cookieDatabasePath(definition, paths, profile.directory)), + ); + return found.some(Boolean); +}); diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx new file mode 100644 index 00000000000..f7b58ce0d6a --- /dev/null +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -0,0 +1,406 @@ +import type { BrowserImportSource } from "@t3tools/contracts"; +import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts"; +import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { cn, randomUUID } from "~/lib/utils"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Spinner } from "../ui/spinner"; +import { + initialWizardStep, + isRetryableReason, + formatSkippedDomains, + outcomeToStep, + refreshedSourceStep, + type ImportOutcome, + type WizardStep, +} from "./browserImportWizard.logic"; + +/** A profile the import can land in. */ +export interface WizardTargetProfile { + readonly id: string; + readonly name: string; +} + +/** The target the user picked: a brand-new profile, or an existing one. */ +export type WizardTarget = + | { readonly kind: "new"; readonly profileId: string } + | { readonly kind: "existing"; readonly profileId: string; readonly name: string }; + +const NEW_TARGET_VALUE = "new"; + +interface BrowserImportWizardProps { + readonly source: BrowserImportSource; + /** Existing profiles the import can go into. Incognito is excluded upstream. */ + readonly targetProfiles: ReadonlyArray; + /** Whether a new profile can still be created (profile cap). */ + readonly canCreateProfile: boolean; + /** + * Runs the import and returns how it went. For a new target the caller only + * registers the profile once the import succeeds, so a blocked attempt never + * leaves an empty profile behind. + */ + readonly onImport: (input: { + readonly sourceProfileDirectory: string; + readonly target: WizardTarget; + }) => Promise; + /** Re-checks the source's availability after the user quits the browser. */ + readonly onRefreshSource: () => Promise; + readonly onClose: () => void; +} + +/** + * Guides one browser's cookies into a profile. + * + * Every state the import can be in — the browser is open, a profile has to be + * chosen, the read failed — is a screen the user can move forward from, rather + * than a disabled row that only says no. + */ +export function BrowserImportWizard({ + source: initialSource, + targetProfiles, + canCreateProfile, + onImport, + onRefreshSource, + onClose, +}: BrowserImportWizardProps) { + const [source, setSource] = useState(initialSource); + const [step, setStep] = useState(() => initialWizardStep(initialSource)); + const [sourceProfileDirectory, setSourceProfileDirectory] = useState( + () => initialSource.profiles[0]?.directory ?? "", + ); + const [target, setTarget] = useState( + canCreateProfile ? NEW_TARGET_VALUE : (targetProfiles[0]?.id ?? NEW_TARGET_VALUE), + ); + // Stable across retries so a Full Disk Access round-trip (added on the Safari + // branch) or a keychain re-approval lands in one profile, not a new one each + // time. + const newProfileId = useRef(`profile-${randomUUID()}`); + + const runImport = () => { + setStep({ step: "importing" }); + const chosen: WizardTarget = + target === NEW_TARGET_VALUE + ? { kind: "new", profileId: newProfileId.current } + : { + kind: "existing", + profileId: target, + name: targetProfiles.find((profile) => profile.id === target)?.name ?? "", + }; + void onImport({ sourceProfileDirectory, target: chosen }) + .then((outcome) => setStep(outcomeToStep(outcome))) + .catch(() => setStep({ step: "blocked", reason: "readFailed" })); + }; + + const recheckAfterQuit = () => { + setStep({ step: "importing" }); + void onRefreshSource() + .then((refreshed) => { + if (refreshed) { + setSource(refreshed); + setSourceProfileDirectory(refreshed.profiles[0]?.directory ?? sourceProfileDirectory); + } + setStep(refreshedSourceStep(refreshed)); + }) + .catch(() => setStep({ step: "blocked", reason: "readFailed" })); + }; + + return ( + (open ? undefined : onClose())}> + + {step.step === "quit" ? ( + + ) : step.step === "importing" ? ( + + ) : step.step === "done" ? ( + + ) : step.step === "blocked" ? ( + + ) : ( + + )} + + + ); +} + +function QuitStep({ + source, + onCancel, + onRechecked, +}: { + readonly source: BrowserImportSource; + readonly onCancel: () => void; + readonly onRechecked: () => void; +}) { + return ( + <> + + Quit {source.name} to import + + {source.name} is open, so its cookies can’t be read yet. Quit it, then continue. + + + + + + + + ); +} + +/** "5,065 cookies", or "no cookies", or nothing when the store is unreadable. */ +function cookieCountLabel(count: number | undefined): string | undefined { + if (count === undefined) return undefined; + if (count === 0) return "no cookies"; + return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`; +} + +type ConfigureStepProps = { + readonly source: BrowserImportSource; + readonly targetProfiles: ReadonlyArray; + readonly canCreateProfile: boolean; + readonly sourceProfileDirectory: string; + readonly onSourceProfileChange: (directory: string) => void; + readonly target: string; + readonly onTargetChange: (target: string) => void; + readonly onCancel: () => void; + readonly onImport: () => void; +}; + +// TEMP: an in-dialog layout switcher for comparing directions live — the ui.sh +// picker can't load under the app's CSP. Collapse to the chosen variant and +// delete this switcher before merge. +function ConfigureStep({ + source, + targetProfiles, + canCreateProfile, + sourceProfileDirectory, + onSourceProfileChange, + target, + onTargetChange, + onCancel, + onImport, +}: ConfigureStepProps) { + return ( + <> + + Import from {source.name} + Cookies flow from the browser into a profile here. + + + {/* Side by side when the dialog has room, stacked when it doesn't. */} +
+
+

+ From +

+ {source.profiles.map((profile) => ( + onSourceProfileChange(profile.directory)} + /> + ))} +
+
+ + +
+
+

+ Into +

+ {canCreateProfile ? ( + onTargetChange(NEW_TARGET_VALUE)} + /> + ) : null} + {targetProfiles.map((profile) => ( + onTargetChange(profile.id)} + /> + ))} +
+
+
+ + + ); +} + +/** Shared footer so the step keeps one set of actions. */ +function ConfigureFooter({ + onCancel, + onImport, +}: { + readonly onCancel: () => void; + readonly onImport: () => void; +}) { + return ( + + + + + ); +} + +/** One selectable option: a name, an optional detail line, and a check. */ +function SelectableTile({ + selected, + title, + subtitle, + onSelect, +}: { + readonly selected: boolean; + readonly title: string; + readonly subtitle?: string | undefined; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +function ImportingStep() { + return ( + + + Importing cookies… + + ); +} + +function DoneStep({ + imported, + skipped, + skippedDomains, + targetName, + onClose, +}: { + readonly imported: number; + readonly skipped: number; + readonly skippedDomains: ReadonlyArray; + readonly targetName: string; + readonly onClose: () => void; +}) { + return ( + <> + + + {imported > 0 ? `Imported ${imported} cookies` : "Nothing to import"} + + + {imported > 0 + ? `Into ${targetName}.${skipped > 0 ? ` ${skipped} couldn't be brought over.` : ""}` + : "There were no cookies to bring over."} + + + {skippedDomains.length > 0 ? ( + +

+ Skipped +

+

{formatSkippedDomains(skippedDomains)}

+
+ ) : null} + + } onClick={onClose}> + Done + + + + ); +} + +function BlockedStep({ + source, + reason, + onClose, + onRetry, +}: { + readonly source: BrowserImportSource; + readonly reason: keyof typeof BROWSER_IMPORT_FAILURE_COPY; + readonly onClose: () => void; + readonly onRetry: (() => void) | undefined; +}) { + return ( + <> + + Couldn’t import from {source.name} + {BROWSER_IMPORT_FAILURE_COPY[reason]} + + + + {onRetry ? : null} + + + ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 34f3d71f43b..72632c48a5b 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,6 +7,7 @@ * @module IntegrationsSettings */ import { + BrowserImportFailureReason, BROWSER_PROFILE_MAX_COUNT, type BrowserProfile, BROWSER_PROFILE_NAME_MAX_LENGTH, @@ -24,13 +25,13 @@ import { findBrowserProfile, isBuiltInBrowserProfileId, resolveBrowserProfiles, + type BrowserImportSource, type PreviewAppearancePreference, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; -import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react"; -import { useState } from "react"; -import type { ReactNode } from "react"; +import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { previewBridge } from "~/components/preview/previewBridge"; @@ -39,6 +40,16 @@ import { usePrimaryEnvironment } from "~/state/environments"; import { isElectron } from "../../env"; import { Badge } from "../ui/badge"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; +import { toastManager } from "../ui/toast"; import { AlertDialog, AlertDialogClose, @@ -74,8 +85,9 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; -import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { searchableSetting } from "./settingsSearch"; +import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; +import type { ImportOutcome } from "./browserImportWizard.logic"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; @@ -97,6 +109,19 @@ const APPEARANCE_LABELS: Readonly> = const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`; +/** + * IPC flattens the failure to its message, so the reason token travels inside + * it. Anything unrecognised reads as a plain read failure rather than leaking + * the raw message into a toast. + */ +const importFailureReason = (cause: unknown): BrowserImportFailureReason => { + const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); + return ( + BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ?? + "readFailed" + ); +}; + const viewportSelectValue = (viewport: PreviewViewportSetting): string => { if (viewport._tag === "fill") return FILL_VALUE; if ( @@ -477,30 +502,56 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode } /** - * Create, rename, and remove browser profiles. + * Profile list, its header menu, and the import flow. + * + * One menu creates profiles and imports into them, because the two are the + * same decision from the user's side: "I want a profile that has my Helium + * logins in it". Import targets include "New profile" so that case does not + * require creating one first and then finding a second control. + * + * Built-ins render without a rename field: they are synthesized rather than + * stored, so there is nothing to rename and removing them would strand every + * tab that opened under them. * - * Built-ins render without controls: they are synthesized rather than stored, - * so there is nothing to rename and removing them would strand every tab that - * opened under them. + * Sources are listed lazily on open: detection touches the other browser's + * files, and the answer changes while the app is running (quitting the browser + * clears `browserRunning`), so a value cached at mount would go stale. */ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const userProfiles = useClientSettings((settings) => settings.browserProfiles); const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); const updateSettings = useUpdatePrimarySettings(); const environmentId = usePrimaryEnvironment()?.environmentId; + const [sources, setSources] = useState | null>(null); + const [importSource, setImportSource] = useState(null); const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); - const addProfile = () => { - if (userProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return; - const taken = new Set(resolveBrowserProfiles(userProfiles).map((profile) => profile.name)); - let name = "New profile"; - for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`; - updateSettings({ - browserProfiles: [ - ...userProfiles, - { id: `profile-${randomUUID()}`, name, kind: "persistent" as const }, - ], - }); + const profiles = resolveBrowserProfiles(userProfiles); + // Incognito is deliberately not a row — it holds nothing to manage — so the + // default has to resolve against the list that renders. A stored + // `browserDefaultProfileId` of "incognito" would otherwise leave the section + // with no Default badge at all. + const listedProfiles = profiles.filter((profile) => profile.kind !== "incognito"); + const resolvedDefaultId = + findBrowserProfile(listedProfiles, defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID; + + const uniqueName = (base: string) => { + const taken = new Set(profiles.map((profile) => profile.name)); + if (!taken.has(base)) return base; + for (let index = 2; ; index += 1) { + const candidate = `${base} ${index}`; + if (!taken.has(candidate)) return candidate; + } + }; + + const createProfile = (name: string) => { + const profile = { + id: `profile-${randomUUID()}`, + name: uniqueName(name), + kind: "persistent" as const, + }; + updateSettings({ browserProfiles: [...userProfiles, profile] }); + return profile; }; const renameProfile = (id: string, next: string) => { @@ -513,6 +564,30 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }); }; + const clearProfileData = (id: string, name: string) => { + // Reported rather than ignored: the menu item stays enabled in this + // window, so bailing silently reads as a dead control. Matches what + // `importInto` says for the same precondition. + if (!environmentId || !previewBridge) { + toastManager.add({ + type: "error", + title: `Could not clear ${name}'s data`, + description: "No environment is connected yet.", + }); + return; + } + void Promise.all([ + previewBridge.clearCookies(environmentId, id), + previewBridge.clearCache(environmentId, id), + ]) + .then(() => { + toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` }); + }) + .catch(() => { + toastManager.add({ type: "error", title: `Could not clear ${name}'s data` }); + }); + }; + const removeProfile = (id: string) => { setProfilePendingRemoval(null); // Drop the partition's data too, otherwise a removed profile's cookies @@ -523,91 +598,212 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } updateSettings({ browserProfiles: userProfiles.filter((profile) => profile.id !== id), - // Reassign the default rather than leaving it pointing at nothing. ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}), }); }; + // A browser that is not on this machine is left out rather than listed as a + // dead row: there is nothing to act on, and the menu is a list of things you + // can import from. Every other unavailable reason stays, since each names a + // step the user can take. + const importableSources = (sources ?? []).filter( + (source) => source.unavailable !== "notInstalled", + ); + + // Refreshed without blanking the last result: the menu shows the cached list + // straight away so it doesn't reflow on open, and the source list is stable + // (names only) since choosing what to import happens in the wizard, not here. + const loadSources = () => { + if (!previewBridge) return; + void previewBridge + .listBrowserImportSources() + .then(setSources) + .catch(() => setSources((previous) => previous ?? [])); + }; + + // Loaded once so the first open is instant instead of flashing a spinner. + useEffect(() => { + loadSources(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Runs one import for the wizard. A new profile is registered only once the + // import succeeds — the cookies land in its partition first — so a blocked + // attempt never leaves an empty profile behind. + const runWizardImport = async ( + source: BrowserImportSource, + input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget }, + ): Promise => { + if (!environmentId || !previewBridge) return { kind: "blocked", reason: "sessionUnavailable" }; + try { + const result = await previewBridge.importBrowserCookies({ + environmentId, + sourceId: source.id, + sourceProfileDirectory: input.sourceProfileDirectory, + targetProfileId: input.target.profileId, + }); + let targetName: string; + if (input.target.kind === "new") { + targetName = uniqueName(source.name); + // Registered only when something actually came over: an import that + // found no cookies should not leave a new, empty profile behind. + if (result.imported > 0) { + updateSettings({ + browserProfiles: [ + ...userProfiles, + { id: input.target.profileId, name: targetName, kind: "persistent" as const }, + ], + }); + } + } else { + targetName = input.target.name; + } + return { + kind: "imported", + imported: result.imported, + skipped: result.skipped, + skippedDomains: result.skippedDomains, + targetName, + }; + } catch (cause) { + return { kind: "blocked", reason: importFailureReason(cause) }; + } + }; + + // Re-checks a source's availability after the user quits the browser, and + // keeps the cached list in step so the menu reflects it too. + const refreshImportSource = async ( + sourceId: BrowserImportSource["id"], + ): Promise => { + if (!previewBridge) return undefined; + try { + const latest = await previewBridge.listBrowserImportSources(); + setSources(latest); + return latest.find((source) => source.id === sourceId); + } catch { + return undefined; + } + }; + + const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT; + return ( = BROWSER_PROFILE_MAX_COUNT} - onClick={addProfile} - > - - Add profile - + open && loadSources()}> + }> + + Add profile + + + createProfile("New profile")}> + Blank profile + + + + Import from + {sources === null ? ( + Looking for browsers… + ) : importableSources.length === 0 ? ( + No supported browsers found + ) : ( + // Every source is a plain row — running, needs-permission and + // ready all look the same here. The wizard picks up whatever + // state the source is in and walks the user forward from there. + importableSources.map((source) => ( + setImportSource(source)}> + {source.name} + + )) + )} + + + } > {/* - Each profile is its own bounded row, and the list carries the bottom - spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows - stack on narrow viewports with a larger gap inside a row than between - rows, which reads as the remove button belonging to the profile below. + The bordered container groups rows unambiguously at any width, and + carries the bottom spacing `SettingsRow` leaves to its children + (`pt-3 pb-1`). */} -
- {resolveBrowserProfiles(userProfiles).map((profile) => { +
+ {listedProfiles.map((profile, index) => { const builtIn = isBuiltInBrowserProfileId(profile.id); + const isDefault = profile.id === resolvedDefaultId; return (
0 && "border-t border-border/60", )} > - {builtIn ? ( - // Dimmed here rather than on the list, which is the only - // content in the row without a disabled treatment of its own: - // a wrapper-level dim would stack with the rename field's and - // the remove button's, landing them near 0.41 while every - // other disabled control in the block sits at 0.64. - - {profile.name} - - {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} - - - ) : ( - renameProfile(profile.id, next)} - /> - )} - {builtIn ? null : ( - - setProfilePendingRemoval(profile)} - > - - - } + + {builtIn ? ( + // Dimmed here rather than on the table: a wrapper-level dim + // stacks with the rename field's and the row menu button's + // own, landing them near 0.41 while every other disabled + // control in the block sits at 0.64. + + {profile.name} + + ) : ( + renameProfile(profile.id, next)} /> - Remove profile and its data - - )} + )} + {/* + Dimmed with the rest of the row: a `Badge` has no disabled + treatment of its own, so a solid `bg-primary` pill would + otherwise sit at full strength beside a name, rename field + and menu button that are all at 0.64. + */} + {isDefault ? Default : null} + + + + } + > + + + + updateSettings({ browserDefaultProfileId: profile.id })} + > + Set as default + + clearProfileData(profile.id, profile.name)}> + Clear cookies and cache + + {builtIn ? null : ( + setProfilePendingRemoval(profile)} + > + Remove profile and data + + )} + +
); })} @@ -639,64 +835,26 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { + {importSource ? ( + ({ id: profile.id, name: profile.name }))} + canCreateProfile={!atProfileLimit} + onImport={(input) => runWizardImport(importSource, input)} + onRefreshSource={() => refreshImportSource(importSource.id)} + onClose={() => setImportSource(null)} + /> + ) : null} ); } -function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) { - const userProfiles = useClientSettings((settings) => settings.browserProfiles); - const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId); - const updateSettings = useUpdatePrimarySettings(); - // Incognito is deliberately absent: as a default it would open every tab - // into storage that is discarded on close. - const profiles = resolveBrowserProfiles(userProfiles).filter( - (profile) => profile.kind !== "incognito", - ); - const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0]; - - return ( - updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID })} - /> - ) : null - } - control={ - - } - /> - ); -} - export function IntegrationsSettingsPanel() { // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( <> - diff --git a/apps/web/src/components/settings/browserImportWizard.logic.test.ts b/apps/web/src/components/settings/browserImportWizard.logic.test.ts new file mode 100644 index 00000000000..f8a7cd461dd --- /dev/null +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { BrowserImportSource } from "@t3tools/contracts"; + +import { + initialWizardStep, + isRetryableReason, + formatSkippedDomains, + outcomeToStep, + refreshedSourceStep, +} from "./browserImportWizard.logic"; + +const source = (over: Partial = {}): BrowserImportSource => ({ + id: "helium", + name: "Helium", + profiles: [{ directory: "Default", name: "You" }], + ...over, +}); + +describe("initialWizardStep", () => { + it("opens on the quit screen when the browser is running", () => { + expect(initialWizardStep(source({ unavailable: "browserRunning" }))).toEqual({ step: "quit" }); + }); + + it("opens on configure when the source is ready", () => { + expect(initialWizardStep(source())).toEqual({ step: "configure" }); + }); + + it("blocks on a reason nothing local can fix", () => { + expect(initialWizardStep(source({ unavailable: "unsupportedPlatform" }))).toEqual({ + step: "blocked", + reason: "unsupportedPlatform", + }); + }); +}); + +describe("outcomeToStep", () => { + it("lands on done after a successful import", () => { + expect( + outcomeToStep({ + kind: "imported", + imported: 12, + skipped: 3, + skippedDomains: ["example.com"], + targetName: "Work", + }), + ).toEqual({ + step: "done", + imported: 12, + skipped: 3, + skippedDomains: ["example.com"], + targetName: "Work", + }); + }); + + it("routes a reopened browser back to the quit screen", () => { + expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" }); + }); + + it("surfaces every other failure on the blocked screen", () => { + expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({ + step: "blocked", + reason: "readFailed", + }); + }); +}); + +describe("refreshedSourceStep", () => { + it("moves to configure once a quit browser frees its cookies", () => { + expect(refreshedSourceStep(source())).toEqual({ step: "configure" }); + }); + + it("stays on quit while the browser is still running", () => { + expect(refreshedSourceStep(source({ unavailable: "browserRunning" }))).toEqual({ + step: "quit", + }); + }); + + it("blocks when the source vanished from the list", () => { + expect(refreshedSourceStep(undefined)).toEqual({ step: "blocked", reason: "unknownSource" }); + }); +}); + +describe("isRetryableReason", () => { + it("offers a retry for failures a second attempt can clear", () => { + expect(isRetryableReason("needsKeychainApproval")).toBe(true); + expect(isRetryableReason("readFailed")).toBe(true); + }); + + it("does not offer a retry for a permanent failure", () => { + expect(isRetryableReason("unsupportedPlatform")).toBe(false); + expect(isRetryableReason("keychainItemMissing")).toBe(false); + }); +}); + +describe("formatSkippedDomains", () => { + it("joins a short list naturally", () => { + expect(formatSkippedDomains([])).toBe(""); + expect(formatSkippedDomains(["a.com"])).toBe("a.com"); + expect(formatSkippedDomains(["a.com", "b.com"])).toBe("a.com and b.com"); + expect(formatSkippedDomains(["a.com", "b.com", "c.com"])).toBe("a.com, b.com and c.com"); + }); + + it("summarizes a long list", () => { + expect(formatSkippedDomains(["a.com", "b.com", "c.com", "d.com", "e.com"])).toBe( + "a.com, b.com, c.com and 2 more", + ); + }); +}); diff --git a/apps/web/src/components/settings/browserImportWizard.logic.ts b/apps/web/src/components/settings/browserImportWizard.logic.ts new file mode 100644 index 00000000000..c61e2a978a6 --- /dev/null +++ b/apps/web/src/components/settings/browserImportWizard.logic.ts @@ -0,0 +1,95 @@ +import type { BrowserImportFailureReason, BrowserImportSource } from "@t3tools/contracts"; + +/** + * What the import wizard produces once it has actually tried to import. The + * parent runs the import and classifies the result; the wizard only reacts to + * it, which keeps the step transitions pure and testable. + */ +export type ImportOutcome = + | { + readonly kind: "imported"; + readonly imported: number; + readonly skipped: number; + readonly skippedDomains: ReadonlyArray; + readonly targetName: string; + } + | { readonly kind: "blocked"; readonly reason: BrowserImportFailureReason }; + +/** + * The wizard's screens. Every one is a place the user can act from — there are + * no dead ends. `blocked` covers the reasons no local step recovers. + */ +export type WizardStep = + | { readonly step: "quit" } + | { readonly step: "configure" } + | { readonly step: "importing" } + | { + readonly step: "done"; + readonly imported: number; + readonly skipped: number; + readonly skippedDomains: ReadonlyArray; + readonly targetName: string; + } + | { readonly step: "blocked"; readonly reason: BrowserImportFailureReason }; + +/** + * Where the wizard opens for a source. A running browser is the one thing we + * know up front, from the source listing; everything else is discovered by + * trying, so the wizard starts by letting the user choose what to import. + */ +export function initialWizardStep(source: BrowserImportSource): WizardStep { + if (source.unavailable === "browserRunning") return { step: "quit" }; + if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable }; + return { step: "configure" }; +} + +/** Where an attempted import lands the wizard, by how it turned out. */ +export function outcomeToStep(outcome: ImportOutcome): WizardStep { + if (outcome.kind === "imported") { + return { + step: "done", + imported: outcome.imported, + skipped: outcome.skipped, + skippedDomains: outcome.skippedDomains, + targetName: outcome.targetName, + }; + } + // A browser that reopened mid-import routes back to the quit screen; every + // other failure surfaces on the blocked screen, which offers a retry when + // one could help. + if (outcome.reason === "browserRunning") return { step: "quit" }; + return { step: "blocked", reason: outcome.reason }; +} + +/** Where a fresh availability check lands the wizard after the user quits. */ +export function refreshedSourceStep(source: BrowserImportSource | undefined): WizardStep { + if (source === undefined) return { step: "blocked", reason: "unknownSource" }; + return initialWizardStep(source); +} + +/** + * Whether retrying could clear a failure. The keychain prompt can be approved + * on a second try, and a read or session error may be transient; a missing key + * or an unsupported browser will not change, so those get no retry button. + */ +export function isRetryableReason(reason: BrowserImportFailureReason): boolean { + switch (reason) { + case "needsKeychainApproval": + case "readFailed": + case "sessionUnavailable": + return true; + default: + return false; + } +} + +/** + * Names the sites whose cookies were skipped: "example.com and google.com", + * or "a, b, c and 4 more" past a few, so the line stays short. + */ +export function formatSkippedDomains(domains: ReadonlyArray): string { + if (domains.length === 0) return ""; + if (domains.length === 1) return domains[0]!; + if (domains.length <= 3) return `${domains.slice(0, -1).join(", ")} and ${domains.at(-1)}`; + return `${domains.slice(0, 3).join(", ")} and ${domains.length - 3} more`; +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 099bf4d26b4..13e3df41f91 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -209,12 +209,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/integrations", targetId: "browser", }, - { - id: "browser-default-profile", - title: "Default browser profile", - to: "/settings/integrations", - targetId: "browser", - }, { id: "browser-default-viewport", title: "Default browser viewport", diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts new file mode 100644 index 00000000000..d7e2aa26444 --- /dev/null +++ b/packages/contracts/src/browserImport.ts @@ -0,0 +1,137 @@ +/** + * Browser import - pulling cookies from a browser already installed on the + * machine into a T3 Code browser profile. + * + * Only cookies are imported. They carry the logged-in sessions, which is what + * makes an imported profile useful; saved passwords are deliberately out of + * scope because Electron exposes no password store to put them in. + * + * Availability is per source and per platform, and the reasons are modelled + * explicitly: some are a permission the user can grant, one is a limitation + * no amount of consent works around. The UI needs to tell those apart. + * + * @module BrowserImport + */ +import * as Schema from "effect/Schema"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { BrowserProfileId } from "./browserProfile.ts"; + +export const BROWSER_IMPORT_SOURCE_IDS = ["helium"] as const; + +export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); +export type BrowserImportSourceId = typeof BrowserImportSourceId.Type; + +/** + * Why a detected source cannot be imported right now. + * + * `needsKeychainApproval` and `browserRunning` are recoverable — the user + * grants access or quits the browser. `unsupportedPlatform` is not: it covers + * cases like Chrome on Windows, whose App-Bound Encryption is designed to stop + * exactly this, and which we will not work around. + */ +export const BrowserImportUnavailableReason = Schema.Literals([ + "notInstalled", + "needsKeychainApproval", + "keychainItemMissing", + "browserRunning", + "unsupportedPlatform", +]); +export type BrowserImportUnavailableReason = typeof BrowserImportUnavailableReason.Type; + +/** + * Why an import that was actually attempted failed. + * + * A superset of the unavailable reasons: a source can pass the pre-flight + * check and still fail, most often because the user declined the keychain + * prompt the read triggers. + */ +export const BrowserImportFailureReason = Schema.Literals([ + ...BrowserImportUnavailableReason.literals, + /** No source registered under the requested id. */ + "unknownSource", + /** The requested profile directory is not one the source reported. */ + "unknownSourceProfile", + /** The target profile's Electron session could not be opened. */ + "sessionUnavailable", + /** Anything else: a corrupt database, a failed decrypt, a vanished file. */ + "readFailed", +]); +export type BrowserImportFailureReason = typeof BrowserImportFailureReason.Type; + +/** A profile inside the source browser, e.g. Chromium's "Default" directory. */ +export const BrowserImportSourceProfile = Schema.Struct({ + /** Directory name under the source's user-data dir. */ + directory: TrimmedNonEmptyString, + /** The name the source browser shows for it. */ + name: TrimmedNonEmptyString, + /** + * How many cookies the profile holds. Counted without decrypting, so it is + * cheap; absent when the store could not be read yet (Safari before Full + * Disk Access is granted). + */ + cookieCount: Schema.optional(Schema.Int), +}); +export type BrowserImportSourceProfile = typeof BrowserImportSourceProfile.Type; + +export const BrowserImportSource = Schema.Struct({ + id: BrowserImportSourceId, + name: TrimmedNonEmptyString, + profiles: Schema.Array(BrowserImportSourceProfile), + /** Absent when the source is importable. */ + unavailable: Schema.optional(BrowserImportUnavailableReason), +}); +export type BrowserImportSource = typeof BrowserImportSource.Type; + +export const BrowserImportInput = Schema.Struct({ + sourceId: BrowserImportSourceId, + sourceProfileDirectory: TrimmedNonEmptyString, + /** T3 Code profile the cookies are written into. */ + targetProfileId: BrowserProfileId, +}); +export type BrowserImportInput = typeof BrowserImportInput.Type; + +/** IPC payload: the import input plus the environment the partition belongs to. */ +export const DesktopPreviewImportCookiesInputSchema = Schema.Struct({ + environmentId: TrimmedNonEmptyString, + sourceId: BrowserImportSourceId, + sourceProfileDirectory: TrimmedNonEmptyString, + targetProfileId: BrowserProfileId, +}); + +export const BrowserImportResult = Schema.Struct({ + /** Cookies successfully written into the target partition. */ + imported: Schema.Int, + /** + * Cookies read but not written — expired, rejected as malformed, or held + * under a key we could not use. Surfaced rather than hidden so a + * mostly-failed import doesn't look like a success. + */ + skipped: Schema.Int, + /** + * The distinct hosts those skipped cookies belonged to, so the user can be + * told what didn't come over rather than just how many. Capped, since a + * broken key can skip thousands across many sites. + */ + skippedDomains: Schema.Array(Schema.String), +}); +export type BrowserImportResult = typeof BrowserImportResult.Type; + +export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< + Record +> = { + notInstalled: "Not installed on this machine.", + needsKeychainApproval: "Needs Keychain access to read its cookies.", + keychainItemMissing: + "No encryption key in your Keychain — sign in to that browser once, then retry.", + browserRunning: "Quit the browser first so its cookie database can be read.", + unsupportedPlatform: "Importing from this browser isn't possible on this platform.", +}; + +/** What to tell the user when an attempted import fails. */ +export const BROWSER_IMPORT_FAILURE_COPY: Readonly> = { + ...BROWSER_IMPORT_UNAVAILABLE_COPY, + unknownSource: "That browser is no longer available to import from.", + unknownSourceProfile: "That browser profile no longer exists.", + sessionUnavailable: "The target profile could not be opened.", + readFailed: "The browser's cookie database could not be read.", +}; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8ab164bd8a5..0769f21af0d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -26,6 +26,7 @@ export * from "./project.ts"; export * from "./filesystem.ts"; export * from "./assets.ts"; export * from "./review.ts"; +export * from "./browserImport.ts"; export * from "./browserProfile.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 232647e0b06..f25c073544d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -89,6 +89,11 @@ import type { } from "./orchestration.ts"; import { EnvironmentId } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; +import type { + BrowserImportResult, + BrowserImportSource, + BrowserImportSourceId, +} from "./browserImport.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; @@ -1174,6 +1179,14 @@ export interface DesktopPreviewBridge { environmentId: EnvironmentId, profileId?: string, ) => Promise; + /** Browsers on this machine whose cookies can be imported. */ + listBrowserImportSources: () => Promise>; + importBrowserCookies: (input: { + readonly environmentId: EnvironmentId; + readonly sourceId: BrowserImportSourceId; + readonly sourceProfileDirectory: string; + readonly targetProfileId: string; + }) => Promise; setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise; /** * Activate the in-page element picker for the given tab. Resolves with diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c79aea36a0..b4720d2bd01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -3173,6 +3176,87 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -13313,6 +13397,57 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index bf36029bc75..7f7bed5c9e6 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -199,6 +199,22 @@ export class MacPasskeySigningConfigurationResolutionError extends Schema.Tagged } } +export class KeyringNativePackageMissingError extends Schema.TaggedErrorClass()( + "KeyringNativePackageMissingError", + { + packageName: Schema.String, + binaryFileName: Schema.String, + packageEntryPath: Schema.String, + platform: BuildPlatform, + arch: BuildArch, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Keyring native package is missing: ${this.packageName}`; + } +} + export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass()( "ClerkPasskeyNativePackageMissingError", { @@ -1110,6 +1126,69 @@ export function resolveClerkPasskeyNativeArtifacts( return []; } +export function resolveKeyringNativeArtifacts( + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +): readonly ClerkPasskeyNativeArtifact[] { + const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch]; + + if (platform === "mac") { + return architectures.map((architecture) => ({ + packageName: `@napi-rs/keyring-darwin-${architecture}`, + binaryFileName: `keyring.darwin-${architecture}.node`, + })); + } + + if (platform === "win") { + return architectures.map((architecture) => ({ + packageName: `@napi-rs/keyring-win32-${architecture}-msvc`, + binaryFileName: `keyring.win32-${architecture}-msvc.node`, + })); + } + + return architectures.map((architecture) => ({ + packageName: `@napi-rs/keyring-linux-${architecture}-gnu`, + binaryFileName: `keyring.linux-${architecture}-gnu.node`, + })); +} + +/** + * Same nesting problem as the Clerk passkey binaries: pnpm keeps the platform + * package under `@napi-rs/keyring`, electron-builder only retains collected + * top-level dependencies, and the generated loader checks for a sibling + * `keyring..node` before falling back to the package. Staging the + * binary beside `index.js` lets that first branch win. + */ +const stageKeyringNativeBinaries = Effect.fn("stageKeyringNativeBinaries")(function* ( + stageAppDir: string, + platform: typeof BuildPlatform.Type, + arch: typeof BuildArch.Type, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const packageEntryPath = yield* fs.realPath( + path.join(stageAppDir, "node_modules", "@napi-rs", "keyring", "index.js"), + ); + const packageDir = path.dirname(packageEntryPath); + const packageRequire = NodeModule.createRequire(packageEntryPath); + + for (const artifact of resolveKeyringNativeArtifacts(platform, arch)) { + const sourcePath = yield* Effect.try({ + try: () => packageRequire.resolve(`${artifact.packageName}/${artifact.binaryFileName}`), + catch: (cause) => + new KeyringNativePackageMissingError({ + packageName: artifact.packageName, + binaryFileName: artifact.binaryFileName, + packageEntryPath, + platform, + arch, + cause, + }), + }); + yield* fs.copyFile(sourcePath, path.join(packageDir, artifact.binaryFileName)); + } +}); + // pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only // retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage // the binary there and let electron-builder's native-addon handling unpack it from the ASAR. @@ -2953,6 +3032,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( { label: "vp install --prod", verbose: options.verbose }, ); yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); + yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch); // WSL is Windows-only, so only the Windows artifact carries the server // sidecar (which embeds the Linux node-pty prebuild); other platforms