From fd0f971b25afac67e2791b9bb7eec74280afad89 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 22:39:46 +0200 Subject: [PATCH 01/18] feat(desktop): import cookies from an installed browser into a profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-profile cookie import, with Helium on macOS as the first source. Cookies carry the logged-in sessions, which is what makes an imported profile useful; saved passwords are out of scope because Electron exposes no password store to put them in. The key is read through the in-process Keychain API rather than by shelling out to `/usr/bin/security`, because macOS attributes both the consent prompt and the resulting ACL grant to the binary that asks. Via the CLI the prompt said "security" and "Always Allow" granted trust to a tool every process on the machine can invoke; in-process it names this app and the grant belongs to it. There is deliberately no fallback when consent is denied — the techniques that work around it exist to defeat exactly this. The read is untimed: macOS answers it with a modal, and a timeout racing the user means the prompt can be approved after nothing is left listening, which reads as "approving did nothing". Sources pin their own coordinates rather than deriving them, because Chromium forks disagree: Helium uses the keychain service "Helium Storage Key" / account "Helium" where Chrome and its closer relatives use " Safe Storage" / "". `expires_utc` counts microseconds since 1601 and exceeds JavaScript's safe integer range, which `node:sqlite` refuses to narrow, so the division happens in SQL and only seconds cross the boundary. The cookie database is snapshotted before reading, since Chromium keeps it open with WAL. Unavailable sources are reported with a reason rather than as one generic failure — a missing keychain item is not something approving a prompt can fix. `unsupportedPlatform` is the one that is not a permission at all: it covers cases like Chrome on Windows, whose App-Bound Encryption is designed to stop this and which we will not work around. The platform binary is staged beside the loader during packaging, mirroring the Clerk passkey handling: pnpm nests the arch package, electron-builder only retains top-level dependencies, and the generated loader checks for a sibling `.node` first. Verified end to end against a real Helium profile: 5,002 cookies imported. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/package.json | 1 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/preview.ts | 35 +++ apps/desktop/src/main.ts | 4 + apps/desktop/src/preload.ts | 3 + .../preview/BrowserImport/BrowserImport.ts | 177 +++++++++++++ .../preview/BrowserImport/ChromiumCookies.ts | 237 ++++++++++++++++++ .../src/preview/BrowserImport/Sources.ts | 95 +++++++ packages/contracts/src/browserImport.ts | 96 +++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 13 + pnpm-lock.yaml | 135 ++++++++++ scripts/build-desktop-artifact.ts | 80 ++++++ 14 files changed, 881 insertions(+) create mode 100644 apps/desktop/src/preview/BrowserImport/BrowserImport.ts create mode 100644 apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts create mode 100644 apps/desktop/src/preview/BrowserImport/Sources.ts create mode 100644 packages/contracts/src/browserImport.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a34a55f16acf..71ae451f9755 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 37fd873a1b03..5af68a1f50a7 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 02f9ad0df36e..f6ebbef0e9bc 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 a930a0560952..a158b4c22c91 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 14caeed8a9a1..0620ccc0fba2 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 ad7918f1f77d..ce138de9e579 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.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts new file mode 100644 index 000000000000..d52e9cfa871b --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -0,0 +1,177 @@ +/** + * 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 * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as BrowserSession from "../BrowserSession.ts"; +import { + ChromiumCookieReadError, + readChromiumCookies, + type ChromiumCookie, +} from "./ChromiumCookies.ts"; +import { + BROWSER_IMPORT_SOURCES, + cookieDatabasePath, + isSourceInstalled, + isSourceRunning, + listSourceProfiles, + type BrowserImportSourceDefinition, +} from "./Sources.ts"; + +export class BrowserImportFailedError extends Schema.TaggedErrorClass()( + "BrowserImportFailedError", + { + sourceId: Schema.String, + reason: Schema.String, + }, +) { + 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 = async ( + definition: BrowserImportSourceDefinition, + platform: NodeJS.Platform, +): Promise => { + if (!definition.platforms.includes(platform)) return "unsupportedPlatform"; + if (!(await isSourceInstalled(definition))) return "notInstalled"; + if (await isSourceRunning(definition)) return "browserRunning"; + return undefined; +}; + +export const make = Effect.gen(function* BrowserImportMake() { + const browserSession = yield* BrowserSession.BrowserSession; + const platform = yield* HostProcessPlatform; + const executablePath = yield* HostProcessExecutablePath; + + const listSources = Effect.promise(async (): Promise> => { + const sources: BrowserImportSource[] = []; + for (const definition of BROWSER_IMPORT_SOURCES) { + const unavailable = await unavailableReason(definition, platform); + sources.push({ + 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 ? await listSourceProfiles(definition) : [], + ...(unavailable === undefined ? {} : { unavailable }), + }); + } + return sources; + }); + + 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: "unknown source", + }); + } + + const blocked = yield* Effect.promise(() => unavailableReason(definition, platform)); + 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, + }); + + const cookies: ReadonlyArray = yield* Effect.tryPromise({ + try: () => + readChromiumCookies({ + cookieDatabasePath: cookieDatabasePath(definition, input.input.sourceProfileDirectory), + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + platform, + }), + catch: (cause) => + new BrowserImportFailedError({ + sourceId: definition.id, + reason: cause instanceof ChromiumCookieReadError ? cause.failure.reason : "readFailed", + }), + }); + + const session = yield* browserSession + .getSession(input.scope, input.persistent) + .pipe( + Effect.mapError( + () => new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }), + ), + ); + + // 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; + let skipped = 0; + for (const cookie of cookies) { + const written = yield* Effect.tryPromise({ + try: () => + session.cookies.set({ + url: cookie.url, + name: cookie.name, + value: cookie.value, + 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; + } + + return { imported, skipped }; + }); + + return BrowserImport.of({ listSources, importCookies }); +}); + +export const layer = Layer.effect(BrowserImport, make); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts new file mode 100644 index 000000000000..7a4ca8140229 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -0,0 +1,237 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * 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 NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; + +/** 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; + readonly domain: string; + 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 type ChromiumCookieReadFailure = + | { readonly reason: "needsKeychainApproval" } + | { readonly reason: "keychainItemMissing" } + | { readonly reason: "browserRunning" } + | { readonly reason: "unsupportedPlatform" }; + +export class ChromiumCookieReadError extends Error { + readonly failure: ChromiumCookieReadFailure; + + constructor(failure: ChromiumCookieReadFailure) { + super(`Could not read Chromium cookies: ${failure.reason}`); + this.name = "ChromiumCookieReadError"; + this.failure = failure; + } +} + +/** + * 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". + */ +async function readMacKeychainPassword(service: string, account: string): Promise { + let password: string | null; + try { + password = new Keyring.Entry(service, account).getPassword(); + } catch (cause) { + const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); + // Distinguish the causes rather than reporting "approve the prompt" for a + // failure approving cannot fix. + if (/no (matching )?entry|not found/i.test(message)) { + throw new ChromiumCookieReadError({ reason: "keychainItemMissing" }); + } + throw new ChromiumCookieReadError({ reason: "needsKeychainApproval" }); + } + if (password === null || password === "") { + throw new ChromiumCookieReadError({ reason: "keychainItemMissing" }); + } + 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. + */ +async function copyCookieDatabase(cookiePath: string): Promise<{ + readonly path: string; + readonly cleanup: () => Promise; +}> { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-cookie-import-")); + const target = NodePath.join(directory, "Cookies"); + await NodeFSP.copyFile(cookiePath, target); + for (const suffix of ["-wal", "-shm"]) { + await NodeFSP.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).catch(() => undefined); + } + return { + path: target, + cleanup: () => NodeFSP.rm(directory, { recursive: true, force: true }).catch(() => undefined), + }; +} + +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; + } +}; + +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 async function readChromiumCookies( + source: ChromiumCookieSource, +): Promise> { + 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. + throw new ChromiumCookieReadError({ reason: "unsupportedPlatform" }); + } + + const password = await readMacKeychainPassword(source.keychainService, source.keychainAccount); + const key = NodeCrypto.pbkdf2Sync( + password, + MAC_KEY_SALT, + MAC_KEY_ITERATIONS, + MAC_KEY_LENGTH, + "sha1", + ); + + const snapshot = await copyCookieDatabase(source.cookieDatabasePath); + try { + const database = new NodeSqlite.DatabaseSync(snapshot.path, { readOnly: true }); + try { + const rows = database + .prepare( + `select host_key, name, encrypted_value, path, + expires_utc / 1000000 as expires_seconds, + is_secure, is_httponly, samesite + from cookies`, + ) + .all() as unknown as ReadonlyArray<{ + host_key: string; + name: string; + encrypted_value: Uint8Array; + path: string; + expires_seconds: number; + is_secure: number; + is_httponly: number; + samesite: number; + }>; + + const cookies: ChromiumCookie[] = []; + for (const row of rows) { + const value = decryptValue(row.encrypted_value, key, row.host_key); + if (value === null) continue; + const secure = row.is_secure === 1; + // Electron matches cookies to a URL rather than a bare domain, so a + // host-only entry keeps its leading dot stripped for the URL but not + // for the domain it is registered under. + const host = row.host_key.startsWith(".") ? row.host_key.slice(1) : row.host_key; + cookies.push({ + url: `${secure ? "https" : "http"}://${host}${row.path}`, + name: row.name, + value, + domain: row.host_key, + path: row.path, + secure, + httpOnly: row.is_httponly === 1, + expirationDate: toUnixSeconds(row.expires_seconds), + sameSite: sameSiteFromColumn(row.samesite), + }); + } + return cookies; + } finally { + database.close(); + } + } finally { + await snapshot.cleanup(); + } +} diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts new file mode 100644 index 000000000000..0a514a348cb5 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -0,0 +1,95 @@ +/** + * 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 + */ +// @effect-diagnostics nodeBuiltinImport:off +import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +export interface BrowserImportSourceDefinition { + readonly id: BrowserImportSourceId; + readonly name: string; + /** Platforms the definition's paths are valid for. */ + readonly platforms: ReadonlyArray; + readonly userDataDirectory: () => string; + readonly keychainService: string; + readonly keychainAccount: string; +} + +export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ + { + id: "helium", + name: "Helium", + platforms: ["darwin"], + userDataDirectory: () => + NodePath.join(NodeOS.homedir(), "Library", "Application Support", "net.imput.helium"), + keychainService: "Helium Storage Key", + keychainAccount: "Helium", + }, +]; + +const pathExists = (path: string): Promise => + NodeFSP.access(path).then( + () => true, + () => false, + ); + +export const cookieDatabasePath = ( + definition: BrowserImportSourceDefinition, + profileDirectory: string, +): string => NodePath.join(definition.userDataDirectory(), profileDirectory, "Cookies"); + +/** + * Profiles the source browser knows about, read from its `Local State`. + * + * Falls back to the `Default` directory when that file is unreadable or has no + * profile cache: a browser that has only ever had one profile is the common + * case, and failing the whole import over a missing display name would be + * disproportionate. + */ +export async function listSourceProfiles( + definition: BrowserImportSourceDefinition, +): Promise> { + const fallback: ReadonlyArray = [ + { directory: "Default", name: "Default" }, + ]; + try { + const raw = await NodeFSP.readFile( + NodePath.join(definition.userDataDirectory(), "Local State"), + "utf8", + ); + const parsed = JSON.parse(raw) as { + profile?: { info_cache?: Record }; + }; + const entries = Object.entries(parsed.profile?.info_cache ?? {}); + if (entries.length === 0) return fallback; + return entries.map(([directory, info]) => ({ + directory, + name: info.name?.trim() || directory, + })); + } catch { + return fallback; + } +} + +/** Whether the browser is running, which leaves its cookie DB mid-write. */ +export async function isSourceRunning(definition: BrowserImportSourceDefinition): Promise { + // 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. + return pathExists(NodePath.join(definition.userDataDirectory(), "SingletonLock")); +} + +export async function isSourceInstalled( + definition: BrowserImportSourceDefinition, +): Promise { + return pathExists(definition.userDataDirectory()); +} diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts new file mode 100644 index 000000000000..acc331fb9561 --- /dev/null +++ b/packages/contracts/src/browserImport.ts @@ -0,0 +1,96 @@ +/** + * 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 { Schema } from "effect"; +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; + +/** 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, +}); +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, or rejected by Chromium as + * malformed. Surfaced rather than hidden so a mostly-failed import doesn't + * look like a success. + */ + skipped: Schema.Int, +}); +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.", +}; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 8ab164bd8a59..0769f21af0da 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 232647e0b066..f25c073544db 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 2c79aea36a0e..b4720d2bd016 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 bf36029bc75b..7f7bed5c9e61 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 From b8f0eb064bb42ac75f5f24631237938ea6217b65 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 22:39:59 +0200 Subject: [PATCH 02/18] feat(web): rebuild the profile list as a table with import targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list was a loose stack of rows with two competing controls and a separate "Default browser profile" setting duplicating what the list already showed. Profiles now render as a table. Which one is default is a badge on its row and is changed from that row's menu, so the standalone setting is gone. Each row also gains "Clear cookies and cache", scoped to that profile's partition. Creating and importing collapse into one "Add profile" menu, because from the user's side they are one decision: "I want a profile with my Helium logins in it". Each source offers its targets directly — New profile, or any existing one — so importing into a fresh profile no longer means creating it first and then hunting for a second control. Previously import offered no choice of target at all. Incognito is not listed. It keeps nothing between launches, so it has no data to clear, no name to edit, and no state to manage; it belongs in the menu that opens a tab. It is also excluded as an import target, since importing into it would be discarded on quit. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/IntegrationsSettings.tsx | 412 ++++++++++++------ .../src/components/settings/settingsSearch.ts | 6 - 2 files changed, 281 insertions(+), 137 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 34f3d71f43b4..d474cd4fc19d 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,6 +7,7 @@ * @module IntegrationsSettings */ import { + BROWSER_IMPORT_UNAVAILABLE_COPY, 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 { useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { previewBridge } from "~/components/preview/previewBridge"; @@ -48,6 +49,19 @@ import { AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "../ui/menu"; +import { toastManager } from "../ui/toast"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; @@ -74,7 +88,6 @@ import { SettingsRow, SettingsSection, } from "./settingsLayout"; -import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; import { searchableSetting } from "./settingsSearch"; const FILL_VALUE = "fill"; @@ -483,24 +496,52 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode * so there is nothing to rename and removing them would strand every tab that * opened under them. */ + +/** + * Per-profile cookie import from a browser installed on this machine. + * + * 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. + */ +/** + * 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. + */ 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 [busy, setBusy] = useState(false); 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); + const resolvedDefaultId = + findBrowserProfile(profiles, 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 +554,20 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }); }; + const clearProfileData = (id: string, name: string) => { + if (!environmentId || !previewBridge) 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,94 +578,237 @@ 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 } : {}), }); }; + const loadSources = () => { + if (!previewBridge) return; + void previewBridge + .listBrowserImportSources() + .then(setSources) + .catch(() => setSources([])); + }; + + const runImport = ( + source: BrowserImportSource, + sourceProfileDirectory: string, + targetProfileId: string, + targetName: string, + ) => { + if (!environmentId || !previewBridge) return; + setBusy(true); + void previewBridge + .importBrowserCookies({ + environmentId, + sourceId: source.id, + sourceProfileDirectory, + targetProfileId, + }) + .then((result) => { + toastManager.add({ + type: result.imported > 0 ? "success" : "error", + title: + result.imported > 0 + ? `Imported ${result.imported} cookies into ${targetName}` + : `No cookies imported from ${source.name}`, + // Surfaced rather than hidden: a mostly-skipped import should not + // read as a clean success. + ...(result.skipped > 0 ? { description: `${result.skipped} skipped.` } : {}), + }); + }) + .catch((cause: unknown) => { + const reason = String((cause as { message?: unknown })?.message ?? ""); + const known = ( + Object.keys(BROWSER_IMPORT_UNAVAILABLE_COPY) as ReadonlyArray< + keyof typeof BROWSER_IMPORT_UNAVAILABLE_COPY + > + ).find((candidate) => reason.includes(candidate)); + toastManager.add({ + type: "error", + title: `Could not import from ${source.name}`, + description: known + ? BROWSER_IMPORT_UNAVAILABLE_COPY[known] + : "The browser's cookie database could not be read.", + }); + }) + .finally(() => setBusy(false)); + }; + + const importInto = ( + source: BrowserImportSource, + sourceProfileDirectory: string, + target: "new" | { readonly id: string; readonly name: string }, + ) => { + if (target === "new") { + const created = createProfile(source.name); + runImport(source, sourceProfileDirectory, created.id, created.name); + return; + } + runImport(source, sourceProfileDirectory, target.id, target.name); + }; + + 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… + ) : sources.length === 0 ? ( + No supported browsers found + ) : ( + sources.flatMap((source) => + source.unavailable + ? [ + // Kept visible with its reason rather than hidden: + // "Helium is running, quit it" beats "Helium isn't + // listed". + + + {source.name} + + {BROWSER_IMPORT_UNAVAILABLE_COPY[source.unavailable]} + + + , + ] + : source.profiles.map((sourceProfile) => ( + + + {source.profiles.length > 1 + ? `${source.name} — ${sourceProfile.name}` + : source.name} + + + importInto(source, sourceProfile.directory, "new")} + > + New profile + + + + Existing profile + {profiles + // Incognito is discarded on quit, so importing + // into it would throw the work away. + .filter((profile) => profile.kind !== "incognito") + .map((profile) => ( + + importInto(source, sourceProfile.directory, { + id: profile.id, + name: profile.name, + }) + } + > + {profile.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) => { - const builtIn = isBuiltInBrowserProfileId(profile.id); - return ( -
- {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. - + {profiles + .filter((profile) => profile.kind !== "incognito") + .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 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)} + /> )} - > - {profile.name} - - {profile.kind === "incognito" ? "Ephemeral" : "Built-in"} - + {isDefault ? Default : null} - ) : ( - renameProfile(profile.id, next)} - /> - )} - {builtIn ? null : ( - - + setProfilePendingRemoval(profile)} - > - - + aria-label={`${profile.name} options`} + /> } - /> - Remove profile and its data - - )} -
- ); - })} + > + + + + updateSettings({ browserDefaultProfileId: profile.id })} + > + Set as default + + clearProfileData(profile.id, profile.name)}> + Clear cookies and cache + + {builtIn ? null : ( + removeProfile(profile.id)}> + Remove profile and data + + )} + + +
+ ); + })}
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/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 099bf4d26b4c..13e3df41f910 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", From 467933a1214e7b2ae4a34c8ba30fe0203ff770f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 23:43:09 +0200 Subject: [PATCH 03/18] fix(desktop): reject unlisted source profiles and detect running browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `importCookies` forwarded the IPC-supplied `sourceProfileDirectory` straight into the cookie database path, so `..` segments walked out of the browser's user-data directory and imported any cookie database reachable on disk. It is now only honoured when the source itself reported it. The running-browser check used `access` on Chromium's `SingletonLock`, which is a symlink to a `-` target that never exists. Following it reported every running browser as closed, so an import could read a live, mid-write database — Helium open on this machine was detected as closed. It now stats the link itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../BrowserImport/BrowserImport.test.ts | 78 +++++++++++++++++++ .../preview/BrowserImport/BrowserImport.ts | 17 +++- .../src/preview/BrowserImport/Sources.test.ts | 50 ++++++++++++ .../src/preview/BrowserImport/Sources.ts | 15 +++- 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts create mode 100644 apps/desktop/src/preview/BrowserImport/Sources.test.ts 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 000000000000..67d8a133d282 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -0,0 +1,78 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds the on-disk browser layout the import reads. +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as BrowserSession from "../BrowserSession.ts"; +import * as BrowserImport from "./BrowserImport.ts"; +import { BROWSER_IMPORT_SOURCES } from "./Sources.ts"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; + +/** + * Fails loudly if the import ever reaches session work: every test 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"]); + +const layer = BrowserImport.layer.pipe( + Layer.provide(rejectedBeforeSession), + Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), + Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")), +); + +const withScratchHome = Effect.fnUntraced(function* () { + const realHome = process.env.HOME; + const home = yield* Effect.acquireRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-import-"))), + (dir) => + Effect.promise(async () => { + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; + await NodeFSP.rm(dir, { recursive: true, force: true }); + }), + ); + process.env.HOME = home; + yield* Effect.promise(() => + NodeFSP.mkdir(NodePath.join(helium.userDataDirectory(), "Default"), { recursive: true }), + ); + return home; +}); + +describe("BrowserImport.importCookies", () => { + it.effect("rejects a profile directory the source never reported", () => + Effect.gen(function* () { + const home = yield* withScratchHome(); + // A cookie database that is reachable on disk but outside the browser's + // user-data directory — the payoff a traversal would be after. + const secrets = NodePath.join(home, "secrets"); + yield* Effect.promise(() => NodeFSP.mkdir(secrets, { recursive: true })); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(secrets, "Cookies"), "not-a-db")); + + const importer = yield* BrowserImport.BrowserImport; + 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(layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index d52e9cfa871b..64243cc55038 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -116,10 +116,25 @@ export const make = Effect.gen(function* BrowserImportMake() { 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* Effect.promise(() => listSourceProfiles(definition)); + const requestedProfile = sourceProfiles.find( + (profile) => profile.directory === input.input.sourceProfileDirectory, + ); + if (requestedProfile === undefined) { + return yield* new BrowserImportFailedError({ + sourceId: definition.id, + reason: "unknownSourceProfile", + }); + } + const cookies: ReadonlyArray = yield* Effect.tryPromise({ try: () => readChromiumCookies({ - cookieDatabasePath: cookieDatabasePath(definition, input.input.sourceProfileDirectory), + cookieDatabasePath: cookieDatabasePath(definition, requestedProfile.directory), keychainService: definition.keychainService, keychainAccount: definition.keychainAccount, platform, 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 000000000000..20f39bdb4f0a --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -0,0 +1,50 @@ +// @effect-diagnostics nodeBuiltinImport:off - Drives the raw fs paths the module under test uses. +import { afterEach, describe, expect, it } from "@effect/vitest"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { BROWSER_IMPORT_SOURCES, cookieDatabasePath, isSourceRunning } from "./Sources.ts"; + +const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; +const realHome = process.env.HOME; + +// `userDataDirectory()` resolves `os.homedir()` on every call, and on POSIX +// that reads $HOME, so a scratch home is enough to exercise the real function. +const withScratchHome = async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-sources-")); + process.env.HOME = home; + await NodeFSP.mkdir(helium.userDataDirectory(), { recursive: true }); + return home; +}; + +afterEach(() => { + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; +}); + +describe("isSourceRunning", () => { + it("reads Chromium's dangling SingletonLock symlink as a running browser", async () => { + // Chromium points `SingletonLock` at `-`, a target that never + // exists on disk. A check that follows the link reports a running browser + // as closed, which lets an import read a live, mid-write cookie database. + await withScratchHome(); + expect(await isSourceRunning(helium)).toBe(false); + + await NodeFSP.symlink( + "host-that-does-not-exist-1234", + NodePath.join(helium.userDataDirectory(), "SingletonLock"), + ); + + expect(await isSourceRunning(helium)).toBe(true); + }); +}); + +describe("cookieDatabasePath", () => { + it("places the cookie database under the source profile directory", async () => { + const home = await withScratchHome(); + expect(cookieDatabasePath(helium, "Profile 1")).toBe( + NodePath.join(home, "Library/Application Support/net.imput.helium/Profile 1/Cookies"), + ); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 0a514a348cb5..8ab277a77950 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -42,6 +42,19 @@ const pathExists = (path: string): Promise => () => false, ); +/** + * Tests the directory entry itself rather than whatever it points at. + * + * Chromium points `SingletonLock` at `-`, a target that never + * exists, so following the link reports a running browser as closed — exactly + * backwards, and it would let an import read a live, mid-write database. + */ +const entryExists = (path: string): Promise => + NodeFSP.lstat(path).then( + () => true, + () => false, + ); + export const cookieDatabasePath = ( definition: BrowserImportSourceDefinition, profileDirectory: string, @@ -85,7 +98,7 @@ export async function isSourceRunning(definition: BrowserImportSourceDefinition) // 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. - return pathExists(NodePath.join(definition.userDataDirectory(), "SingletonLock")); + return entryExists(NodePath.join(definition.userDataDirectory(), "SingletonLock")); } export async function isSourceInstalled( From a5bd47830bd4c4456d56ce24223854394703799d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 16 Aug 2026 23:56:55 +0200 Subject: [PATCH 04/18] refactor(desktop): put browser cookie import on Effect services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import module reached for `node:fs/promises`, `node:os`, `node:path` and `node:sqlite` directly and threaded results through hand-rolled promises, which meant a blanket `nodeBuiltinImport:off` on two files and failures that were plain thrown `Error`s. It now uses FileSystem, Path and the shared Effect SQL client, and the cookie snapshot is a scoped resource rather than a try/finally with a cleanup callback. `node:crypto` stays — it implements the OSCrypt primitives Chromium uses and has no Effect equivalent — with the suppression narrowed to it and a reason attached. Failure reasons are a typed union in contracts rather than free strings, so the renderer maps a reason to copy instead of substring-matching an error message. Porting `isSourceRunning` to `FileSystem.stat` reintroduced the dangling SingletonLock bug, because `stat` and `exists` both follow symlinks; `readLink` is the probe that answers for the entry itself. The regression test caught it. Co-Authored-By: Claude Opus 5 (1M context) --- .../BrowserImport/BrowserImport.test.ts | 106 ++++++--- .../preview/BrowserImport/BrowserImport.ts | 107 +++++---- .../preview/BrowserImport/ChromiumCookies.ts | 223 ++++++++++-------- .../src/preview/BrowserImport/Sources.test.ts | 163 +++++++++---- .../src/preview/BrowserImport/Sources.ts | 140 ++++++----- .../settings/IntegrationsSettings.tsx | 25 +- packages/contracts/src/browserImport.ts | 29 +++ 7 files changed, 504 insertions(+), 289 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 67d8a133d282..6d4ab4639f87 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -1,21 +1,23 @@ -// @effect-diagnostics nodeBuiltinImport:off - Builds the on-disk browser layout the import reads. +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 NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as BrowserSession from "../BrowserSession.ts"; import * as BrowserImport from "./BrowserImport.ts"; -import { BROWSER_IMPORT_SOURCES } from "./Sources.ts"; +import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; /** - * Fails loudly if the import ever reaches session work: every test here covers - * a request that must be rejected before a cookie is read or written. + * 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"), @@ -24,41 +26,46 @@ const rejectedBeforeSession = Layer.succeed(BrowserSession.BrowserSession, { clearCache: () => Effect.die("clearCache must not be reached"), } as unknown as BrowserSession.BrowserSession["Service"]); -const layer = BrowserImport.layer.pipe( - Layer.provide(rejectedBeforeSession), - Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), - Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")), -); - -const withScratchHome = Effect.fnUntraced(function* () { - const realHome = process.env.HOME; - const home = yield* Effect.acquireRelease( - Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-import-"))), - (dir) => - Effect.promise(async () => { - if (realHome === undefined) delete process.env.HOME; - else process.env.HOME = realHome; - await NodeFSP.rm(dir, { recursive: true, force: true }); - }), +/** + * 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 }), ); - process.env.HOME = home; - yield* Effect.promise(() => - NodeFSP.mkdir(NodePath.join(helium.userDataDirectory(), "Default"), { recursive: true }), + yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, { + recursive: true, + }); + + 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 home; + return { importer, home, paths }; }); describe("BrowserImport.importCookies", () => { - it.effect("rejects a profile directory the source never reported", () => + it.effect("rejects a source profile the browser never reported", () => Effect.gen(function* () { - const home = yield* withScratchHome(); - // A cookie database that is reachable on disk but outside the browser's + 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. - const secrets = NodePath.join(home, "secrets"); - yield* Effect.promise(() => NodeFSP.mkdir(secrets, { recursive: true })); - yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(secrets, "Cookies"), "not-a-db")); + yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); - const importer = yield* BrowserImport.BrowserImport; const error = yield* importer .importCookies({ input: { @@ -73,6 +80,33 @@ describe("BrowserImport.importCookies", () => { assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "unknownSourceProfile"); - }).pipe(Effect.provide(layer), Effect.scoped), + }).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 index 64243cc55038..4c92a96b824c 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -10,35 +10,40 @@ import type { 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 { - ChromiumCookieReadError, - readChromiumCookies, - type ChromiumCookie, -} from "./ChromiumCookies.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: 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}.`; } @@ -57,36 +62,40 @@ export class BrowserImport extends Context.Service< } >()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {} -const unavailableReason = async ( +const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( definition: BrowserImportSourceDefinition, platform: NodeJS.Platform, -): Promise => { + paths: SourcePaths, +): Effect.fn.Return { if (!definition.platforms.includes(platform)) return "unsupportedPlatform"; - if (!(await isSourceInstalled(definition))) return "notInstalled"; - if (await isSourceRunning(definition)) return "browserRunning"; + if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled"; + if (yield* isSourceRunning(definition, paths)) return "browserRunning"; return undefined; -}; +}); export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcessPlatform; const executablePath = yield* HostProcessExecutablePath; - - const listSources = Effect.promise(async (): Promise> => { - const sources: BrowserImportSource[] = []; - for (const definition of BROWSER_IMPORT_SOURCES) { - const unavailable = await unavailableReason(definition, platform); - sources.push({ + // 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 ? await listSourceProfiles(definition) : [], + profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [], ...(unavailable === undefined ? {} : { unavailable }), - }); - } - return sources; - }); + } satisfies BrowserImportSource; + }), + ).pipe(Effect.provide(platformServices)); const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: { readonly input: BrowserImportInput; @@ -99,11 +108,13 @@ export const make = Effect.gen(function* BrowserImportMake() { if (!definition) { return yield* new BrowserImportFailedError({ sourceId: input.input.sourceId, - reason: "unknown source", + reason: "unknownSource", }); } - const blocked = yield* Effect.promise(() => unavailableReason(definition, platform)); + const blocked = yield* unavailableReason(definition, platform, paths).pipe( + Effect.provide(platformServices), + ); if (blocked !== undefined) { return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); } @@ -120,7 +131,9 @@ export const make = Effect.gen(function* BrowserImportMake() { // 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* Effect.promise(() => listSourceProfiles(definition)); + const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe( + Effect.provide(platformServices), + ); const requestedProfile = sourceProfiles.find( (profile) => profile.directory === input.input.sourceProfileDirectory, ); @@ -131,28 +144,30 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - const cookies: ReadonlyArray = yield* Effect.tryPromise({ - try: () => - readChromiumCookies({ - cookieDatabasePath: cookieDatabasePath(definition, requestedProfile.directory), - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - platform, - }), - catch: (cause) => - new BrowserImportFailedError({ - sourceId: definition.id, - reason: cause instanceof ChromiumCookieReadError ? cause.failure.reason : "readFailed", - }), - }); + const cookies = 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( - () => new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }), - ), - ); + 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. diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index 7a4ca8140229..05da10effe42 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the +// OSCrypt primitives Chromium uses; Effect has no equivalent. /** * Chromium cookie extraction. * @@ -15,10 +16,13 @@ */ import * as Keyring from "@napi-rs/keyring"; import * as NodeCrypto from "node:crypto"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeSqlite from "node:sqlite"; + +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; @@ -41,22 +45,42 @@ export interface ChromiumCookie { readonly sameSite: "no_restriction" | "lax" | "strict"; } -export type ChromiumCookieReadFailure = - | { readonly reason: "needsKeychainApproval" } - | { readonly reason: "keychainItemMissing" } - | { readonly reason: "browserRunning" } - | { readonly reason: "unsupportedPlatform" }; - -export class ChromiumCookieReadError extends Error { - readonly failure: ChromiumCookieReadFailure; +export const ChromiumCookieReadReason = Schema.Literals([ + "needsKeychainApproval", + "keychainItemMissing", + "browserRunning", + "unsupportedPlatform", + "readFailed", +]); +export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; - constructor(failure: ChromiumCookieReadFailure) { - super(`Could not read Chromium cookies: ${failure.reason}`); - this.name = "ChromiumCookieReadError"; - this.failure = failure; +export class ChromiumCookieReadError extends Schema.TaggedErrorClass()( + "ChromiumCookieReadError", + { + reason: ChromiumCookieReadReason, + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not read Chromium cookies: ${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 @@ -97,45 +121,52 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => { * the user means the prompt can be approved while nothing is left listening — * which reads as "approving did nothing". */ -async function readMacKeychainPassword(service: string, account: string): Promise { - let password: string | null; - try { - password = new Keyring.Entry(service, account).getPassword(); - } catch (cause) { - const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); - // Distinguish the causes rather than reporting "approve the prompt" for a - // failure approving cannot fix. - if (/no (matching )?entry|not found/i.test(message)) { - throw new ChromiumCookieReadError({ reason: "keychainItemMissing" }); - } - throw new ChromiumCookieReadError({ reason: "needsKeychainApproval" }); - } +const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* ( + service: string, + account: 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", + cause, + }); + }, + }); if (password === null || password === "") { - throw new ChromiumCookieReadError({ reason: "keychainItemMissing" }); + return yield* new ChromiumCookieReadError({ reason: "keychainItemMissing" }); } 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. */ -async function copyCookieDatabase(cookiePath: string): Promise<{ - readonly path: string; - readonly cleanup: () => Promise; -}> { - const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-cookie-import-")); - const target = NodePath.join(directory, "Cookies"); - await NodeFSP.copyFile(cookiePath, target); - for (const suffix of ["-wal", "-shm"]) { - await NodeFSP.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).catch(() => undefined); - } - return { - path: target, - cleanup: () => NodeFSP.rm(directory, { recursive: true, force: true }).catch(() => undefined), - }; -} +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); + // The sidecars only exist while the browser holds the database open, so a + // missing one is normal rather than a failure. + yield* Effect.forEach(["-wal", "-shm"], (suffix) => + fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(Effect.ignore), + ); + return target; +}); const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => { const buffer = Buffer.from(encrypted); @@ -166,16 +197,16 @@ export interface ChromiumCookieSource { readonly platform: NodeJS.Platform; } -export async function readChromiumCookies( +export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( source: ChromiumCookieSource, -): Promise> { +) { 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. - throw new ChromiumCookieReadError({ reason: "unsupportedPlatform" }); + return yield* new ChromiumCookieReadError({ reason: "unsupportedPlatform" }); } - const password = await readMacKeychainPassword(source.keychainService, source.keychainAccount); + const password = yield* readMacKeychainPassword(source.keychainService, source.keychainAccount); const key = NodeCrypto.pbkdf2Sync( password, MAC_KEY_SALT, @@ -184,54 +215,44 @@ export async function readChromiumCookies( "sha1", ); - const snapshot = await copyCookieDatabase(source.cookieDatabasePath); - try { - const database = new NodeSqlite.DatabaseSync(snapshot.path, { readOnly: true }); - try { - const rows = database - .prepare( - `select host_key, name, encrypted_value, path, - expires_utc / 1000000 as expires_seconds, - is_secure, is_httponly, samesite - from cookies`, - ) - .all() as unknown as ReadonlyArray<{ - host_key: string; - name: string; - encrypted_value: Uint8Array; - path: string; - expires_seconds: number; - is_secure: number; - is_httponly: number; - samesite: number; - }>; - - const cookies: ChromiumCookie[] = []; - for (const row of rows) { - const value = decryptValue(row.encrypted_value, key, row.host_key); - if (value === null) continue; - const secure = row.is_secure === 1; - // Electron matches cookies to a URL rather than a bare domain, so a - // host-only entry keeps its leading dot stripped for the URL but not - // for the domain it is registered under. - const host = row.host_key.startsWith(".") ? row.host_key.slice(1) : row.host_key; - cookies.push({ - url: `${secure ? "https" : "http"}://${host}${row.path}`, - name: row.name, - value, - domain: row.host_key, - path: row.path, - secure, - httpOnly: row.is_httponly === 1, - expirationDate: toUnixSeconds(row.expires_seconds), - sameSite: sameSiteFromColumn(row.samesite), - }); - } - return cookies; - } finally { - database.close(); - } - } finally { - await snapshot.cleanup(); + const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( + Effect.mapError((cause) => new ChromiumCookieReadError({ reason: "readFailed", 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", cause })), + ); + + const cookies: ChromiumCookie[] = []; + for (const row of rows) { + const value = decryptValue(row.encrypted_value, key, row.host_key); + if (value === null) continue; + const secure = row.is_secure === 1; + // Electron matches cookies to a URL rather than a bare domain, so a + // host-only entry keeps its leading dot stripped for the URL but not for + // the domain it is registered under. + const host = row.host_key.startsWith(".") ? row.host_key.slice(1) : row.host_key; + cookies.push({ + url: `${secure ? "https" : "http"}://${host}${row.path}`, + name: row.name, + value, + domain: row.host_key, + path: row.path, + secure, + httpOnly: row.is_httponly === 1, + expirationDate: toUnixSeconds(row.expires_seconds), + sameSite: sameSiteFromColumn(row.samesite), + }); } -} + return cookies satisfies ReadonlyArray; +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 20f39bdb4f0a..c43d85e3ee31 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -1,50 +1,133 @@ -// @effect-diagnostics nodeBuiltinImport:off - Drives the raw fs paths the module under test uses. -import { afterEach, describe, expect, it } from "@effect/vitest"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; +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 { BROWSER_IMPORT_SOURCES, cookieDatabasePath, isSourceRunning } from "./Sources.ts"; +import { + BROWSER_IMPORT_SOURCES, + cookieDatabasePath, + isSourceInstalled, + isSourceRunning, + listSourceProfiles, + sourcePaths, +} from "./Sources.ts"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; -const realHome = process.env.HOME; - -// `userDataDirectory()` resolves `os.homedir()` on every call, and on POSIX -// that reads $HOME, so a scratch home is enough to exercise the real function. -const withScratchHome = async () => { - const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3code-sources-")); - process.env.HOME = home; - await NodeFSP.mkdir(helium.userDataDirectory(), { recursive: true }); - return home; -}; - -afterEach(() => { - if (realHome === undefined) delete process.env.HOME; - else process.env.HOME = realHome; + +/** 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); + describe("isSourceRunning", () => { - it("reads Chromium's dangling SingletonLock symlink as a running browser", async () => { - // Chromium points `SingletonLock` at `-`, a target that never - // exists on disk. A check that follows the link reports a running browser - // as closed, which lets an import read a live, mid-write cookie database. - await withScratchHome(); - expect(await isSourceRunning(helium)).toBe(false); - - await NodeFSP.symlink( - "host-that-does-not-exist-1234", - NodePath.join(helium.userDataDirectory(), "SingletonLock"), - ); - - expect(await isSourceRunning(helium)).toBe(true); - }); + 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("follows the presence of the user-data directory", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + assert.isTrue(yield* isSourceInstalled(helium, paths)); + + yield* fileSystem.remove(helium.userDataDirectory(paths), { recursive: true }); + assert.isFalse(yield* isSourceInstalled(helium, paths)); + }), + ), + ); +}); + +describe("listSourceProfiles", () => { + it.effect("falls back to Default when Local State is absent", () => + run( + Effect.gen(function* () { + const paths = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + { directory: "Default", name: "Default" }, + ]); + }), + ), + ); + + 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("falls back to Default when Local State is malformed", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const paths = yield* withSourceHome(); + yield* fileSystem.writeFileString( + `${helium.userDataDirectory(paths)}/Local State`, + "{not-json", + ); + + assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + { directory: "Default", name: "Default" }, + ]); + }), + ), + ); }); describe("cookieDatabasePath", () => { - it("places the cookie database under the source profile directory", async () => { - const home = await withScratchHome(); - expect(cookieDatabasePath(helium, "Profile 1")).toBe( - NodePath.join(home, "Library/Application Support/net.imput.helium/Profile 1/Cookies"), - ); - }); + 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`, + ); + }), + ), + ); }); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 8ab277a77950..4489dac22102 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -8,18 +8,34 @@ * * @module BrowserImportSources */ -// @effect-diagnostics nodeBuiltinImport:off import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; +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"; + +/** + * 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: () => string; + readonly userDataDirectory: (paths: SourcePaths) => string; readonly keychainService: string; readonly keychainAccount: string; } @@ -29,36 +45,34 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray - NodePath.join(NodeOS.homedir(), "Library", "Application Support", "net.imput.helium"), + userDataDirectory: ({ path, home }) => + path.join(home, "Library", "Application Support", "net.imput.helium"), keychainService: "Helium Storage Key", keychainAccount: "Helium", }, ]; -const pathExists = (path: string): Promise => - NodeFSP.access(path).then( - () => true, - () => false, - ); - -/** - * Tests the directory entry itself rather than whatever it points at. - * - * Chromium points `SingletonLock` at `-`, a target that never - * exists, so following the link reports a running browser as closed — exactly - * backwards, and it would let an import read a live, mid-write database. - */ -const entryExists = (path: string): Promise => - NodeFSP.lstat(path).then( - () => true, - () => false, - ); - export const cookieDatabasePath = ( definition: BrowserImportSourceDefinition, + paths: SourcePaths, profileDirectory: string, -): string => NodePath.join(definition.userDataDirectory(), profileDirectory, "Cookies"); +): 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)); + +const DEFAULT_PROFILES: ReadonlyArray = [ + { directory: "Default", name: "Default" }, +]; /** * Profiles the source browser knows about, read from its `Local State`. @@ -68,41 +82,53 @@ export const cookieDatabasePath = ( * case, and failing the whole import over a missing display name would be * disproportionate. */ -export async function listSourceProfiles( +export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, -): Promise> { - const fallback: ReadonlyArray = [ - { directory: "Default", name: "Default" }, - ]; - try { - const raw = await NodeFSP.readFile( - NodePath.join(definition.userDataDirectory(), "Local State"), - "utf8", - ); - const parsed = JSON.parse(raw) as { - profile?: { info_cache?: Record }; - }; - const entries = Object.entries(parsed.profile?.info_cache ?? {}); - if (entries.length === 0) return fallback; - return entries.map(([directory, info]) => ({ - directory, - name: info.name?.trim() || directory, - })); - } catch { - return fallback; - } -} + paths: SourcePaths, +) { + const fileSystem = yield* FileSystem.FileSystem; + const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State"); + + const profiles = yield* fileSystem.readFileString(localStatePath).pipe( + Effect.flatMap(decodeLocalState), + Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), + Effect.map((entries) => + entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + + return profiles.length === 0 ? DEFAULT_PROFILES : profiles; +}); /** Whether the browser is running, which leaves its cookie DB mid-write. */ -export async function isSourceRunning(definition: BrowserImportSourceDefinition): Promise { +export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( + definition: BrowserImportSourceDefinition, + paths: SourcePaths, +) { + const fileSystem = yield* FileSystem.FileSystem; + 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. - return entryExists(NodePath.join(definition.userDataDirectory(), "SingletonLock")); -} + // + // 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* fileSystem.stat(lock).pipe( + Effect.catchCause(() => fileSystem.readLink(lock)), + Effect.as(true), + Effect.orElseSucceed(() => false), + ); +}); -export async function isSourceInstalled( +export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, -): Promise { - return pathExists(definition.userDataDirectory()); -} + paths: SourcePaths, +) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem + .exists(definition.userDataDirectory(paths)) + .pipe(Effect.orElseSucceed(() => false)); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index d474cd4fc19d..09ea4c9fcb02 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,7 +7,9 @@ * @module IntegrationsSettings */ import { + BROWSER_IMPORT_FAILURE_COPY, BROWSER_IMPORT_UNAVAILABLE_COPY, + BrowserImportFailureReason, BROWSER_PROFILE_MAX_COUNT, type BrowserProfile, BROWSER_PROFILE_NAME_MAX_LENGTH, @@ -110,6 +112,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 ( @@ -618,18 +633,10 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }); }) .catch((cause: unknown) => { - const reason = String((cause as { message?: unknown })?.message ?? ""); - const known = ( - Object.keys(BROWSER_IMPORT_UNAVAILABLE_COPY) as ReadonlyArray< - keyof typeof BROWSER_IMPORT_UNAVAILABLE_COPY - > - ).find((candidate) => reason.includes(candidate)); toastManager.add({ type: "error", title: `Could not import from ${source.name}`, - description: known - ? BROWSER_IMPORT_UNAVAILABLE_COPY[known] - : "The browser's cookie database could not be read.", + description: BROWSER_IMPORT_FAILURE_COPY[importFailureReason(cause)], }); }) .finally(() => setBusy(false)); diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index acc331fb9561..8b4e6eb1c5f6 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -38,6 +38,26 @@ export const BrowserImportUnavailableReason = Schema.Literals([ ]); 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. */ @@ -94,3 +114,12 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< 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.", +}; From 063679814613a3242846fcf62775ef5416ab253b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 00:31:45 +0200 Subject: [PATCH 05/18] fix(web): confirm profile removal and keep the table honest Removing a profile deleted it and wiped its cookies and cache from a single icon-menu click, while every comparable destructive action in Settings confirms first. It now routes through the same AlertDialog. The Default badge resolved against the unfiltered profile list while the table renders only non-incognito rows, so a stored default of "incognito" left the section with no default marked at all. It now resolves against the rows that render. Reopening the import menu kept the previous source list on screen while the refresh was in flight, leaving a source that had since become unavailable selectable; the list is cleared first so the menu shows its loading state. A cookie sidecar that exists but cannot be copied is no longer ignored alongside the missing-file case. SQLite would open the snapshot without the write-ahead log and return a cookie set silently missing its newest transactions. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/ChromiumCookies.ts | 13 +- .../settings/IntegrationsSettings.tsx | 156 ++++++++++-------- 2 files changed, 97 insertions(+), 72 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index 05da10effe42..95c22f4bf83a 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -160,10 +160,17 @@ const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-cookie-import-" }); const target = path.join(directory, "Cookies"); yield* fileSystem.copyFile(cookiePath, target); - // The sidecars only exist while the browser holds the database open, so a - // missing one is normal rather than a failure. + // 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.ignore), + fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.void, + ), + ), ); return target; }); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 09ea4c9fcb02..4c2f5bf48da3 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -64,6 +64,15 @@ import { MenuTrigger, } from "../ui/menu"; import { toastManager } from "../ui/toast"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; @@ -533,12 +542,18 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const updateSettings = useUpdatePrimarySettings(); const environmentId = usePrimaryEnvironment()?.environmentId; const [sources, setSources] = useState | null>(null); + const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); const [busy, setBusy] = useState(false); const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); 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(profiles, defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID; + findBrowserProfile(listedProfiles, defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID; const uniqueName = (base: string) => { const taken = new Set(profiles.map((profile) => profile.name)); @@ -599,6 +614,10 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const loadSources = () => { if (!previewBridge) return; + // Cleared first: availability changes while the app runs (quitting a + // browser clears `browserRunning`), and showing the previous answer during + // the refresh lets the user start an import the source no longer supports. + setSources(null); void previewBridge .listBrowserImportSources() .then(setSources) @@ -740,82 +759,81 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } > {/* - The bordered container groups rows unambiguously at any width, and - carries the bottom spacing `SettingsRow` leaves to its children - (`pt-3 pb-1`). + Dimmed as a whole when the section is unavailable. The built-in rows + are a plain span and a badge rather than `h3`/`p` or disabled controls, + so the block's own dimming does not reach them and they would be the + only full-contrast content inside "only available in the desktop app". */} -
- {profiles - .filter((profile) => profile.kind !== "incognito") - .map((profile, index) => { - const builtIn = isBuiltInBrowserProfileId(profile.id); - const isDefault = profile.id === resolvedDefaultId; - return ( -
0 && "border-t border-border/60", +
+ {listedProfiles.map((profile, index) => { + const builtIn = isBuiltInBrowserProfileId(profile.id); + const isDefault = profile.id === resolvedDefaultId; + return ( +
0 && "border-t border-border/60", + )} + > + + {builtIn ? ( + {profile.name} + ) : ( + renameProfile(profile.id, next)} + /> )} - > - - {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} - - ) : ( - Default : null} + + + renameProfile(profile.id, next)} + aria-label={`${profile.name} options`} /> - )} - {isDefault ? Default : null} - - - - } + } + > + + + + updateSettings({ browserDefaultProfileId: profile.id })} > - - - + Set as default + + clearProfileData(profile.id, profile.name)}> + Clear cookies and cache + + {builtIn ? null : ( updateSettings({ browserDefaultProfileId: profile.id })} + variant="destructive" + onClick={() => setProfilePendingRemoval(profile)} > - Set as default + Remove profile and data - clearProfileData(profile.id, profile.name)}> - Clear cookies and cache - - {builtIn ? null : ( - removeProfile(profile.id)}> - Remove profile and data - - )} - - -
- ); - })} + )} + + +
+ ); + })}
Date: Mon, 17 Aug 2026 01:06:24 +0200 Subject: [PATCH 06/18] refactor(desktop): name the database a cookie read failed on `ChromiumCookieReadError` carried only a reason and a cause, so every `readFailed` and keychain refusal logged identically. A user with several Chromium browsers installed had no way to tell which one refused. The database path is now a structural attribute and the message derives from it, matching how `BrowserSession`'s errors carry their partition. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/ChromiumCookies.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index 95c22f4bf83a..08e91f19abe0 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -58,12 +58,18 @@ export class ChromiumCookieReadError extends Schema.TaggedErrorClass { 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(), @@ -134,12 +141,16 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo 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" }); + return yield* new ChromiumCookieReadError({ + reason: "keychainItemMissing", + cookieDatabasePath, + }); } return password; }); @@ -210,10 +221,17 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie 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" }); + return yield* new ChromiumCookieReadError({ + reason: "unsupportedPlatform", + cookieDatabasePath: source.cookieDatabasePath, + }); } - const password = yield* readMacKeychainPassword(source.keychainService, source.keychainAccount); + const password = yield* readMacKeychainPassword( + source.keychainService, + source.keychainAccount, + source.cookieDatabasePath, + ); const key = NodeCrypto.pbkdf2Sync( password, MAC_KEY_SALT, @@ -223,7 +241,14 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie ); const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( - Effect.mapError((cause) => new ChromiumCookieReadError({ reason: "readFailed", cause })), + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), ); const rows = yield* Effect.gen(function* () { @@ -237,7 +262,14 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie return yield* decodeCookieRows(raw); }).pipe( Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), - Effect.mapError((cause) => new ChromiumCookieReadError({ reason: "readFailed", cause })), + Effect.mapError( + (cause) => + new ChromiumCookieReadError({ + reason: "readFailed", + cookieDatabasePath: source.cookieDatabasePath, + cause, + }), + ), ); const cookies: ChromiumCookie[] = []; From 3ecd5d88db35b2fc5de8b38668d3596493cdbac6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 01:11:53 +0200 Subject: [PATCH 07/18] docs(web): fold the stale profile-setting comments into one Renaming the component left three consecutive doc blocks above it, two of them describing controls that no longer exist separately. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/IntegrationsSettings.tsx | 33 +++++-------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 4c2f5bf48da3..e6ebf05cf5be 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -42,15 +42,6 @@ import { usePrimaryEnvironment } from "~/state/environments"; import { isElectron } from "../../env"; import { Badge } from "../ui/badge"; -import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "../ui/alert-dialog"; import { Menu, MenuGroup, @@ -513,21 +504,6 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode ); } -/** - * Create, rename, and remove browser profiles. - * - * 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. - */ - -/** - * Per-profile cookie import from a browser installed on this machine. - * - * 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. - */ /** * Profile list, its header menu, and the import flow. * @@ -535,6 +511,14 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode * 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. + * + * 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); @@ -542,7 +526,6 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const updateSettings = useUpdatePrimarySettings(); const environmentId = usePrimaryEnvironment()?.environmentId; const [sources, setSources] = useState | null>(null); - const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); const [busy, setBusy] = useState(false); const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); From 82a184b276d3fe8b7901e815180d6f83cb6e5675 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 01:40:20 +0200 Subject: [PATCH 08/18] fix(desktop): stop listing browsers that are not installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection keyed off the browser's user-data directory, which 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 and Helium listed Edge, Brave, Vivaldi, Opera and Arc as importable sources — each holding nothing but a `NativeMessagingHosts` folder. It now keys off the cookie database, which is the thing an import actually needs. 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 Safari is still found and the user gets the Full Disk Access prompt instead of Safari vanishing from the list. A source that is not on the machine is now left out of the menu rather than shown as a dead row. Every other unavailable reason stays visible, because each names something the user can do — quit the browser, grant access. Co-Authored-By: Claude Opus 5 (1M context) --- .../BrowserImport/BrowserImport.test.ts | 3 ++ .../src/preview/BrowserImport/Sources.test.ts | 14 +++++- .../src/preview/BrowserImport/Sources.ts | 46 +++++++++++++++---- .../settings/IntegrationsSettings.tsx | 18 ++++++-- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 6d4ab4639f87..f7d1b02f6ca3 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -40,6 +40,9 @@ const withImporter = Effect.fnUntraced(function* () { 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( diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index c43d85e3ee31..fa81e639a901 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -54,14 +54,24 @@ describe("isSourceRunning", () => { }); describe("isSourceInstalled", () => { - it.effect("follows the presence of the user-data directory", () => + 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)); - yield* fileSystem.remove(helium.userDataDirectory(paths), { recursive: true }); + yield* fileSystem.remove(root, { recursive: true }); assert.isFalse(yield* isSourceInstalled(helium, paths)); }), ), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 4489dac22102..3d905dfd3e41 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -101,12 +101,26 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf return profiles.length === 0 ? DEFAULT_PROFILES : profiles; }); +/** + * 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 fileSystem = yield* FileSystem.FileSystem; 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 @@ -116,19 +130,31 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") // `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* fileSystem.stat(lock).pipe( - Effect.catchCause(() => fileSystem.readLink(lock)), - Effect.as(true), - Effect.orElseSucceed(() => false), - ); + 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 fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem - .exists(definition.userDataDirectory(paths)) - .pipe(Effect.orElseSucceed(() => false)); + 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/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index e6ebf05cf5be..9c827e9055fe 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -595,6 +595,14 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }); }; + // 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", + ); + const loadSources = () => { if (!previewBridge) return; // Cleared first: availability changes while the app runs (quitting a @@ -678,15 +686,15 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { Import from {sources === null ? ( Looking for browsers… - ) : sources.length === 0 ? ( + ) : importableSources.length === 0 ? ( No supported browsers found ) : ( - sources.flatMap((source) => + importableSources.flatMap((source) => source.unavailable ? [ - // Kept visible with its reason rather than hidden: - // "Helium is running, quit it" beats "Helium isn't - // listed". + // Kept visible with its reason, because each remaining + // reason is something the user can act on: "Helium is + // running, quit it" beats "Helium isn't listed". {source.name} From 28cb2365c5b6b203dd7f8df3bcac5e00d208f6d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 02:54:47 +0200 Subject: [PATCH 09/18] fix(web): don't leave an empty profile behind when import can't start `importInto` created the target profile before `runImport` checked for an environment and a bridge, so choosing an import target before the environment resolved left a new empty profile named after the source browser and produced no toast at all. The check now happens first and says what went wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/settings/IntegrationsSettings.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 9c827e9055fe..870594978d53 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -657,6 +657,17 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { sourceProfileDirectory: string, target: "new" | { readonly id: string; readonly name: string }, ) => { + // Checked before creating anything. `runImport` bails on the same + // condition, so creating first left an empty profile named after the + // source browser behind, with no toast to explain it. + if (!environmentId || !previewBridge) { + toastManager.add({ + type: "error", + title: `Could not import from ${source.name}`, + description: "No environment is connected yet.", + }); + return; + } if (target === "new") { const created = createProfile(source.name); runImport(source, sourceProfileDirectory, created.id, created.name); From 6fc42914a8921ca371355ac8c9e0e2c978a89451 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 03:36:51 +0200 Subject: [PATCH 10/18] fix(web): say why clearing a profile did nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clearProfileData` returned silently when no environment was connected, while the menu item stayed enabled — a dead control with no explanation. It now reports the same way `importInto` does for the same precondition. Also switches `browserImport` to the subpath namespace import the rest of `packages/contracts` uses. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/IntegrationsSettings.tsx | 36 ++++++++++++------- packages/contracts/src/browserImport.ts | 2 +- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 870594978d53..71f286edc8b7 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -568,7 +568,17 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { }; const clearProfileData = (id: string, name: string) => { - if (!environmentId || !previewBridge) return; + // 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), @@ -761,17 +771,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } > {/* - Dimmed as a whole when the section is unavailable. The built-in rows - are a plain span and a badge rather than `h3`/`p` or disabled controls, - so the block's own dimming does not reach them and they would be the - only full-contrast content inside "only available in the desktop app". + The bordered container groups rows unambiguously at any width, and + carries the bottom spacing `SettingsRow` leaves to its children + (`pt-3 pb-1`). */} -
+
{listedProfiles.map((profile, index) => { const builtIn = isBuiltInBrowserProfileId(profile.id); const isDefault = profile.id === resolvedDefaultId; @@ -785,7 +789,15 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { > {builtIn ? ( - {profile.name} + // 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} + ) : ( Date: Mon, 17 Aug 2026 03:54:50 +0200 Subject: [PATCH 11/18] fix(web): dim the Default badge with the rest of its row A `Badge` carries no disabled treatment of its own, so the solid `bg-primary` pill stayed at full strength in the desktop-only block while the name, rename field and row menu button around it all sat at 0.64. The badge it replaced was inside the dimmed span, so this was a regression. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/settings/IntegrationsSettings.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 71f286edc8b7..ccb53dc6b20f 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -810,7 +810,13 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { onCommit={(next) => renameProfile(profile.id, next)} /> )} - {isDefault ? Default : null} + {/* + 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} Date: Mon, 17 Aug 2026 04:55:29 +0200 Subject: [PATCH 12/18] fix(desktop): reject crafted profile directories from Local State MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `info_cache` keys are directory names from the browser's own metadata file, which anything running as the user can write. A key like `../../../../secrets` was returned as a profile and handed to `cookieDatabasePath`, so the import would read a database outside the browser's user-data directory — the same escape the IPC-side guard closes, reached through the other door. Only a single plain path segment is accepted now. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/preview/BrowserImport/Sources.test.ts | 25 +++++++++++++++++++ .../src/preview/BrowserImport/Sources.ts | 14 +++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index fa81e639a901..d489541c7980 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -141,3 +141,28 @@ describe("cookieDatabasePath", () => { ), ); }); + +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 index 3d905dfd3e41..72413472d54e 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -70,6 +70,14 @@ const LocalState = Schema.Struct({ }); 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 DEFAULT_PROFILES: ReadonlyArray = [ { directory: "Default", name: "Default" }, ]; @@ -92,6 +100,12 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const profiles = 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 })), ), From 7005ec2d27a178570a26aed186cc91547019125e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 10:08:29 +0200 Subject: [PATCH 13/18] fix(desktop): keep host-only cookies host-only on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every imported cookie passed `domain` to `session.cookies.set`. Electron reads any `domain` as marking a domain cookie and normalizes it with a leading dot, so every host-only row — Chromium stores those without one — came out scoped to all subdomains of the host it had been confined to. It also rejects `__Host-` cookies, which require `domain` to be absent. `domain` is now carried only for rows Chromium marked as domain cookies, and omitted from the write otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/BrowserImport.ts | 5 ++- .../BrowserImport/ChromiumCookies.test.ts | 26 ++++++++++++ .../preview/BrowserImport/ChromiumCookies.ts | 40 +++++++++++++++---- 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 4c92a96b824c..a2ac44e11845 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -180,7 +180,10 @@ export const make = Effect.gen(function* BrowserImportMake() { url: cookie.url, name: cookie.name, value: cookie.value, - domain: cookie.domain, + // 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, 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 000000000000..46e0c02f2399 --- /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 index 08e91f19abe0..9537a2589dd9 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -36,7 +36,13 @@ export interface ChromiumCookie { readonly url: string; readonly name: string; readonly value: string; - readonly domain: 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; @@ -186,6 +192,29 @@ const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase 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 }), + }; +}; + const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => { const buffer = Buffer.from(encrypted); if (buffer.length === 0) return ""; @@ -277,15 +306,12 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie const value = decryptValue(row.encrypted_value, key, row.host_key); if (value === null) continue; const secure = row.is_secure === 1; - // Electron matches cookies to a URL rather than a bare domain, so a - // host-only entry keeps its leading dot stripped for the URL but not for - // the domain it is registered under. - const host = row.host_key.startsWith(".") ? row.host_key.slice(1) : row.host_key; + const scope = cookieScope(row.host_key, row.path, secure); cookies.push({ - url: `${secure ? "https" : "http"}://${host}${row.path}`, + url: scope.url, name: row.name, value, - domain: row.host_key, + domain: scope.domain, path: row.path, secure, httpOnly: row.is_httponly === 1, From 83077b1305a675543a717305a064a6a65f3d9220 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 10:09:51 +0200 Subject: [PATCH 14/18] fix(desktop): find profiles when Local State cannot be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback assumed a single `Default` profile, so a browser whose cookies live in `Profile 1` reported nothing to import — and since sources without a cookie database are now left out of the menu, it disappeared entirely rather than degrading. The user-data directory is scanned for directories that hold a cookie database instead, which is the same signal the install check uses. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/preview/BrowserImport/Sources.test.ts | 38 +++++++++++++++---- .../src/preview/BrowserImport/Sources.ts | 31 ++++++++++----- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index d489541c7980..15dbd0799e95 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -71,6 +71,13 @@ describe("isSourceInstalled", () => { 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)); }), @@ -79,12 +86,20 @@ describe("isSourceInstalled", () => { }); describe("listSourceProfiles", () => { - it.effect("falls back to Default when Local State is absent", () => + 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: "Default", name: "Default" }, + { directory: "Profile 1", name: "Profile 1" }, ]); }), ), @@ -110,15 +125,15 @@ describe("listSourceProfiles", () => { ), ); - it.effect("falls back to Default when Local State is malformed", () => + it.effect("scans for profiles when Local State is malformed", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const paths = yield* withSourceHome(); - yield* fileSystem.writeFileString( - `${helium.userDataDirectory(paths)}/Local State`, - "{not-json", - ); + 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" }, @@ -126,6 +141,15 @@ describe("listSourceProfiles", () => { }), ), ); + + 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), []); + }), + ), + ); }); describe("cookieDatabasePath", () => { diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 72413472d54e..5cb942a7c811 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -78,17 +78,13 @@ const isSafeProfileDirectory = (directory: string): boolean => !/[\\/]/.test(directory) && !directory.includes("\u0000"); -const DEFAULT_PROFILES: ReadonlyArray = [ - { directory: "Default", name: "Default" }, -]; - /** * Profiles the source browser knows about, read from its `Local State`. * - * Falls back to the `Default` directory when that file is unreadable or has no - * profile cache: a browser that has only ever had one profile is the common - * case, and failing the whole import over a missing display name would be - * disproportionate. + * 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, @@ -97,7 +93,8 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const fileSystem = yield* FileSystem.FileSystem; const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State"); - const profiles = yield* fileSystem.readFileString(localStatePath).pipe( + 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 @@ -111,8 +108,22 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ), Effect.orElseSucceed(() => [] as ReadonlyArray), ); + if (declared.length > 0) return declared; - return profiles.length === 0 ? DEFAULT_PROFILES : profiles; + // `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 found.filter((profile) => profile !== undefined); }); /** From 7b8e2945f47d19d090c920e5f993df0bf721ca2f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 10:15:34 +0200 Subject: [PATCH 15/18] fix(desktop): count cookies the import could not decrypt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows `decryptChromiumValue` could not read were dropped without a trace, so an import that recovered a fraction of the database still reported a clean success. They now reach the user as part of the skipped total. This matters most on Linux, where records written under a keyring-derived `v11` key are unreadable unless that secret is reachable — see the note in `ChromiumKeys`. Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/BrowserImport.ts | 8 ++++--- .../preview/BrowserImport/ChromiumCookies.ts | 21 +++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index a2ac44e11845..a0c91ce016e8 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -144,7 +144,7 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - const cookies = yield* readChromiumCookies({ + const read = yield* readChromiumCookies({ cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory), keychainService: definition.keychainService, keychainAccount: definition.keychainAccount, @@ -172,8 +172,10 @@ export const make = Effect.gen(function* BrowserImportMake() { // 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; - let skipped = 0; - for (const cookie of cookies) { + // 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; + for (const cookie of read.cookies) { const written = yield* Effect.tryPromise({ try: () => session.cookies.set({ diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index 9537a2589dd9..22ea1de3ef85 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -236,6 +236,16 @@ const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): strin } }; +/** + * 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; +} + export interface ChromiumCookieSource { readonly cookieDatabasePath: string; readonly keychainService: string; @@ -302,9 +312,16 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie ); 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; for (const row of rows) { const value = decryptValue(row.encrypted_value, key, row.host_key); - if (value === null) continue; + if (value === null) { + undecryptable += 1; + continue; + } const secure = row.is_secure === 1; const scope = cookieScope(row.host_key, row.path, secure); cookies.push({ @@ -319,5 +336,5 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie sameSite: sameSiteFromColumn(row.samesite), }); } - return cookies satisfies ReadonlyArray; + return { cookies, undecryptable } satisfies CookieReadResult; }); From 05fe2c1c07af19afd480b84df6fc54f11a5ef45a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 11:05:47 +0200 Subject: [PATCH 16/18] feat(web): import browser cookies through a guided wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Add-profile menu buried imports in nested submenus and disabled rows — a running browser was a dead "quit it" line you couldn't act on, and picking a source profile meant hunting through submenus. The menu now lists each browser as a plain row; clicking one opens a wizard that carries the whole import. The wizard has a screen for every state instead of a disabled row: quit the browser and retry, choose which source profile and where it lands, then import. A new target profile is registered only once cookies actually arrive, so a blocked or empty import leaves nothing behind. Sources are cached and refreshed without blanking, so the menu no longer reflows on open. The step transitions are pure and tested. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/BrowserImportWizard.tsx | 323 ++++++++++++++++++ .../settings/IntegrationsSettings.tsx | 197 +++++------ .../browserImportWizard.logic.test.ts | 80 +++++ .../settings/browserImportWizard.logic.ts | 81 +++++ 4 files changed, 561 insertions(+), 120 deletions(-) create mode 100644 apps/web/src/components/settings/BrowserImportWizard.tsx create mode 100644 apps/web/src/components/settings/browserImportWizard.logic.test.ts create mode 100644 apps/web/src/components/settings/browserImportWizard.logic.ts diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx new file mode 100644 index 000000000000..644321660618 --- /dev/null +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -0,0 +1,323 @@ +import type { BrowserImportSource } from "@t3tools/contracts"; +import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts"; +import { useRef, useState } from "react"; + +import { randomUUID } from "~/lib/utils"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Radio, RadioGroup } from "../ui/radio-group"; +import { Spinner } from "../ui/spinner"; +import { + initialWizardStep, + isRetryableReason, + 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. + + + + + + + + ); +} + +function ConfigureStep({ + source, + targetProfiles, + canCreateProfile, + sourceProfileDirectory, + onSourceProfileChange, + target, + onTargetChange, + onCancel, + onImport, +}: { + 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; +}) { + const hasMultipleSourceProfiles = source.profiles.length > 1; + return ( + <> + + Import from {source.name} + + Copy {source.name}’s cookies and logins into a browser profile here. + + + + {hasMultipleSourceProfiles ? ( +
+

Which {source.name} profile?

+ + {source.profiles.map((profile) => ( + + ))} + +
+ ) : null} +
+

Import into

+ + {canCreateProfile ? ( + + ) : null} + {targetProfiles.map((profile) => ( + + ))} + +
+
+ + + + + + ); +} + +function ImportingStep() { + return ( + + + Importing cookies… + + ); +} + +function DoneStep({ + imported, + skipped, + targetName, + onClose, +}: { + readonly imported: number; + readonly skipped: number; + 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."} + + + + } 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 ccb53dc6b20f..52e6fd17fa37 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -7,8 +7,6 @@ * @module IntegrationsSettings */ import { - BROWSER_IMPORT_FAILURE_COPY, - BROWSER_IMPORT_UNAVAILABLE_COPY, BrowserImportFailureReason, BROWSER_PROFILE_MAX_COUNT, type BrowserProfile, @@ -33,7 +31,7 @@ import { } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { previewBridge } from "~/components/preview/previewBridge"; @@ -49,9 +47,6 @@ import { MenuItem, MenuPopup, MenuSeparator, - MenuSub, - MenuSubPopup, - MenuSubTrigger, MenuTrigger, } from "../ui/menu"; import { toastManager } from "../ui/toast"; @@ -91,6 +86,8 @@ import { SettingsSection, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard"; +import type { ImportOutcome } from "./browserImportWizard.logic"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; @@ -526,7 +523,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { const updateSettings = useUpdatePrimarySettings(); const environmentId = usePrimaryEnvironment()?.environmentId; const [sources, setSources] = useState | null>(null); - const [busy, setBusy] = useState(false); + const [importSource, setImportSource] = useState(null); const [profilePendingRemoval, setProfilePendingRemoval] = useState(null); const profiles = resolveBrowserProfiles(userProfiles); @@ -613,77 +610,73 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { (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; - // Cleared first: availability changes while the app runs (quitting a - // browser clears `browserRunning`), and showing the previous answer during - // the refresh lets the user start an import the source no longer supports. - setSources(null); void previewBridge .listBrowserImportSources() .then(setSources) - .catch(() => setSources([])); + .catch(() => setSources((previous) => previous ?? [])); }; - const runImport = ( + // 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, - sourceProfileDirectory: string, - targetProfileId: string, - targetName: string, - ) => { - if (!environmentId || !previewBridge) return; - setBusy(true); - void previewBridge - .importBrowserCookies({ + 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, - targetProfileId, - }) - .then((result) => { - toastManager.add({ - type: result.imported > 0 ? "success" : "error", - title: - result.imported > 0 - ? `Imported ${result.imported} cookies into ${targetName}` - : `No cookies imported from ${source.name}`, - // Surfaced rather than hidden: a mostly-skipped import should not - // read as a clean success. - ...(result.skipped > 0 ? { description: `${result.skipped} skipped.` } : {}), - }); - }) - .catch((cause: unknown) => { - toastManager.add({ - type: "error", - title: `Could not import from ${source.name}`, - description: BROWSER_IMPORT_FAILURE_COPY[importFailureReason(cause)], - }); - }) - .finally(() => setBusy(false)); - }; - - const importInto = ( - source: BrowserImportSource, - sourceProfileDirectory: string, - target: "new" | { readonly id: string; readonly name: string }, - ) => { - // Checked before creating anything. `runImport` bails on the same - // condition, so creating first left an empty profile named after the - // source browser behind, with no toast to explain it. - if (!environmentId || !previewBridge) { - toastManager.add({ - type: "error", - title: `Could not import from ${source.name}`, - description: "No environment is connected yet.", + sourceProfileDirectory: input.sourceProfileDirectory, + targetProfileId: input.target.profileId, }); - return; + 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, targetName }; + } catch (cause) { + return { kind: "blocked", reason: importFailureReason(cause) }; } - if (target === "new") { - const created = createProfile(source.name); - runImport(source, sourceProfileDirectory, created.id, created.name); - return; + }; + + // 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; } - runImport(source, sourceProfileDirectory, target.id, target.name); }; const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT; @@ -694,7 +687,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito isn't listed here — it keeps nothing, and you pick it when opening a tab." control={ open && loadSources()}> - }> + }> Add profile @@ -710,60 +703,14 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { ) : importableSources.length === 0 ? ( No supported browsers found ) : ( - importableSources.flatMap((source) => - source.unavailable - ? [ - // Kept visible with its reason, because each remaining - // reason is something the user can act on: "Helium is - // running, quit it" beats "Helium isn't listed". - - - {source.name} - - {BROWSER_IMPORT_UNAVAILABLE_COPY[source.unavailable]} - - - , - ] - : source.profiles.map((sourceProfile) => ( - - - {source.profiles.length > 1 - ? `${source.name} — ${sourceProfile.name}` - : source.name} - - - importInto(source, sourceProfile.directory, "new")} - > - New profile - - - - Existing profile - {profiles - // Incognito is discarded on quit, so importing - // into it would throw the work away. - .filter((profile) => profile.kind !== "incognito") - .map((profile) => ( - - importInto(source, sourceProfile.directory, { - id: profile.id, - name: profile.name, - }) - } - > - {profile.name} - - ))} - - - - )), - ) + // 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} + + )) )} @@ -882,6 +829,16 @@ 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} ); } 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 000000000000..9c72d7fac44b --- /dev/null +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { BrowserImportSource } from "@t3tools/contracts"; + +import { + initialWizardStep, + isRetryableReason, + 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, targetName: "Work" }), + ).toEqual({ step: "done", imported: 12, skipped: 3, 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); + }); +}); 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 000000000000..6cebad2bee7a --- /dev/null +++ b/apps/web/src/components/settings/browserImportWizard.logic.ts @@ -0,0 +1,81 @@ +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 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 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, + 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; + } +} From 463df5e4b179a402c91fff568e4c54124e972a6f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 11:51:33 +0200 Subject: [PATCH 17/18] =?UTF-8?q?feat(web):=20show=20cookie=20counts=20and?= =?UTF-8?q?=20redesign=20the=20import=20step=20as=20From=20=E2=86=92=20Int?= =?UTF-8?q?o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configure step was two flat radio lists. It's now a From → Into layout — the source profiles on one side, the target on the other, side by side when the dialog has room and stacked when it doesn't — so the copy direction reads at a glance. Each source profile shows how many cookies it holds, counted with a bare `COUNT(*)` that needs no decryption, so picking "You — 5,065 cookies" over "test — 6 cookies" is an informed choice. The count is absent where the store can't be read yet (Safari before Full Disk Access). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/preview/BrowserImport/Sources.test.ts | 28 +++ .../src/preview/BrowserImport/Sources.ts | 53 ++++- .../settings/BrowserImportWizard.tsx | 182 ++++++++++++------ packages/contracts/src/browserImport.ts | 6 + 4 files changed, 212 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 15dbd0799e95..34d04fca1fcc 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -1,3 +1,5 @@ +// @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"; @@ -5,6 +7,7 @@ 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, @@ -31,6 +34,16 @@ const withSourceHome = Effect.fnUntraced(function* () { 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( @@ -150,6 +163,21 @@ describe("listSourceProfiles", () => { }), ), ); + + 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", () => { diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 5cb942a7c811..206800b5f824 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -9,11 +9,13 @@ * @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 @@ -78,6 +80,49 @@ const isSafeProfileDirectory = (directory: string): boolean => !/[\\/]/.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`. * @@ -108,7 +153,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - if (declared.length > 0) return declared; + 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; @@ -123,7 +168,11 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf Effect.map((exists) => (exists ? { directory, name: directory } : undefined)), ), ); - return found.filter((profile) => profile !== undefined); + return yield* withCookieCounts( + definition, + paths, + found.filter((profile) => profile !== undefined), + ); }); /** diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index 644321660618..0ea930352c48 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -1,8 +1,9 @@ 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 { randomUUID } from "~/lib/utils"; +import { cn, randomUUID } from "~/lib/utils"; import { Button } from "../ui/button"; import { @@ -15,7 +16,6 @@ import { DialogPopup, DialogTitle, } from "../ui/dialog"; -import { Radio, RadioGroup } from "../ui/radio-group"; import { Spinner } from "../ui/spinner"; import { initialWizardStep, @@ -117,7 +117,7 @@ export function BrowserImportWizard({ return ( (open ? undefined : onClose())}> - + {step.step === "quit" ? ( ) : step.step === "importing" ? ( @@ -176,6 +176,28 @@ function QuitStep({ ); } +/** "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, @@ -186,74 +208,124 @@ function ConfigureStep({ onTargetChange, onCancel, onImport, -}: { - 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; -}) { - const hasMultipleSourceProfiles = source.profiles.length > 1; +}: ConfigureStepProps) { return ( <> Import from {source.name} - - Copy {source.name}’s cookies and logins into a browser profile here. - + Cookies flow from the browser into a profile here. - - {hasMultipleSourceProfiles ? ( -
-

Which {source.name} profile?

- - {source.profiles.map((profile) => ( - - ))} - + + {/* Side by side when the dialog has room, stacked when it doesn't. */} +
+
+

+ From +

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

Import into

- +
+ + +
+
+

+ Into +

{canCreateProfile ? ( - + onTargetChange(NEW_TARGET_VALUE)} + /> ) : null} {targetProfiles.map((profile) => ( - + selected={target === profile.id} + title={profile.name} + subtitle="Existing profile" + onSelect={() => 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 ( diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 848b7d7cb093..016a711ba48e 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -64,6 +64,12 @@ export const BrowserImportSourceProfile = Schema.Struct({ 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; From 2d762e53d46f9f2bfbc38e1046a8af714f3f60e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 17 Aug 2026 11:54:37 +0200 Subject: [PATCH 18/18] feat(desktop): name the sites whose cookies were skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An import that skipped some cookies only said how many. It now says which sites they belonged to — the reader collects the hosts of rows it couldn't decrypt, the writer adds the hosts it couldn't set, and the import result carries the distinct list (capped, since a broken key can skip thousands). The wizard's done screen reads "example.com, google.com and 3 more". Co-Authored-By: Claude Opus 5 (1M context) --- .../preview/BrowserImport/BrowserImport.ts | 22 +++++++++++-- .../preview/BrowserImport/ChromiumCookies.ts | 14 +++++++- .../settings/BrowserImportWizard.tsx | 11 +++++++ .../settings/IntegrationsSettings.tsx | 8 ++++- .../browserImportWizard.logic.test.ts | 32 +++++++++++++++++-- .../settings/browserImportWizard.logic.ts | 14 ++++++++ packages/contracts/src/browserImport.ts | 12 +++++-- 7 files changed, 103 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index a0c91ce016e8..cd4ba0392dad 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -73,6 +73,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* 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; @@ -175,6 +184,7 @@ export const make = Effect.gen(function* BrowserImportMake() { // 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: () => @@ -199,11 +209,17 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.as(true), Effect.catchCause(() => Effect.succeed(false)), ); - if (written) imported += 1; - else skipped += 1; + if (written) { + imported += 1; + } else { + skipped += 1; + skippedDomains.add(cookieHost(cookie.url)); + } } - return { imported, skipped }; + // 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 }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index 22ea1de3ef85..d1462afc481a 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -215,6 +215,10 @@ export const cookieScope = ( }; }; +/** 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 ""; @@ -244,6 +248,8 @@ const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): strin 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 { @@ -316,10 +322,12 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie // 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; @@ -336,5 +344,9 @@ export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookie sameSite: sameSiteFromColumn(row.samesite), }); } - return { cookies, undecryptable } satisfies CookieReadResult; + return { + cookies, + undecryptable, + undecryptableHosts: [...undecryptableHosts], + } satisfies CookieReadResult; }); diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index 0ea930352c48..f7b58ce0d6a5 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -20,6 +20,7 @@ import { Spinner } from "../ui/spinner"; import { initialWizardStep, isRetryableReason, + formatSkippedDomains, outcomeToStep, refreshedSourceStep, type ImportOutcome, @@ -338,11 +339,13 @@ function ImportingStep() { function DoneStep({ imported, skipped, + skippedDomains, targetName, onClose, }: { readonly imported: number; readonly skipped: number; + readonly skippedDomains: ReadonlyArray; readonly targetName: string; readonly onClose: () => void; }) { @@ -358,6 +361,14 @@ function DoneStep({ : "There were no cookies to bring over."} + {skippedDomains.length > 0 ? ( + +

+ Skipped +

+

{formatSkippedDomains(skippedDomains)}

+
+ ) : null} } onClick={onClose}> Done diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 52e6fd17fa37..72632c48a5b0 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -658,7 +658,13 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { } else { targetName = input.target.name; } - return { kind: "imported", imported: result.imported, skipped: result.skipped, targetName }; + return { + kind: "imported", + imported: result.imported, + skipped: result.skipped, + skippedDomains: result.skippedDomains, + targetName, + }; } catch (cause) { return { kind: "blocked", reason: importFailureReason(cause) }; } diff --git a/apps/web/src/components/settings/browserImportWizard.logic.test.ts b/apps/web/src/components/settings/browserImportWizard.logic.test.ts index 9c72d7fac44b..f8a7cd461dd1 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.test.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -4,6 +4,7 @@ import type { BrowserImportSource } from "@t3tools/contracts"; import { initialWizardStep, isRetryableReason, + formatSkippedDomains, outcomeToStep, refreshedSourceStep, } from "./browserImportWizard.logic"; @@ -35,8 +36,20 @@ describe("initialWizardStep", () => { describe("outcomeToStep", () => { it("lands on done after a successful import", () => { expect( - outcomeToStep({ kind: "imported", imported: 12, skipped: 3, targetName: "Work" }), - ).toEqual({ step: "done", imported: 12, skipped: 3, targetName: "Work" }); + 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", () => { @@ -78,3 +91,18 @@ describe("isRetryableReason", () => { 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 index 6cebad2bee7a..c61e2a978a61 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.ts @@ -10,6 +10,7 @@ export type ImportOutcome = readonly kind: "imported"; readonly imported: number; readonly skipped: number; + readonly skippedDomains: ReadonlyArray; readonly targetName: string; } | { readonly kind: "blocked"; readonly reason: BrowserImportFailureReason }; @@ -26,6 +27,7 @@ export type WizardStep = readonly step: "done"; readonly imported: number; readonly skipped: number; + readonly skippedDomains: ReadonlyArray; readonly targetName: string; } | { readonly step: "blocked"; readonly reason: BrowserImportFailureReason }; @@ -48,6 +50,7 @@ export function outcomeToStep(outcome: ImportOutcome): WizardStep { step: "done", imported: outcome.imported, skipped: outcome.skipped, + skippedDomains: outcome.skippedDomains, targetName: outcome.targetName, }; } @@ -79,3 +82,14 @@ export function isRetryableReason(reason: BrowserImportFailureReason): boolean { 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/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 016a711ba48e..d7e2aa26444f 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -102,11 +102,17 @@ export const BrowserImportResult = Schema.Struct({ /** Cookies successfully written into the target partition. */ imported: Schema.Int, /** - * Cookies read but not written — expired, or rejected by Chromium as - * malformed. Surfaced rather than hidden so a mostly-failed import doesn't - * look like a success. + * 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;