40 unresolved threads Β· Jump to file
Selected thread: apps/web/src/components/settings/settingsSearch.ts:345 β€” Dropping the `browser-default-profile` entry removes the only way to reach this setting from settings search, but the setting itself still exists β€” it moved into each profile row's **Set as default**. `searchSettings` substring-matches titles only, so "default browser profile" (and "default profile") now return nothing, while the three sibling defaults β€” viewport, zoom, appearance β€” still resolve. Since `targetId ?? id` is what the jump scrolls to, an entry can point at the profiles row without owning an anchor of its own: ```suggestion }, { // No row of its own any more: "Set as default" lives in each profile's // menu, so this result lands on the profiles list. id: "browser-default-profile", title: "Default browser profile", to: "/settings/integrations", targetId: "browser-profiles", }, ``` <sup>Posted via Macroscope β€” <a href="https://github.com/pingdotgg/t3code/runs/99060721517">UI Consistency</a></sup> <!-- macroscope-meta: {"kind":"check_run","config":".macroscope/check-run-agents/ui-consistency.md","check":"UI Consistency"} -->
0 / 27 viewed27 files changedLine totals unavailable

apps/web/src/components/settings/settingsSearch.ts Β· Earlier revision 56e3f72a Β· right 227–345

Original diff
@@ -225,12 +225,6 @@ export const SETTINGS_SEARCH_ITEMS = [
     to: "/settings/integrations",
     targetId: "browser",
   },

apps/web/src/components/settings/settingsSearch.ts Β· right 227–345

M
macroscopeappBotAug 29, 2026

Dropping the browser-default-profile entry removes the only way to reach this setting from settings search, but the setting itself still exists β€” it moved into each profile row's Set as default. searchSettings substring-matches titles only, so "default browser profile" (and "default profile") now return nothing, while the three sibling defaults β€” viewport, zoom, appearance β€” still resolve.

Since targetId ?? id is what the jump scrolls to, an entry can point at the profiles row without owning an anchor of its own:

suggestion
1 },
2 {
3 // No row of its own any more: "Set as default" lives in each profile's
4 // menu, so this result lands on the profiles list.
5 id: "browser-default-profile",
6 title: "Default browser profile",
7 to: "/settings/integrations",
8 targetId: "browser-profiles",
9 },

Posted via Macroscope β€” UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 23950d7c31. The Default browser profile search entry targets the existing browser-profiles row, with search coverage.

apps/web/src/components/settings/IntegrationsSettings.tsx Β· File comment

M
macroscopeappBotAug 17, 2026

🟑 Medium

https://github.com/pingdotgg/t3code/blob/76fc9d21cb04161223f2cb6ce10ee2e146778199/apps/web/src/components/settings/IntegrationsSettings.tsx#L589

Removing a profile can leave its cookies or cache on disk while the profile entry is already gone, with no error or retry path. removeProfile closes the dialog and calls updateSettings without awaiting clearCookies/clearCache, and the per-call .catch(() => undefined) suppresses cleanup failures; await successful cleanup before updating settings, or retain the profile and expose a recoverable error when cleanup fails.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/settings/IntegrationsSettings.tsx around line 589:
2
3Removing a profile can leave its cookies or cache on disk while the profile entry is already gone, with no error or retry path. `removeProfile` closes the dialog and calls `updateSettings` without awaiting `clearCookies`/`clearCache`, and the per-call `.catch(() => undefined)` suppresses cleanup failures; await successful cleanup before updating settings, or retain the profile and expose a recoverable error when cleanup fails.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 0891a8c5 Β· right 104

Original diff
@@ -0,0 +1,352 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt primitives Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie extraction.
+ *
+ * Reads a Chromium-family browser's cookie database and decrypts it with the
+ * key the OS keychain hands us, which is the mechanism the browser itself
+ * uses. macOS mediates that with a per-app consent prompt, so the user
+ * explicitly approves T3 Code reading it.
+ *
+ * Deliberately no fallback when the keychain says no: the alternative
+ * techniques exist to defeat that consent, and this feature is not worth
+ * shipping them.
+ *
+ * @module ChromiumCookies
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
+const MAC_KEY_ITERATIONS = 1003;
+const MAC_KEY_SALT = "saltysalt";
+const MAC_KEY_LENGTH = 16;
+/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_IV = Buffer.alloc(16, 0x20);
+const V10_PREFIX = "v10";
+
+export interface ChromiumCookie {
+  readonly url: string;
+  readonly name: string;
+  readonly value: string;
+  /**
+   * Set only for domain cookies, which Chromium stores with a leading dot.
+   * A host-only cookie leaves this undefined: Electron treats any `domain` it
+   * is given as a domain cookie and re-adds the dot, which would widen the
+   * cookie to every subdomain of the host it was scoped to.
+   */
+  readonly domain: string | undefined;
+  readonly path: string;
+  readonly secure: boolean;
+  readonly httpOnly: boolean;
+  /** Seconds since the UNIX epoch, or undefined for a session cookie. */
+  readonly expirationDate: number | undefined;
+  readonly sameSite: "no_restriction" | "lax" | "strict";
+}
+
+export const ChromiumCookieReadReason = Schema.Literals([
+  "needsKeychainApproval",
+  "keychainItemMissing",
+  "browserRunning",
+  "unsupportedPlatform",
+  "readFailed",
+]);
+export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
+
+export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()(
+  "ChromiumCookieReadError",
+  {
+    reason: ChromiumCookieReadReason,
+    /**
+     * Which database the read was for. Without it every `readFailed` and
+     * keychain failure logs identically, and a user with several browsers
+     * installed has no way to tell which one refused.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+/** Row shape of the cookie table, decoded rather than cast. */
+const CookieRow = Schema.Struct({
+  host_key: Schema.String,
+  name: Schema.String,
+  encrypted_value: Schema.Uint8Array,
+  path: Schema.String,
+  expires_seconds: Schema.Number,
+  is_secure: Schema.Number,
+  is_httponly: Schema.Number,
+  samesite: Schema.Number,
+});
+
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+/**
+ * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in
+ * modern Chromium, so it maps there rather than to `no_restriction`, which
+ * would widen the cookie's scope on import.
+ */
+const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
M
macroscopeappBotAug 28, 2026

🟠 High BrowserImport/ChromiumCookies.ts:104

sameSiteFromColumn converts Chromium's stored UNSPECIFIED value (-1) to explicit `

βœ… Resolved in 779b12083c33dab8537e94273cd2b679abc690d9

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 23b22b90 Β· right 162

Original diff
@@ -0,0 +1,265 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt primitives Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie extraction.
+ *
+ * Reads a Chromium-family browser's cookie database and decrypts it with the
+ * key the OS keychain hands us, which is the mechanism the browser itself
+ * uses. macOS mediates that with a per-app consent prompt, so the user
+ * explicitly approves T3 Code reading it.
+ *
+ * Deliberately no fallback when the keychain says no: the alternative
+ * techniques exist to defeat that consent, and this feature is not worth
+ * shipping them.
+ *
+ * @module ChromiumCookies
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
+const MAC_KEY_ITERATIONS = 1003;
+const MAC_KEY_SALT = "saltysalt";
+const MAC_KEY_LENGTH = 16;
+/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_IV = Buffer.alloc(16, 0x20);
+const V10_PREFIX = "v10";
+
+export interface ChromiumCookie {
+  readonly url: string;
+  readonly name: string;
+  readonly value: string;
+  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 const ChromiumCookieReadReason = Schema.Literals([
+  "needsKeychainApproval",
+  "keychainItemMissing",
+  "browserRunning",
+  "unsupportedPlatform",
+  "readFailed",
+]);
+export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
+
+export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()(
+  "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
+ * would widen the cookie's scope on import.
+ */
+const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+/**
+ * Chromium timestamps count microseconds from 1601-01-01; Electron wants
+ * seconds from the UNIX epoch.
+ *
+ * The microsecond value overflows JavaScript's safe integer range, and
+ * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
+ * only ever sees seconds.
+ */
+const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
+const toUnixSeconds = (webkitSeconds: number): number | undefined => {
+  if (webkitSeconds <= 0) return undefined;
+  return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS;
+};
+
+/**
+ * Reads the OSCrypt key from the login keychain.
+ *
+ * Uses the in-process Keychain API rather than shelling out to
+ * `/usr/bin/security`, because the keychain attributes both the consent prompt
+ * and the resulting ACL entry to the binary that asks. Via the CLI the prompt
+ * says "security" and "Always Allow" grants trust to a tool every process on
+ * the machine can invoke; in-process it names this app and the grant belongs
+ * to it. (In an unsigned dev build the name is the dev Electron binary rather
+ * than the shipped app identity.)
+ *
+ * Deliberately untimed: macOS answers this with a modal, and a timeout racing
+ * the user means the prompt can be approved while nothing is left listening β€”
+ * which reads as "approving did nothing".
+ */
+const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
+  service: string,
+  account: string,
+) {
+  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 === "") {
+    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.
+ */
+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);
M
macroscopeappBotAug 16, 2026

🟑 Medium BrowserImport/ChromiumCookies.ts:162

importCookies can snapshot a database while Chromium is writing it, producing a torn snapshot that fails to open or silently omits recent cookies. The earlier running-browser check can become stale while readMacKeychainPassword waits for approval, and copying Cookies, Cookies-wal, and Cookies-shm separately does not form an atomic SQLite snapshot; recheck immediately before snapshotting and use SQLite's backup mechanism or otherwise prevent writes for the entire snapshot.

Also found in 1 other location(s)

apps/desktop/src/preview/BrowserImport/BrowserImport.ts:147

importCookies checks isSourceRunning before invoking readChromiumCookies, but that invocation can wait indefinitely on the macOS Keychain approval modal. If the user launches Helium while the prompt is open, the import proceeds after approval without rechecking the lock and snapshots a live cookie database, defeating the running-browser guard and potentially importing a torn or incomplete cookie set. Recheck isSourceRunning after keychain approval/immediately before snapshotting, or otherwise make the check and snapshot atomic.

No longer relevant as of 3264ff7a4a32d791ea62c6b4e3338440b6e06a78

apps/web/src/components/settings/IntegrationsSettings.tsx Β· Earlier revision 76fc9d21 Β· right 801–846

Original diff
@@ -523,69 +591,226 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
     }
     updateSettings({
       browserProfiles: userProfiles.filter((profile) => profile.id !== id),
-      // Reassign the default rather than leaving it pointing at nothing.
       ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
     });
   };
 
+  // A browser that is not on this machine is left out rather than listed as a
+  // dead row: there is nothing to act on, and the menu is a list of things you
+  // can import from. Every other unavailable reason stays, since each names a
+  // step the user can take.
+  const importableSources = (sources ?? []).filter(
+    (source) => source.unavailable !== "notInstalled",
+  );
+
+  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([]));
+  };
+
+  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) => {
+        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 },
+  ) => {
+    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 (
     <SettingsRow
       {...searchableSetting("browser-profiles")}
-      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      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={
-        <Button
-          size="sm"
-          variant="outline"
-          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
-          onClick={addProfile}
-        >
-          <PlusIcon />
-          Add profile
-        </Button>
+        <Menu onOpenChange={(open) => open && loadSources()}>
+          <MenuTrigger render={<Button size="sm" variant="outline" disabled={disabled || busy} />}>
+            <PlusIcon />
+            Add profile
+          </MenuTrigger>
+          <MenuPopup align="end" className="min-w-56">
+            <MenuItem disabled={atProfileLimit} onClick={() => createProfile("New profile")}>
+              Blank profile
+            </MenuItem>
+            <MenuSeparator />
+            <MenuGroup>
+              <MenuGroupLabel>Import from</MenuGroupLabel>
+              {sources === null ? (
+                <MenuItem disabled>Looking for browsers…</MenuItem>
+              ) : importableSources.length === 0 ? (
+                <MenuItem disabled>No supported browsers found</MenuItem>
+              ) : (
+                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".
+                        <MenuItem key={source.id} disabled>
+                          <span className="flex flex-col items-start">
+                            <span>{source.name}</span>
+                            <span className="text-xs text-muted-foreground">
+                              {BROWSER_IMPORT_UNAVAILABLE_COPY[source.unavailable]}
+                            </span>
+                          </span>
+                        </MenuItem>,
+                      ]
+                    : source.profiles.map((sourceProfile) => (
+                        <MenuSub key={`${source.id}:${sourceProfile.directory}`}>
+                          <MenuSubTrigger>
+                            {source.profiles.length > 1
+                              ? `${source.name} β€” ${sourceProfile.name}`
+                              : source.name}
+                          </MenuSubTrigger>
+                          <MenuSubPopup className="min-w-44">
+                            <MenuItem
+                              disabled={atProfileLimit}
+                              onClick={() => importInto(source, sourceProfile.directory, "new")}
+                            >
+                              New profile
+                            </MenuItem>
+                            <MenuSeparator />
+                            <MenuGroup>
+                              <MenuGroupLabel>Existing profile</MenuGroupLabel>
+                              {profiles
+                                // Incognito is discarded on quit, so importing
+                                // into it would throw the work away.
+                                .filter((profile) => profile.kind !== "incognito")
+                                .map((profile) => (
+                                  <MenuItem
+                                    key={profile.id}
+                                    onClick={() =>
+                                      importInto(source, sourceProfile.directory, {
+                                        id: profile.id,
+                                        name: profile.name,
+                                      })
+                                    }
+                                  >
+                                    {profile.name}
+                                  </MenuItem>
+                                ))}
+                            </MenuGroup>
+                          </MenuSubPopup>
+                        </MenuSub>
+                      )),
+                )
+              )}
+            </MenuGroup>
+          </MenuPopup>
+        </Menu>
       }
     >
-      <div className="mt-1 space-y-1">
-        {resolveBrowserProfiles(userProfiles).map((profile) => {
+      <div className="mt-2 overflow-hidden rounded-lg border border-border/60">
+        {listedProfiles.map((profile, index) => {
           const builtIn = isBuiltInBrowserProfileId(profile.id);
+          const isDefault = profile.id === resolvedDefaultId;
           return (
-            <div key={profile.id} className={ITEM_ROW_INNER_CLASSNAME}>
-              {builtIn ? (
-                <span className="flex min-w-0 items-center gap-2 text-sm text-foreground">
-                  {profile.name}
-                  <Badge variant="outline">
-                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
-                  </Badge>
-                </span>
-              ) : (
-                <DraftInput
-                  nativeInput
-                  size="sm"
-                  className="w-full sm:w-64"
-                  aria-label={`Rename ${profile.name}`}
-                  disabled={disabled}
-                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
-                  value={profile.name}
-                  onCommit={(next) => renameProfile(profile.id, next)}
-                />
+            <div
+              key={profile.id}
+              className={cn(
+                "flex items-center gap-3 px-3 py-2",
+                index > 0 && "border-t border-border/60",
               )}
-              {builtIn ? null : (
-                <Tooltip>
-                  <TooltipTrigger
-                    render={
-                      <Button
-                        size="icon-sm"
-                        variant="ghost-muted"
-                        disabled={disabled}
-                        aria-label={`Remove ${profile.name}`}
-                        onClick={() => setProfilePendingRemoval(profile)}
-                      >
-                        <Trash2Icon />
-                      </Button>
-                    }
+            >
+              <span className="flex min-w-0 flex-1 items-center gap-2">
+                {builtIn ? (
+                  <span className="truncate text-sm text-foreground">{profile.name}</span>
+                ) : (
+                  <DraftInput
+                    nativeInput
+                    size="sm"
+                    className="w-full max-w-56"
+                    aria-label={`Rename ${profile.name}`}
+                    disabled={disabled}
+                    maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                    value={profile.name}
+                    onCommit={(next) => renameProfile(profile.id, next)}
                   />
-                  <TooltipPopup side="top">Remove profile and its data</TooltipPopup>
-                </Tooltip>
-              )}
+                )}
+                {isDefault ? <Badge>Default</Badge> : null}
+              </span>
+              <Menu>
+                <MenuTrigger
+                  render={
+                    <Button
+                      size="icon-sm"
+                      variant="ghost-muted"
+                      disabled={disabled}
+                      aria-label={`${profile.name} options`}
+                    />
+                  }
+                >
+                  <MoreVertical />
+                </MenuTrigger>
+                <MenuPopup align="end" className="min-w-44">
+                  <MenuItem
+                    disabled={isDefault}
+                    onClick={() => updateSettings({ browserDefaultProfileId: profile.id })}
+                  >
+                    Set as default
+                  </MenuItem>
+                  <MenuItem onClick={() => clearProfileData(profile.id, profile.name)}>

apps/web/src/components/settings/IntegrationsSettings.tsx Β· right 801–846

M
macroscopeappBotAug 17, 2026

🟑 Medium settings/IntegrationsSettings.tsx:801

With no primary environmentId, β€œClear cookies and cache” remains enabled but clearProfileData returns immediately, so the menu closes without deleting anything or showing a toast. Disable this action when clearing is unavailable.

βœ… Resolved in d35f0a79a17ce3d07afd8e9b24ef0515f120a47b

apps/web/src/components/settings/IntegrationsSettings.tsx Β· Earlier revision adef2961 Β· right 674–800

Original diff
@@ -584,90 +656,227 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
     setProfilePendingRemoval(null);
   };
 
+  // A browser that is not on this machine is left out rather than listed as a
+  // dead row: there is nothing to act on, and the menu is a list of things you
+  // can import from. Every other unavailable reason stays, since each names a
+  // step the user can take.
+  const importableSources = (sources ?? []).filter(
+    (source) => source.unavailable !== "notInstalled",
+  );
+
+  // Refreshed without blanking the last result: the menu shows the cached list
+  // straight away so it doesn't reflow on open, and the source list is stable
+  // (names only) since choosing what to import happens in the wizard, not here.
+  const loadSources = () => {
+    if (!previewBridge) return;
+    void previewBridge
+      .listBrowserImportSources()
+      .then(setSources)

apps/web/src/components/settings/IntegrationsSettings.tsx Β· right 674–800

M
macroscopeappBotAug 29, 2026

The import targets whatever usePrimaryEnvironment() happens to be, but a browser profile is one Electron partition per environment (resolvePartitionScope(environmentId, profileId)), which is exactly why the two sibling actions in this component enumerate environments instead: clearProfileData and removeProfile call clearBrowserProfileData(..., environments.map(...)), with a test asserting it "clears every known environment". So with two environments connected, the cookies land in the primary one's partition only, while the wizard reports Imported N cookies β€” Added to <profile> with no mention of which environment; a preview tab opened under that profile in the other environment is still signed out, and the settings surface never names the environment it picked.

Consider either fanning the import out the way the clear path does (one importBrowserCookies per known environment id, reporting the aggregate once), or β€” if a single target is intentional β€” naming the environment in the configure and done copy so the result isn't presented as profile-wide.

No diff: the fix spans the IPC call site and the wizard's result copy.

Posted via Macroscope β€” UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 6dd58889ef and 52c95d7d2f. Import intentionally targets one environment: the wizard captures its id and label when opened, uses that id throughout retries, and names the environment in configuration and results. Copy says for the environment because storage remains desktop-local; cookies are not fanned out to other environments.

apps/web/src/components/settings/IntegrationsSettings.tsx Β· Earlier revision 779b1208 Β· right 821–821

Original diff
@@ -622,8 +818,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
           <AlertDialogHeader>
             <AlertDialogTitle>Remove β€œ{profilePendingRemoval?.name}”?</AlertDialogTitle>
             <AlertDialogDescription>
-              Its cookies, logins, and cache are deleted with it. Tabs open in this profile move to
-              the default one.
+              Its cookies and logins are deleted. Any open tabs switch to your default profile.

apps/web/src/components/settings/IntegrationsSettings.tsx Β· right 821–821

M
macroscopeappBotAug 29, 2026

The new wording promises something the app doesn't do: a tab's profile is fixed at attach (HostedBrowserWebview documents this, and usePreviewWebviewConfig derives the partition from snapshot.profileId), so a tab already open in the removed profile keeps running in that partition β€” it doesn't move to the default. What the user sees instead is the profile badge in PreviewChromeRow disappearing (because activeProfile no longer resolves), which makes the tab look like it switched while its clear actions still target the removed profile's partition.

Consider describing what actually happens.

suggestion
1 Its cookies and logins are deleted. Tabs already open in it stay open, signed out.

Posted via Macroscope β€” UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 8560cddf38 and preserved in 7ee3c4f0c5. Removal copy no longer claims open tabs switch profiles. Tabs keep their original partition and show Removed profile when metadata is gone.

apps/web/src/components/settings/IntegrationsSettings.tsx Β· Earlier revision ea2efcb5 Β· right 657

Original diff
@@ -523,69 +591,218 @@ 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;
+    // 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([]));
+  };
+
+  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) => {
+        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 },
+  ) => {
+    if (target === "new") {
+      const created = createProfile(source.name);
+      runImport(source, sourceProfileDirectory, created.id, created.name);
+      return;
+    }
+    runImport(source, sourceProfileDirectory, target.id, target.name);
C
cursorBotAug 16, 2026

Import creates profile then no-ops

Medium Severity

importInto with target "new" always calls createProfile before runImport. runImport returns silently when environmentId or previewBridge is missingβ€”no toast. The user gets an empty profile and no import feedback whenever the primary environment is not ready yet.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit ea2efcb52ead0f58de45258f4961a1f7ffcfaa40. Configure here.

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision 757f0258 Β· right 122

Original diff
@@ -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>()(
+  "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<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = async (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+): Promise<BrowserImportUnavailableReason | undefined> => {
+  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<ReadonlyArray<BrowserImportSource>> => {
+    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<ChromiumCookie> = yield* Effect.tryPromise({
+      try: () =>
+        readChromiumCookies({
+          cookieDatabasePath: cookieDatabasePath(definition, input.input.sourceProfileDirectory),
M
macroscopeappBotAug 16, 2026

🟠 High BrowserImport/BrowserImport.ts:122

importCookies passes the IPC-controlled sourceProfileDirectory directly to cookieDatabasePath, so .. segments let a forged request escape the browser user-data directory and read/import cookies from another reachable Cookies database. Validate the directory against listSourceProfiles(definition) and enforce resolved-path containment before calling readChromiumCookies.

Also found in 1 other location(s)

apps/desktop/src/ipc/methods/preview.ts:295

importBrowserCookies forwards the renderer-controlled sourceProfileDirectory without checking it against the profiles returned for the selected source. The existing callee builds the database path with NodePath.join(userDataDirectory, sourceProfileDirectory, &#34;Cookies&#34;), so a value containing ../ can escape the browser data directory and make the privileged main process open another SQLite cookie database. A compromised renderer can therefore make this new IPC endpoint read/import cookies from unintended filesystem locations; validate that the directory is one of listSourceProfiles(definition) (and remains beneath userDataDirectory) before calling browserImport.importCookies.

No longer relevant as of 58530950a4e4ec0a1743ac0ee2826ceba41af020

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 0891a8c5 Β· right 119

Original diff
@@ -0,0 +1,352 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt primitives Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie extraction.
+ *
+ * Reads a Chromium-family browser's cookie database and decrypts it with the
+ * key the OS keychain hands us, which is the mechanism the browser itself
+ * uses. macOS mediates that with a per-app consent prompt, so the user
+ * explicitly approves T3 Code reading it.
+ *
+ * Deliberately no fallback when the keychain says no: the alternative
+ * techniques exist to defeat that consent, and this feature is not worth
+ * shipping them.
+ *
+ * @module ChromiumCookies
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
+const MAC_KEY_ITERATIONS = 1003;
+const MAC_KEY_SALT = "saltysalt";
+const MAC_KEY_LENGTH = 16;
+/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_IV = Buffer.alloc(16, 0x20);
+const V10_PREFIX = "v10";
+
+export interface ChromiumCookie {
+  readonly url: string;
+  readonly name: string;
+  readonly value: string;
+  /**
+   * Set only for domain cookies, which Chromium stores with a leading dot.
+   * A host-only cookie leaves this undefined: Electron treats any `domain` it
+   * is given as a domain cookie and re-adds the dot, which would widen the
+   * cookie to every subdomain of the host it was scoped to.
+   */
+  readonly domain: string | undefined;
+  readonly path: string;
+  readonly secure: boolean;
+  readonly httpOnly: boolean;
+  /** Seconds since the UNIX epoch, or undefined for a session cookie. */
+  readonly expirationDate: number | undefined;
+  readonly sameSite: "no_restriction" | "lax" | "strict";
+}
+
+export const ChromiumCookieReadReason = Schema.Literals([
+  "needsKeychainApproval",
+  "keychainItemMissing",
+  "browserRunning",
+  "unsupportedPlatform",
+  "readFailed",
+]);
+export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
+
+export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()(
+  "ChromiumCookieReadError",
+  {
+    reason: ChromiumCookieReadReason,
+    /**
+     * Which database the read was for. Without it every `readFailed` and
+     * keychain failure logs identically, and a user with several browsers
+     * installed has no way to tell which one refused.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+/** Row shape of the cookie table, decoded rather than cast. */
+const CookieRow = Schema.Struct({
+  host_key: Schema.String,
+  name: Schema.String,
+  encrypted_value: Schema.Uint8Array,
+  path: Schema.String,
+  expires_seconds: Schema.Number,
+  is_secure: Schema.Number,
+  is_httponly: Schema.Number,
+  samesite: Schema.Number,
+});
+
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+/**
+ * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in
+ * modern Chromium, so it maps there rather than to `no_restriction`, which
+ * would widen the cookie's scope on import.
+ */
+const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+/**
+ * Chromium timestamps count microseconds from 1601-01-01; Electron wants
+ * seconds from the UNIX epoch.
+ *
+ * The microsecond value overflows JavaScript's safe integer range, and
+ * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
+ * only ever sees seconds.
+ */
+const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
+const toUnixSeconds = (webkitSeconds: number): number | undefined => {
+  if (webkitSeconds <= 0) return undefined;
+  return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS;
+};
C
cursorBotAug 28, 2026

Expired cookies overwrite target sessions

Medium Severity

Already-expired source cookies are converted to a past expirationDate and written with cookies.set. Chromium treats that as a delete (expired-overwrite), so importing into an existing profile can remove matching live cookies. Those rows also increment imported even though they do not persist, which disagrees with BrowserImportResult treating expired cookies as skipped.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 0891a8c5ccb60adac2864c3450fe14934eeb5319. Configure here.

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision f047f210 Β· right 74

Original diff
@@ -0,0 +1,207 @@
+/**
+ * Browser import service - lists importable sources and writes their cookies
+ * into a T3 Code browser profile's Electron partition.
+ *
+ * @module BrowserImport
+ */
+import type {
+  BrowserImportInput,
+  BrowserImportResult,
+  BrowserImportSource,
+  BrowserImportUnavailableReason,
+} from "@t3tools/contracts";
+import { BrowserImportFailureReason } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+import * as BrowserSession from "../BrowserSession.ts";
+import { readChromiumCookies } from "./ChromiumCookies.ts";
+import {
+  BROWSER_IMPORT_SOURCES,
+  cookieDatabasePath,
+  isSourceInstalled,
+  isSourceRunning,
+  listSourceProfiles,
+  sourcePaths,
+  type BrowserImportSourceDefinition,
+  type SourcePaths,
+} from "./Sources.ts";
+
+export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
+  "BrowserImportFailedError",
+  {
+    sourceId: Schema.String,
+    reason: BrowserImportFailureReason,
+    /** Kept for the log; the user only ever sees the reason's copy. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  // The reason token is part of the message on purpose: IPC flattens the error
+  // to its message, and the renderer maps that token back to user-facing copy.
+  override get message(): string {
+    return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
+  }
+}
+
+export class BrowserImport extends Context.Service<
+  BrowserImport,
+  {
+    readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+  paths: SourcePaths,
+): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
+  if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
+  if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
+  if (yield* isSourceRunning(definition, paths)) return "browserRunning";
+  return undefined;
+});
C
cursorBotAug 17, 2026

Unsupported sources clutter import menu

Low Severity

unavailableReason returns unsupportedPlatform before install detection, and the UI only hides notInstalled. On Windows/Linux, mac-only Helium therefore always appears as a disabled β€œunsupported platform” row even though it can never be imported thereβ€”the same β€œnothing to act on” case the notInstalled filter was written to remove.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit f047f210923aa3919adca37ec6a348555a485679. Configure here.

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision f047f210 Β· right 159

Original diff
@@ -0,0 +1,207 @@
+/**
+ * Browser import service - lists importable sources and writes their cookies
+ * into a T3 Code browser profile's Electron partition.
+ *
+ * @module BrowserImport
+ */
+import type {
+  BrowserImportInput,
+  BrowserImportResult,
+  BrowserImportSource,
+  BrowserImportUnavailableReason,
+} from "@t3tools/contracts";
+import { BrowserImportFailureReason } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+import * as BrowserSession from "../BrowserSession.ts";
+import { readChromiumCookies } from "./ChromiumCookies.ts";
+import {
+  BROWSER_IMPORT_SOURCES,
+  cookieDatabasePath,
+  isSourceInstalled,
+  isSourceRunning,
+  listSourceProfiles,
+  sourcePaths,
+  type BrowserImportSourceDefinition,
+  type SourcePaths,
+} from "./Sources.ts";
+
+export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
+  "BrowserImportFailedError",
+  {
+    sourceId: Schema.String,
+    reason: BrowserImportFailureReason,
+    /** Kept for the log; the user only ever sees the reason's copy. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  // The reason token is part of the message on purpose: IPC flattens the error
+  // to its message, and the renderer maps that token back to user-facing copy.
+  override get message(): string {
+    return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
+  }
+}
+
+export class BrowserImport extends Context.Service<
+  BrowserImport,
+  {
+    readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+  paths: SourcePaths,
+): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
+  if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
+  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;
+  // 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<FileSystem.FileSystem | Path.Path>();
+  const paths = yield* sourcePaths;
+
+  const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
+    BROWSER_IMPORT_SOURCES,
+    Effect.fnUntraced(function* (definition) {
+      const unavailable = yield* unavailableReason(definition, platform, paths);
+      return {
+        id: definition.id,
+        name: definition.name,
+        // Listing profiles touches the source's own files, so skip it when the
+        // source is unusable anyway.
+        profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
+        ...(unavailable === undefined ? {} : { unavailable }),
+      } satisfies BrowserImportSource;
+    }),
+  ).pipe(Effect.provide(platformServices));
+
+  const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: {
+    readonly input: BrowserImportInput;
+    readonly scope: string;
+    readonly persistent: boolean;
+  }) {
+    const definition = BROWSER_IMPORT_SOURCES.find(
+      (candidate) => candidate.id === input.input.sourceId,
+    );
+    if (!definition) {
+      return yield* new BrowserImportFailedError({
+        sourceId: input.input.sourceId,
+        reason: "unknownSource",
+      });
+    }
+
+    const blocked = yield* unavailableReason(definition, platform, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    if (blocked !== undefined) {
+      return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
+    }
+
+    // macOS attributes the Keychain prompt and the resulting ACL grant to the
+    // executable that asks, so record which one that was β€” in a packaged build
+    // it is the signed app, in dev whatever binary hosts the main process.
+    yield* Effect.logInfo("Reading browser cookie key from the keychain", {
+      sourceId: definition.id,
+      executablePath,
+    });
+
+    // The profile directory arrives over IPC, so it is only honoured when the
+    // source itself reported it. Forwarding it unchecked would let `..`
+    // segments walk out of the browser's user-data directory and read any
+    // cookie database reachable on disk.
+    const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    const requestedProfile = sourceProfiles.find(
+      (profile) => profile.directory === input.input.sourceProfileDirectory,
+    );
+    if (requestedProfile === undefined) {
+      return yield* new BrowserImportFailedError({
+        sourceId: definition.id,
+        reason: "unknownSourceProfile",
+      });
+    }
+
+    const 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 }),
+      ),
+    );
C
cursorBotAug 17, 2026

Stale running check after Keychain

Medium Severity

browserRunning is checked only before the Keychain read, and that read is deliberately untimed. The source browser can start while the modal is up, then snapshotCookieDatabase runs against a live WAL-backed DBβ€”the case SingletonLock was meant to blockβ€”producing torn or incomplete cookie imports without a clear failure.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit f047f210923aa3919adca37ec6a348555a485679. Configure here.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 779b1208 Β· right 225–225

Original diff
@@ -0,0 +1,352 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt primitives Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie extraction.
+ *
+ * Reads a Chromium-family browser's cookie database and decrypts it with the
+ * key the OS keychain hands us, which is the mechanism the browser itself
+ * uses. macOS mediates that with a per-app consent prompt, so the user
+ * explicitly approves T3 Code reading it.
+ *
+ * Deliberately no fallback when the keychain says no: the alternative
+ * techniques exist to defeat that consent, and this feature is not worth
+ * shipping them.
+ *
+ * @module ChromiumCookies
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
+const MAC_KEY_ITERATIONS = 1003;
+const MAC_KEY_SALT = "saltysalt";
+const MAC_KEY_LENGTH = 16;
+/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_IV = Buffer.alloc(16, 0x20);
+const V10_PREFIX = "v10";
+
+export interface ChromiumCookie {
+  readonly url: string;
+  readonly name: string;
+  readonly value: string;
+  /**
+   * Set only for domain cookies, which Chromium stores with a leading dot.
+   * A host-only cookie leaves this undefined: Electron treats any `domain` it
+   * is given as a domain cookie and re-adds the dot, which would widen the
+   * cookie to every subdomain of the host it was scoped to.
+   */
+  readonly domain: string | undefined;
+  readonly path: string;
+  readonly secure: boolean;
+  readonly httpOnly: boolean;
+  /** Seconds since the UNIX epoch, or undefined for a session cookie. */
+  readonly expirationDate: number | undefined;
+  readonly sameSite: "no_restriction" | "lax" | "strict";
+}
+
+export const ChromiumCookieReadReason = Schema.Literals([
+  "needsKeychainApproval",
+  "keychainItemMissing",
+  "browserRunning",
+  "unsupportedPlatform",
+  "readFailed",
+]);
+export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
+
+export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()(
+  "ChromiumCookieReadError",
+  {
+    reason: ChromiumCookieReadReason,
+    /**
+     * Which database the read was for. Without it every `readFailed` and
+     * keychain failure logs identically, and a user with several browsers
+     * installed has no way to tell which one refused.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+/** Row shape of the cookie table, decoded rather than cast. */
+const CookieRow = Schema.Struct({
+  host_key: Schema.String,
+  name: Schema.String,
+  encrypted_value: Schema.Uint8Array,
+  path: Schema.String,
+  expires_seconds: Schema.Number,
+  is_secure: Schema.Number,
+  is_httponly: Schema.Number,
+  samesite: Schema.Number,
+});
+
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+/**
+ * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in
+ * modern Chromium, so it maps there rather than to `no_restriction`, which
+ * would widen the cookie's scope on import.
+ */
+const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+/**
+ * Chromium timestamps count microseconds from 1601-01-01; Electron wants
+ * seconds from the UNIX epoch.
+ *
+ * The microsecond value overflows JavaScript's safe integer range, and
+ * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
+ * only ever sees seconds.
+ */
+const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
+const toUnixSeconds = (webkitSeconds: number): number | undefined => {
+  if (webkitSeconds <= 0) return undefined;
+  return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS;
+};
+
+/**
+ * Reads the OSCrypt key from the login keychain.
+ *
+ * Uses the in-process Keychain API rather than shelling out to
+ * `/usr/bin/security`, because the keychain attributes both the consent prompt
+ * and the resulting ACL entry to the binary that asks. Via the CLI the prompt
+ * says "security" and "Always Allow" grants trust to a tool every process on
+ * the machine can invoke; in-process it names this app and the grant belongs
+ * to it. (In an unsigned dev build the name is the dev Electron binary rather
+ * than the shipped app identity.)
+ *
+ * Deliberately untimed: macOS answers this with a modal, and a timeout racing
+ * the user means the prompt can be approved while nothing is left listening β€”
+ * which reads as "approving did nothing".
+ */
+const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
+  service: string,
+  account: string,
+  cookieDatabasePath: string,
+) {
+  const password = yield* Effect.try({
+    try: () => new Keyring.Entry(service, account).getPassword(),
+    catch: (cause) => {
+      const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
+      // Distinguish the causes rather than telling the user to approve a
+      // prompt when approving cannot fix the failure.
+      const missing = /no (matching )?entry|not found/i.test(message);
+      return new ChromiumCookieReadError({
+        reason: missing ? "keychainItemMissing" : "needsKeychainApproval",
+        cookieDatabasePath,
+        cause,
+      });
+    },
+  });
+  if (password === null || password === "") {
+    return yield* new ChromiumCookieReadError({
+      reason: "keychainItemMissing",
+      cookieDatabasePath,
+    });
+  }
+  return password;
+});
+
+/**
+ * Chromium keeps the cookie DB open with WAL, and reading it in place can
+ * observe a torn state. Copying first β€” including the sidecars β€” gives a
+ * consistent snapshot without touching the browser's own files.
+ *
+ * Scoped: the temp directory is removed when the caller's scope closes.
+ */
+const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(function* (
+  cookiePath: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const path = yield* Path.Path;
+
+  const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-cookie-import-" });
+  const target = path.join(directory, "Cookies");
+  yield* fileSystem.copyFile(cookiePath, target);
+  // A sidecar only exists while the browser holds the database open, so an
+  // absent one is normal. Anything else β€” a permission error, a partial read β€”
+  // is not: SQLite would then open the snapshot without the write-ahead log
+  // and quietly return a cookie set missing its most recent transactions.
+  yield* Effect.forEach(["-wal", "-shm"], (suffix) =>
+    fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(
+      Effect.catchIf(
+        (error) => error.reason._tag === "NotFound",
+        () => Effect.void,
+      ),
+    ),
+  );
+  return target;
+});
+
+/**
+ * The URL and domain Electron should register a stored row under.
+ *
+ * Chromium marks a domain cookie with a leading dot on `host_key`. Electron
+ * matches on a URL, so the dot comes off for that; `domain` is passed through
+ * only for domain cookies, because supplying it at all makes Electron treat
+ * the cookie as one and re-add the dot β€” widening a host-only cookie to every
+ * subdomain of the host it was scoped to, and rejecting `__Host-` cookies,
+ * which require it to be absent.
+ */
+export const cookieScope = (
+  hostKey: string,
+  path: string,
+  secure: boolean,
+): { readonly url: string; readonly domain: string | undefined } => {
+  const isDomainCookie = hostKey.startsWith(".");
+  const host = isDomainCookie ? hostKey.slice(1) : hostKey;
+  return {
+    url: `${secure ? "https" : "http"}://${host}${path}`,
+    ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }),
+  };
+};
+
+/** The host without Chromium's domain-cookie leading dot, for display. */
+const bareHost = (hostKey: string): string =>
+  hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;
+
+const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => {
+  const buffer = Buffer.from(encrypted);
+  if (buffer.length === 0) return "";
+  if (buffer.subarray(0, 3).toString("latin1") !== V10_PREFIX) return null;

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· right 225–225

M
macroscopeappBotAug 29, 2026

🟑 Medium BrowserImport/ChromiumCookies.ts:225

Legacy clear-text values in encrypted_value are returned as null, so readChromiumCookies counts those rows as undecryptable and drops otherwise valid cookies. Chromium treats values without the v10 prefix as legacy clear text; return buffer.toString("utf8") for that branch instead.

suggestion
1 if (buffer.subarray(0, 3).toString("latin1") !== V10_PREFIX) return buffer.toString("utf8");
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts around line 225:
2
3Legacy clear-text values in `encrypted_value` are returned as `null`, so `readChromiumCookies` counts those rows as undecryptable and drops otherwise valid cookies. Chromium treats values without the `v10` prefix as legacy clear text; return `buffer.toString("utf8")` for that branch instead.
J
Julius MarmingeAug 29, 2026

Fixed in 9bad21232a. Unversioned encrypted_value blobs use Chromium’s legacy plaintext behavior on macOS only. Linux unknown schemes remain unsupported. Fixtures cover both paths.

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision 779b1208 Β· right 207–210

Original diff
@@ -0,0 +1,228 @@
+/**
+ * Browser import service - lists importable sources and writes their cookies
+ * into a T3 Code browser profile's Electron partition.
+ *
+ * @module BrowserImport
+ */
+import type {
+  BrowserImportInput,
+  BrowserImportResult,
+  BrowserImportSource,
+  BrowserImportUnavailableReason,
+} from "@t3tools/contracts";
+import { BrowserImportFailureReason } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+import * as BrowserSession from "../BrowserSession.ts";
+import { readChromiumCookies } from "./ChromiumCookies.ts";
+import {
+  BROWSER_IMPORT_SOURCES,
+  cookieDatabasePath,
+  isSourceInstalled,
+  isSourceRunning,
+  listSourceProfiles,
+  sourcePaths,
+  type BrowserImportSourceDefinition,
+  type SourcePaths,
+} from "./Sources.ts";
+
+export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
+  "BrowserImportFailedError",
+  {
+    sourceId: Schema.String,
+    reason: BrowserImportFailureReason,
+    /** Kept for the log; the user only ever sees the reason's copy. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  // The reason token is part of the message on purpose: IPC flattens the error
+  // to its message, and the renderer maps that token back to user-facing copy.
+  override get message(): string {
+    return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
+  }
+}
+
+export class BrowserImport extends Context.Service<
+  BrowserImport,
+  {
+    readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+  paths: SourcePaths,
+): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
+  if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
+  if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
+  if (yield* isSourceRunning(definition, paths)) return "browserRunning";
+  return undefined;
+});
+
+/** The host a constructed cookie URL points at, for naming what was skipped. */
+const cookieHost = (url: string): string => {
+  try {
+    return new URL(url).hostname;
+  } catch {
+    return url;
+  }
+};
+
+export const make = Effect.gen(function* BrowserImportMake() {
+  const browserSession = yield* BrowserSession.BrowserSession;
+  const platform = yield* HostProcessPlatform;
+  const executablePath = yield* HostProcessExecutablePath;
+  // Captured here so the service's methods stay free of a requirements
+  // channel: the layer is built where NodeServices is already in scope.
+  const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
+  const paths = yield* sourcePaths;
+
+  const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
+    BROWSER_IMPORT_SOURCES,
+    Effect.fnUntraced(function* (definition) {
+      const unavailable = yield* unavailableReason(definition, platform, paths);
+      return {
+        id: definition.id,
+        name: definition.name,
+        // Listing profiles touches the source's own files, so skip it when the
+        // source is unusable anyway.
+        profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
+        ...(unavailable === undefined ? {} : { unavailable }),
+      } satisfies BrowserImportSource;
+    }),
+  ).pipe(Effect.provide(platformServices));
+
+  const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: {
+    readonly input: BrowserImportInput;
+    readonly scope: string;
+    readonly persistent: boolean;
+  }) {
+    const definition = BROWSER_IMPORT_SOURCES.find(
+      (candidate) => candidate.id === input.input.sourceId,
+    );
+    if (!definition) {
+      return yield* new BrowserImportFailedError({
+        sourceId: input.input.sourceId,
+        reason: "unknownSource",
+      });
+    }
+
+    const blocked = yield* unavailableReason(definition, platform, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    if (blocked !== undefined) {
+      return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
+    }
+
+    // macOS attributes the Keychain prompt and the resulting ACL grant to the
+    // executable that asks, so record which one that was β€” in a packaged build
+    // it is the signed app, in dev whatever binary hosts the main process.
+    yield* Effect.logInfo("Reading browser cookie key from the keychain", {
+      sourceId: definition.id,
+      executablePath,
+    });
+
+    // The profile directory arrives over IPC, so it is only honoured when the
+    // source itself reported it. Forwarding it unchecked would let `..`
+    // segments walk out of the browser's user-data directory and read any
+    // cookie database reachable on disk.
+    const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    const requestedProfile = sourceProfiles.find(
+      (profile) => profile.directory === input.input.sourceProfileDirectory,
+    );
+    if (requestedProfile === undefined) {
+      return yield* new BrowserImportFailedError({
+        sourceId: definition.id,
+        reason: "unknownSourceProfile",
+      });
+    }
+
+    const read = yield* readChromiumCookies({
+      cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory),
+      keychainService: definition.keychainService,
+      keychainAccount: definition.keychainAccount,
+      platform,
+    }).pipe(
+      Effect.scoped,
+      Effect.provide(platformServices),
+      Effect.mapError(
+        (cause) =>
+          new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
+      ),
+    );
+
+    const session = yield* browserSession.getSession(input.scope, input.persistent).pipe(
+      Effect.mapError(
+        (cause) =>
+          new BrowserImportFailedError({
+            sourceId: definition.id,
+            reason: "sessionUnavailable",
+            cause,
+          }),
+      ),
+    );
+
+    // Written one at a time rather than in parallel: Chromium's cookie store
+    // serialises writes anyway, and a rejected cookie should only cost itself.
+    let imported = 0;
+    // Rows the reader could not decrypt are already lost cookies, so they
+    // count as skipped rather than vanishing from the tally.
+    let skipped = read.undecryptable;
+    const skippedDomains = new Set(read.undecryptableHosts);
+    for (const cookie of read.cookies) {
+      const written = yield* Effect.tryPromise({
+        try: () =>
+          session.cookies.set({
+            url: cookie.url,
+            name: cookie.name,
+            value: cookie.value,
+            // Omitted for host-only cookies: Electron reads any `domain` as a
+            // domain cookie and re-adds the leading dot, which would widen the
+            // scope of every host-only cookie the source had.
+            ...(cookie.domain === undefined ? {} : { domain: cookie.domain }),
+            path: cookie.path,
+            secure: cookie.secure,
+            httpOnly: cookie.httpOnly,
+            sameSite: cookie.sameSite,
+            ...(cookie.expirationDate === undefined
+              ? {}
+              : { expirationDate: cookie.expirationDate }),
+          }),
+        catch: () => undefined,
+      }).pipe(
+        Effect.as(true),
+        Effect.catchCause(() => Effect.succeed(false)),

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· right 207–210

M
macroscopeappBotAug 29, 2026

catch: () => undefined throws the Electron rejection away (no cause, no log), and Effect.catchCause then also converts defects and interruption into written = false, so an interrupted import silently keeps looping over the remaining cookies instead of stopping. Consider keeping the rejection as the typed error, logging it, and handling only the error channel with Effect.catch.

suggestion
1 catch: (cause) => cause,
2 }).pipe(
3 Effect.as(true),
4 Effect.tapError((cause) =>
5 Effect.logDebug("Skipped a cookie the target session rejected", { cause }),
6 ),
7 Effect.catch(() => Effect.succeed(false)),

Posted via Macroscope β€” Effect Service Conventions

J
Julius MarmingeAug 29, 2026

Fixed in 461ee21f37. Only typed cookie-write rejections are counted as skipped; interruption and defects are not swallowed. The interruption test proves the write loop stops.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 40385efa Β· right 35

Original diff
@@ -0,0 +1,340 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt primitives Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie extraction.
+ *
+ * Reads a Chromium-family browser's cookie database and decrypts it with the
+ * key the OS keychain hands us, which is the mechanism the browser itself
+ * uses. macOS mediates that with a per-app consent prompt, so the user
+ * explicitly approves T3 Code reading it.
+ *
+ * Deliberately no fallback when the keychain says no: the alternative
+ * techniques exist to defeat that consent, and this feature is not worth
+ * shipping them.
+ *
+ * @module ChromiumCookies
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
+const MAC_KEY_ITERATIONS = 1003;
+const MAC_KEY_SALT = "saltysalt";
+const MAC_KEY_LENGTH = 16;
+/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_IV = Buffer.alloc(16, 0x20);
+const V10_PREFIX = "v10";
+
+export interface ChromiumCookie {
M
macroscopeappBotAug 17, 2026

🟠 High BrowserImport/ChromiumCookies.ts:35

Partitioned Chromium cookies are imported as ordinary unpartitioned cookies, so a CHIPS cookie becomes available outside its original top-level-site partition and may send a session identifier in unrelated browsing contexts. ChromiumCookie carries no partition-key metadata, and the import path therefore cannot pass it to Electron; preserve and write the partition key, or skip and count partitioned rows.

No longer relevant as of 05fe2c1c07af19afd480b84df6fc54f11a5ef45a

apps/desktop/src/preview/BrowserImport/Sources.ts Β· Earlier revision adef2961 Β· right 207

Original diff
@@ -0,0 +1,234 @@
+/**
+ * Importable browser sources.
+ *
+ * Each entry pins its own on-disk and keychain coordinates rather than
+ * deriving them: Chromium forks do not agree on the convention. Helium, for
+ * instance, uses the keychain service "Helium Storage Key" / account "Helium"
+ * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ *
+ * @module BrowserImportSources
+ */
+import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/**
+ * Where a source's files live, resolved once per call rather than read from
+ * the ambient process so the registry stays testable.
+ */
+export interface SourcePaths {
+  readonly path: Path.Path;
+  readonly home: string;
+}
+
+export const sourcePaths = Effect.gen(function* () {
+  const path = yield* Path.Path;
+  const environment = yield* HostProcessEnvironment;
+  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
+});
+
+export interface BrowserImportSourceDefinition {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
+  /** Platforms the definition's paths are valid for. */
+  readonly platforms: ReadonlyArray<NodeJS.Platform>;
+  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly keychainService: string;
+  readonly keychainAccount: string;
+}
+
+export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
+  {
+    id: "helium",
+    name: "Helium",
+    platforms: ["darwin"],
+    userDataDirectory: ({ path, home }) =>
+      path.join(home, "Library", "Application Support", "net.imput.helium"),
+    keychainService: "Helium Storage Key",
+    keychainAccount: "Helium",
+  },
+];
+
+export const cookieDatabasePath = (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  profileDirectory: string,
+): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies");
+
+/** Shape of the slice of Chromium's `Local State` that names its profiles. */
+const LocalState = Schema.Struct({
+  profile: Schema.optional(
+    Schema.Struct({
+      info_cache: Schema.optional(
+        Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
+      ),
+    }),
+  ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/** A single plain path segment: no separators, no `.`/`..`, not empty. */
+const isSafeProfileDirectory = (directory: string): boolean =>
+  directory.length > 0 &&
+  directory !== "." &&
+  directory !== ".." &&
+  !/[\\/]/.test(directory) &&
+  !directory.includes("\u0000");
+
+const CookieCountRow = Schema.Struct({ count: Schema.Number });
+const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));
+
+/**
+ * How many cookies a profile holds, counted without decrypting anything β€” a
+ * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium
+ * database (Firefox's table is named differently, Safari's is not SQL) yields
+ * `undefined` rather than failing the listing.
+ */
+const countProfileCookies = Effect.fnUntraced(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  directory: string,
+): Effect.fn.Return<number | undefined, never> {
+  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<BrowserImportSourceProfile>,
+) =>
+  Effect.forEach(profiles, (profile) =>
+    countProfileCookies(definition, paths, profile.directory).pipe(
+      Effect.map((cookieCount) =>
+        cookieCount === undefined ? profile : { ...profile, cookieCount },
+      ),
+    ),
+  );
+
+/**
+ * Profiles the source browser knows about, read from its `Local State`.
+ *
+ * When that file is missing, unreadable or malformed, the user-data directory
+ * is scanned for directories that hold a cookie database. Assuming `Default`
+ * instead would report a browser whose cookies live in `Profile 1` as having
+ * nothing to import β€” and it is then left out of the menu entirely.
+ */
+export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+
+  const root = definition.userDataDirectory(paths);
+  const declared = yield* fileSystem.readFileString(localStatePath).pipe(
+    Effect.flatMap(decodeLocalState),
+    Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
+    // The keys are directory names from the browser's own metadata file, which
+    // is writable by anything running as the user. Anything but a single plain
+    // segment is dropped: `..` or a path separator would otherwise be handed
+    // to `cookieDatabasePath` and read a database outside the user-data
+    // directory.
+    Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))),
+    Effect.map((entries) =>
+      entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })),
+    ),
+    Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+  );
+  if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared);
+
+  // `Local State` is missing, unreadable or malformed. Scanning for
+  // directories that hold a cookie database finds the profiles anyway;
+  // assuming `Default` would report a browser whose cookies live in
+  // `Profile 1` as having nothing to import, and it is then hidden entirely.
+  const entries = yield* fileSystem
+    .readDirectory(root)
+    .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
+  const candidates = entries.filter(isSafeProfileDirectory);
+  const found = yield* Effect.forEach(candidates, (directory) =>
+    entryExists(cookieDatabasePath(definition, paths, directory)).pipe(
+      Effect.map((exists) => (exists ? { directory, name: directory } : undefined)),
+    ),
+  );
+  return yield* withCookieCounts(
+    definition,
+    paths,
+    found.filter((profile) => profile !== undefined),
+  );
+});
+
+/**
+ * Whether a directory entry exists, without following it or opening it.
+ *
+ * `stat` resolves symlinks and the locks below deliberately dangle, so
+ * `readLink` is the probe that answers for the entry itself.
+ */
+const entryExists = Effect.fnUntraced(function* (path: string) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.stat(path).pipe(
+    Effect.catch(() => fileSystem.readLink(path)),
+    Effect.as(true),
+    Effect.orElseSucceed(() => false),
+  );
+});
+
+/** Whether the browser is running, which leaves its cookie DB mid-write. */
+export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+) {
+  const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock");
+  // Chromium writes a `SingletonLock` symlink for as long as an instance holds
+  // the profile. Its presence is a far cheaper and more targeted signal than
+  // scanning the process table for a name.
+  //
+  // The link points at `<host>-<pid>`, a target that never exists, and both
+  // `stat` and `exists` follow links β€” so they report every running browser as
+  // closed, which would let an import read a live, mid-write database.
+  // `readLink` is the one probe that answers for the entry itself.
+  return yield* entryExists(lock);
M
macroscopeappBotAug 29, 2026

🟠 High BrowserImport/Sources.ts:207

A stale SingletonLock symlink makes isSourceRunning return true, so after a crash or force-quit every import is rejected as browserRunning even though no browser is using the database. The lock target contains the host/PID but is never checked; validate that process is still alive (or remove stale locks) before reporting the source as running.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/Sources.ts around line 207:
2
3A stale `SingletonLock` symlink makes `isSourceRunning` return `true`, so after a crash or force-quit every import is rejected as `browserRunning` even though no browser is using the database. The lock target contains the host/PID but is never checked; validate that process is still alive (or remove stale locks) before reporting the source as running.
J
Julius MarmingeAug 29, 2026

Fixed in a47eec8048 and 0524c56a20. Chromium host-PID locks are ignored only when a same-host PID is positively absent (ESRCH from signal 0). Live, denied, foreign-host, malformed, and unknown cases remain conservative. No lock is removed and no actual process signal is sent. Focused liveness/lock tests pass.

apps/desktop/src/preview/BrowserImport/Sources.ts Β· Earlier revision 779b1208 Β· right 187–187

Original diff
@@ -0,0 +1,234 @@
+/**
+ * Importable browser sources.
+ *
+ * Each entry pins its own on-disk and keychain coordinates rather than
+ * deriving them: Chromium forks do not agree on the convention. Helium, for
+ * instance, uses the keychain service "Helium Storage Key" / account "Helium"
+ * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ *
+ * @module BrowserImportSources
+ */
+import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/**
+ * Where a source's files live, resolved once per call rather than read from
+ * the ambient process so the registry stays testable.
+ */
+export interface SourcePaths {
+  readonly path: Path.Path;
+  readonly home: string;
+}
+
+export const sourcePaths = Effect.gen(function* () {
+  const path = yield* Path.Path;
+  const environment = yield* HostProcessEnvironment;
+  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
+});
+
+export interface BrowserImportSourceDefinition {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
+  /** Platforms the definition's paths are valid for. */
+  readonly platforms: ReadonlyArray<NodeJS.Platform>;
+  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly keychainService: string;
+  readonly keychainAccount: string;
+}
+
+export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
+  {
+    id: "helium",
+    name: "Helium",
+    platforms: ["darwin"],
+    userDataDirectory: ({ path, home }) =>
+      path.join(home, "Library", "Application Support", "net.imput.helium"),
+    keychainService: "Helium Storage Key",
+    keychainAccount: "Helium",
+  },
+];
+
+export const cookieDatabasePath = (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  profileDirectory: string,
+): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies");
+
+/** Shape of the slice of Chromium's `Local State` that names its profiles. */
+const LocalState = Schema.Struct({
+  profile: Schema.optional(
+    Schema.Struct({
+      info_cache: Schema.optional(
+        Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
+      ),
+    }),
+  ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/** A single plain path segment: no separators, no `.`/`..`, not empty. */
+const isSafeProfileDirectory = (directory: string): boolean =>
+  directory.length > 0 &&
+  directory !== "." &&
+  directory !== ".." &&
+  !/[\\/]/.test(directory) &&
+  !directory.includes("\u0000");
+
+const CookieCountRow = Schema.Struct({ count: Schema.Number });
+const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));
+
+/**
+ * How many cookies a profile holds, counted without decrypting anything β€” a
+ * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium
+ * database (Firefox's table is named differently, Safari's is not SQL) yields
+ * `undefined` rather than failing the listing.
+ */
+const countProfileCookies = Effect.fnUntraced(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  directory: string,
+): Effect.fn.Return<number | undefined, never> {
+  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<BrowserImportSourceProfile>,
+) =>
+  Effect.forEach(profiles, (profile) =>
+    countProfileCookies(definition, paths, profile.directory).pipe(
+      Effect.map((cookieCount) =>
+        cookieCount === undefined ? profile : { ...profile, cookieCount },
+      ),
+    ),
+  );
+
+/**
+ * Profiles the source browser knows about, read from its `Local State`.
+ *
+ * When that file is missing, unreadable or malformed, the user-data directory
+ * is scanned for directories that hold a cookie database. Assuming `Default`
+ * instead would report a browser whose cookies live in `Profile 1` as having
+ * nothing to import β€” and it is then left out of the menu entirely.
+ */
+export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+
+  const root = definition.userDataDirectory(paths);
+  const declared = yield* fileSystem.readFileString(localStatePath).pipe(
+    Effect.flatMap(decodeLocalState),
+    Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
+    // The keys are directory names from the browser's own metadata file, which
+    // is writable by anything running as the user. Anything but a single plain
+    // segment is dropped: `..` or a path separator would otherwise be handed
+    // to `cookieDatabasePath` and read a database outside the user-data
+    // directory.
+    Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))),
+    Effect.map((entries) =>
+      entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })),
+    ),
+    Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+  );
+  if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared);
+
+  // `Local State` is missing, unreadable or malformed. Scanning for
+  // directories that hold a cookie database finds the profiles anyway;
+  // assuming `Default` would report a browser whose cookies live in
+  // `Profile 1` as having nothing to import, and it is then hidden entirely.
+  const entries = yield* fileSystem
+    .readDirectory(root)
+    .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
+  const candidates = entries.filter(isSafeProfileDirectory);
+  const found = yield* Effect.forEach(candidates, (directory) =>
+    entryExists(cookieDatabasePath(definition, paths, directory)).pipe(
+      Effect.map((exists) => (exists ? { directory, name: directory } : undefined)),
+    ),
+  );
+  return yield* withCookieCounts(
+    definition,
+    paths,
+    found.filter((profile) => profile !== undefined),
+  );
+});
+
+/**
+ * Whether a directory entry exists, without following it or opening it.
+ *
+ * `stat` resolves symlinks and the locks below deliberately dangle, so
+ * `readLink` is the probe that answers for the entry itself.
+ */
+const entryExists = Effect.fnUntraced(function* (path: string) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  return yield* fileSystem.stat(path).pipe(
+    Effect.catchCause(() => fileSystem.readLink(path)),

apps/desktop/src/preview/BrowserImport/Sources.ts Β· right 187–187

M
macroscopeappBotAug 29, 2026

stat fails with a typed PlatformError, so catching the full cause here is wider than intended β€” it also turns a defect or an interrupt into a readLink attempt. Effect.catch handles the whole (statically known) error channel without touching defects or interruption.

suggestion
1 Effect.catch(() => fileSystem.readLink(path)),

Posted via Macroscope β€” Effect Service Conventions

apps/web/src/components/settings/BrowserImportWizard.tsx Β· Earlier revision 09ce84d5 Β· right 356–361

Original diff
@@ -0,0 +1,406 @@
+import type { BrowserImportSource } from "@t3tools/contracts";
+import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
+import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { cn, randomUUID } from "~/lib/utils";
+
+import { Button } from "../ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogPanel,
+  DialogPopup,
+  DialogTitle,
+} from "../ui/dialog";
+import { Spinner } from "../ui/spinner";
+import {
+  initialWizardStep,
+  isRetryableReason,
+  formatSkippedDomains,
+  outcomeToStep,
+  refreshedSourceStep,
+  type ImportOutcome,
+  type WizardStep,
+} from "./browserImportWizard.logic";
+
+/** A profile the import can land in. */
+export interface WizardTargetProfile {
+  readonly id: string;
+  readonly name: string;
+}
+
+/** The target the user picked: a brand-new profile, or an existing one. */
+export type WizardTarget =
+  | { readonly kind: "new"; readonly profileId: string }
+  | { readonly kind: "existing"; readonly profileId: string; readonly name: string };
+
+const NEW_TARGET_VALUE = "new";
+
+interface BrowserImportWizardProps {
+  readonly source: BrowserImportSource;
+  /** Existing profiles the import can go into. Incognito is excluded upstream. */
+  readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
+  /** 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<ImportOutcome>;
+  /** Re-checks the source's availability after the user quits the browser. */
+  readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
+  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<WizardStep>(() => initialWizardStep(initialSource));
+  const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
+    () => initialSource.profiles[0]?.directory ?? "",
+  );
+  const [target, setTarget] = useState<string>(
+    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 (
+    <Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
+      <DialogPopup className="max-w-lg">
+        {step.step === "quit" ? (
+          <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
+        ) : step.step === "importing" ? (
+          <ImportingStep />
+        ) : step.step === "done" ? (
+          <DoneStep {...step} onClose={onClose} />
+        ) : step.step === "blocked" ? (
+          <BlockedStep
+            source={source}
+            reason={step.reason}
+            onClose={onClose}
+            onRetry={isRetryableReason(step.reason) ? runImport : undefined}
+          />
+        ) : (
+          <ConfigureStep
+            source={source}
+            targetProfiles={targetProfiles}
+            canCreateProfile={canCreateProfile}
+            sourceProfileDirectory={sourceProfileDirectory}
+            onSourceProfileChange={setSourceProfileDirectory}
+            target={target}
+            onTargetChange={setTarget}
+            onCancel={onClose}
+            onImport={runImport}
+          />
+        )}
+      </DialogPopup>
+    </Dialog>
+  );
+}
+
+function QuitStep({
+  source,
+  onCancel,
+  onRechecked,
+}: {
+  readonly source: BrowserImportSource;
+  readonly onCancel: () => void;
+  readonly onRechecked: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Quit {source.name} to import</DialogTitle>
+        <DialogDescription>
+          {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue.
+        </DialogDescription>
+      </DialogHeader>
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel}>
+          Cancel
+        </Button>
+        <Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
+      </DialogFooter>
+    </>
+  );
+}
+
+/** "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<WizardTargetProfile>;
+  readonly canCreateProfile: boolean;
+  readonly sourceProfileDirectory: string;
+  readonly onSourceProfileChange: (directory: string) => void;
+  readonly target: string;
+  readonly onTargetChange: (target: string) => void;
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+};
+
+// TEMP: an in-dialog layout switcher for comparing directions live β€” the ui.sh
+// picker can't load under the app's CSP. Collapse to the chosen variant and
+// delete this switcher before merge.
+function ConfigureStep({
+  source,
+  targetProfiles,
+  canCreateProfile,
+  sourceProfileDirectory,
+  onSourceProfileChange,
+  target,
+  onTargetChange,
+  onCancel,
+  onImport,
+}: ConfigureStepProps) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Import from {source.name}</DialogTitle>
+        <DialogDescription>Cookies flow from the browser into a profile here.</DialogDescription>
+      </DialogHeader>
+      <DialogPanel>
+        {/* Side by side when the dialog has room, stacked when it doesn't. */}
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              From
+            </p>
+            {source.profiles.map((profile) => (
+              <SelectableTile
+                key={profile.directory}
+                selected={sourceProfileDirectory === profile.directory}
+                title={profile.name}
+                subtitle={cookieCountLabel(profile.cookieCount)}
+                onSelect={() => onSourceProfileChange(profile.directory)}
+              />
+            ))}
+          </section>
+          <div className="flex shrink-0 items-center justify-center text-muted-foreground">
+            <ArrowDownIcon className="size-4 sm:hidden" />
+            <ArrowRightIcon className="hidden size-4 sm:block" />
+          </div>
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              Into
+            </p>
+            {canCreateProfile ? (
+              <SelectableTile
+                selected={target === NEW_TARGET_VALUE}
+                title="New profile"
+                subtitle="Created for these cookies"
+                onSelect={() => onTargetChange(NEW_TARGET_VALUE)}
+              />
+            ) : null}
+            {targetProfiles.map((profile) => (
+              <SelectableTile
+                key={profile.id}
+                selected={target === profile.id}
+                title={profile.name}
+                subtitle="Existing profile"
+                onSelect={() => onTargetChange(profile.id)}
+              />
+            ))}
+          </section>
+        </div>
+      </DialogPanel>
+      <ConfigureFooter onCancel={onCancel} onImport={onImport} />
+    </>
+  );
+}
+
+/** Shared footer so the step keeps one set of actions. */
+function ConfigureFooter({
+  onCancel,
+  onImport,
+}: {
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+}) {
+  return (
+    <DialogFooter>
+      <Button variant="outline" onClick={onCancel}>
+        Cancel
+      </Button>
+      <Button onClick={onImport}>Import</Button>
+    </DialogFooter>
+  );
+}
+
+/** 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 (
+    <button
+      type="button"
+      onClick={onSelect}
+      className={cn(
+        "flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
+        selected
+          ? "border-primary bg-primary/8"
+          : "border-border/60 hover:border-border hover:bg-muted/40",
+      )}
+    >
+      <span className="min-w-0">
+        <span className="block truncate text-sm font-medium text-foreground">{title}</span>
+        {subtitle ? (
+          <span className="block truncate text-xs tabular-nums text-muted-foreground">
+            {subtitle}
+          </span>
+        ) : null}
+      </span>
+      <span
+        className={cn(
+          "grid size-4 shrink-0 place-items-center rounded-full border",
+          selected ? "border-primary bg-primary text-primary-foreground" : "border-input",
+        )}
+      >
+        {selected ? <CheckIcon className="size-2.5" /> : null}
+      </span>
+    </button>
+  );
+}
+
+function ImportingStep() {
+  return (
+    <DialogPanel className="flex items-center gap-3 py-6">
+      <Spinner className="size-4 text-muted-foreground" />
+      <span className="text-sm text-muted-foreground">Importing cookies…</span>
+    </DialogPanel>
+  );
+}
+
+function DoneStep({
+  imported,
+  skipped,
+  skippedDomains,
+  targetName,
+  onClose,
+}: {
+  readonly imported: number;
+  readonly skipped: number;
+  readonly skippedDomains: ReadonlyArray<string>;
+  readonly targetName: string;
+  readonly onClose: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>
+          {imported > 0 ? `Imported ${imported} cookies` : "Nothing to import"}
+        </DialogTitle>
+        <DialogDescription>
+          {imported > 0
+            ? `Into ${targetName}.${skipped > 0 ? ` ${skipped} couldn't be brought over.` : ""}`
+            : "There were no cookies to bring over."}

apps/web/src/components/settings/BrowserImportWizard.tsx Β· right 356–361

M
macroscopeappBotAug 28, 2026

🟑 Medium settings/BrowserImportWizard.tsx:356

When imported is 0 and skipped is positive, DoneStep renders β€œNothing to import” and says there were no cookies, omitting the failed skipped count. The title and description should distinguish an all-skipped import from an empty cookie store.

diff
1- {imported > 0 ? `Imported ${imported} cookies` : "Nothing to import"}
2+ {imported > 0
3+ ? `Imported ${imported} cookies`
4+ : skipped > 0
5+ ? "Couldn't import cookies"
6+ : "Nothing to import"}
7 </DialogTitle>
8 <DialogDescription>
9- {imported > 0
10- ? `Into ${targetName}.${skipped > 0 ? ` ${skipped} couldn't be brought over.` : ""}`
11- : "There were no cookies to bring over."}
12+ {imported > 0
13+ ? `Into ${targetName}.${skipped > 0 ? ` ${skipped} couldn't be brought over.` : ""}`
14+ : skipped > 0
15+ ? `${skipped} cookies couldn't be brought over.`
16+ : "There were no cookies to bring over."}
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/settings/BrowserImportWizard.tsx around lines 356-361:
2
3When `imported` is `0` and `skipped` is positive, `DoneStep` renders β€œNothing to import” and says there were no cookies, omitting the failed `skipped` count. The title and description should distinguish an all-skipped import from an empty cookie store.
J
Julius MarmingeAug 29, 2026

Fixed in 37cdc249ab. An all-skipped result is distinguished from a source with no cookies; it reports failure to import and the skipped count/sites.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 09ce84d5 Β· right 224

Original diff
@@ -0,0 +1,352 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt primitives Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie extraction.
+ *
+ * Reads a Chromium-family browser's cookie database and decrypts it with the
+ * key the OS keychain hands us, which is the mechanism the browser itself
+ * uses. macOS mediates that with a per-app consent prompt, so the user
+ * explicitly approves T3 Code reading it.
+ *
+ * Deliberately no fallback when the keychain says no: the alternative
+ * techniques exist to defeat that consent, and this feature is not worth
+ * shipping them.
+ *
+ * @module ChromiumCookies
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
+const MAC_KEY_ITERATIONS = 1003;
+const MAC_KEY_SALT = "saltysalt";
+const MAC_KEY_LENGTH = 16;
+/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_IV = Buffer.alloc(16, 0x20);
+const V10_PREFIX = "v10";
+
+export interface ChromiumCookie {
+  readonly url: string;
+  readonly name: string;
+  readonly value: string;
+  /**
+   * Set only for domain cookies, which Chromium stores with a leading dot.
+   * A host-only cookie leaves this undefined: Electron treats any `domain` it
+   * is given as a domain cookie and re-adds the dot, which would widen the
+   * cookie to every subdomain of the host it was scoped to.
+   */
+  readonly domain: string | undefined;
+  readonly path: string;
+  readonly secure: boolean;
+  readonly httpOnly: boolean;
+  /** Seconds since the UNIX epoch, or undefined for a session cookie. */
+  readonly expirationDate: number | undefined;
+  readonly sameSite: "no_restriction" | "lax" | "strict";
+}
+
+export const ChromiumCookieReadReason = Schema.Literals([
+  "needsKeychainApproval",
+  "keychainItemMissing",
+  "browserRunning",
+  "unsupportedPlatform",
+  "readFailed",
+]);
+export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
+
+export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()(
+  "ChromiumCookieReadError",
+  {
+    reason: ChromiumCookieReadReason,
+    /**
+     * Which database the read was for. Without it every `readFailed` and
+     * keychain failure logs identically, and a user with several browsers
+     * installed has no way to tell which one refused.
+     */
+    cookieDatabasePath: Schema.String,
+    /** Kept for the log; never surfaced to the user. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
+  }
+}
+
+/** Row shape of the cookie table, decoded rather than cast. */
+const CookieRow = Schema.Struct({
+  host_key: Schema.String,
+  name: Schema.String,
+  encrypted_value: Schema.Uint8Array,
+  path: Schema.String,
+  expires_seconds: Schema.Number,
+  is_secure: Schema.Number,
+  is_httponly: Schema.Number,
+  samesite: Schema.Number,
+});
+
+const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
+
+/**
+ * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in
+ * modern Chromium, so it maps there rather than to `no_restriction`, which
+ * would widen the cookie's scope on import.
+ */
+const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+/**
+ * Chromium timestamps count microseconds from 1601-01-01; Electron wants
+ * seconds from the UNIX epoch.
+ *
+ * The microsecond value overflows JavaScript's safe integer range, and
+ * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
+ * only ever sees seconds.
+ */
+const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
+const toUnixSeconds = (webkitSeconds: number): number | undefined => {
+  if (webkitSeconds <= 0) return undefined;
+  return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS;
+};
+
+/**
+ * Reads the OSCrypt key from the login keychain.
+ *
+ * Uses the in-process Keychain API rather than shelling out to
+ * `/usr/bin/security`, because the keychain attributes both the consent prompt
+ * and the resulting ACL entry to the binary that asks. Via the CLI the prompt
+ * says "security" and "Always Allow" grants trust to a tool every process on
+ * the machine can invoke; in-process it names this app and the grant belongs
+ * to it. (In an unsigned dev build the name is the dev Electron binary rather
+ * than the shipped app identity.)
+ *
+ * Deliberately untimed: macOS answers this with a modal, and a timeout racing
+ * the user means the prompt can be approved while nothing is left listening β€”
+ * which reads as "approving did nothing".
+ */
+const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
+  service: string,
+  account: string,
+  cookieDatabasePath: string,
+) {
+  const password = yield* Effect.try({
+    try: () => new Keyring.Entry(service, account).getPassword(),
+    catch: (cause) => {
+      const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
+      // Distinguish the causes rather than telling the user to approve a
+      // prompt when approving cannot fix the failure.
+      const missing = /no (matching )?entry|not found/i.test(message);
+      return new ChromiumCookieReadError({
+        reason: missing ? "keychainItemMissing" : "needsKeychainApproval",
+        cookieDatabasePath,
+        cause,
+      });
+    },
+  });
+  if (password === null || password === "") {
+    return yield* new ChromiumCookieReadError({
+      reason: "keychainItemMissing",
+      cookieDatabasePath,
+    });
+  }
+  return password;
+});
+
+/**
+ * Chromium keeps the cookie DB open with WAL, and reading it in place can
+ * observe a torn state. Copying first β€” including the sidecars β€” gives a
+ * consistent snapshot without touching the browser's own files.
+ *
+ * Scoped: the temp directory is removed when the caller's scope closes.
+ */
+const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(function* (
+  cookiePath: string,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const path = yield* Path.Path;
+
+  const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-cookie-import-" });
+  const target = path.join(directory, "Cookies");
+  yield* fileSystem.copyFile(cookiePath, target);
+  // A sidecar only exists while the browser holds the database open, so an
+  // absent one is normal. Anything else β€” a permission error, a partial read β€”
+  // is not: SQLite would then open the snapshot without the write-ahead log
+  // and quietly return a cookie set missing its most recent transactions.
+  yield* Effect.forEach(["-wal", "-shm"], (suffix) =>
+    fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(
+      Effect.catchIf(
+        (error) => error.reason._tag === "NotFound",
+        () => Effect.void,
+      ),
+    ),
+  );
+  return target;
+});
+
+/**
+ * The URL and domain Electron should register a stored row under.
+ *
+ * Chromium marks a domain cookie with a leading dot on `host_key`. Electron
+ * matches on a URL, so the dot comes off for that; `domain` is passed through
+ * only for domain cookies, because supplying it at all makes Electron treat
+ * the cookie as one and re-add the dot β€” widening a host-only cookie to every
+ * subdomain of the host it was scoped to, and rejecting `__Host-` cookies,
+ * which require it to be absent.
+ */
+export const cookieScope = (
+  hostKey: string,
+  path: string,
+  secure: boolean,
+): { readonly url: string; readonly domain: string | undefined } => {
+  const isDomainCookie = hostKey.startsWith(".");
+  const host = isDomainCookie ? hostKey.slice(1) : hostKey;
+  return {
+    url: `${secure ? "https" : "http"}://${host}${path}`,
+    ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }),
+  };
+};
+
+/** The host without Chromium's domain-cookie leading dot, for display. */
+const bareHost = (hostKey: string): string =>
+  hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;
+
+const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => {
+  const buffer = Buffer.from(encrypted);
+  if (buffer.length === 0) return "";
M
macroscopeappBotAug 28, 2026

🟠 High BrowserImport/ChromiumCookies.ts:224

Cookies with an empty encrypted_value are imported with value: "" instead of their stored plaintext value. Chromium keeps plaintext cookies in the value column when encryption is unavailable, but this query neither selects nor retains that column; select value and use it when encrypted_value is empty.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts around line 224:
2
3Cookies with an empty `encrypted_value` are imported with `value: ""` instead of their stored plaintext value. Chromium keeps plaintext cookies in the `value` column when encryption is unavailable, but this query neither selects nor retains that column; select `value` and use it when `encrypted_value` is empty.

apps/web/src/components/settings/BrowserImportWizard.tsx Β· Earlier revision 09ce84d5 Β· right 41

Original diff
@@ -0,0 +1,406 @@
+import type { BrowserImportSource } from "@t3tools/contracts";
+import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
+import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { cn, randomUUID } from "~/lib/utils";
+
+import { Button } from "../ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogPanel,
+  DialogPopup,
+  DialogTitle,
+} from "../ui/dialog";
+import { Spinner } from "../ui/spinner";
+import {
+  initialWizardStep,
+  isRetryableReason,
+  formatSkippedDomains,
+  outcomeToStep,
+  refreshedSourceStep,
+  type ImportOutcome,
+  type WizardStep,
+} from "./browserImportWizard.logic";
+
+/** A profile the import can land in. */
+export interface WizardTargetProfile {
+  readonly id: string;
+  readonly name: string;
+}
+
+/** The target the user picked: a brand-new profile, or an existing one. */
+export type WizardTarget =
+  | { readonly kind: "new"; readonly profileId: string }
+  | { readonly kind: "existing"; readonly profileId: string; readonly name: string };
+
+const NEW_TARGET_VALUE = "new";
M
macroscopeappBotAug 28, 2026

🟑 Medium settings/BrowserImportWizard.tsx:41

A persisted profile whose id is "new" is displayed as an existing target but runImport treats the selection as kind: "new", so importing after selecting that profile updates a generated profile instead and can create one even at the profile cap. Use a sentinel outside the valid BrowserProfileId space or model the new/existing choice separately.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/settings/BrowserImportWizard.tsx around line 41:
2
3A persisted profile whose id is `"new"` is displayed as an existing target but `runImport` treats the selection as `kind: "new"`, so importing after selecting that profile updates a generated profile instead and can create one even at the profile cap. Use a sentinel outside the valid `BrowserProfileId` space or model the new/existing choice separately.
J
Julius MarmingeAug 29, 2026

Fixed in 5483e8d898. New versus existing targets are tagged variants; a real profile whose id is new is no longer mistaken for the create-profile action. Regression coverage includes that id.

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision 09ce84d5 Β· right 222–222

Original diff
@@ -0,0 +1,228 @@
+/**
+ * Browser import service - lists importable sources and writes their cookies
+ * into a T3 Code browser profile's Electron partition.
+ *
+ * @module BrowserImport
+ */
+import type {
+  BrowserImportInput,
+  BrowserImportResult,
+  BrowserImportSource,
+  BrowserImportUnavailableReason,
+} from "@t3tools/contracts";
+import { BrowserImportFailureReason } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+import * as BrowserSession from "../BrowserSession.ts";
+import { readChromiumCookies } from "./ChromiumCookies.ts";
+import {
+  BROWSER_IMPORT_SOURCES,
+  cookieDatabasePath,
+  isSourceInstalled,
+  isSourceRunning,
+  listSourceProfiles,
+  sourcePaths,
+  type BrowserImportSourceDefinition,
+  type SourcePaths,
+} from "./Sources.ts";
+
+export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
+  "BrowserImportFailedError",
+  {
+    sourceId: Schema.String,
+    reason: BrowserImportFailureReason,
+    /** Kept for the log; the user only ever sees the reason's copy. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  // The reason token is part of the message on purpose: IPC flattens the error
+  // to its message, and the renderer maps that token back to user-facing copy.
+  override get message(): string {
+    return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
+  }
+}
+
+export class BrowserImport extends Context.Service<
+  BrowserImport,
+  {
+    readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+  paths: SourcePaths,
+): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
+  if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
+  if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
+  if (yield* isSourceRunning(definition, paths)) return "browserRunning";
+  return undefined;
+});
+
+/** The host a constructed cookie URL points at, for naming what was skipped. */
+const cookieHost = (url: string): string => {
+  try {
+    return new URL(url).hostname;
+  } catch {
+    return url;
+  }
+};
+
+export const make = Effect.gen(function* BrowserImportMake() {
+  const browserSession = yield* BrowserSession.BrowserSession;
+  const platform = yield* HostProcessPlatform;
+  const executablePath = yield* HostProcessExecutablePath;
+  // Captured here so the service's methods stay free of a requirements
+  // channel: the layer is built where NodeServices is already in scope.
+  const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
+  const paths = yield* sourcePaths;
+
+  const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
+    BROWSER_IMPORT_SOURCES,
+    Effect.fnUntraced(function* (definition) {
+      const unavailable = yield* unavailableReason(definition, platform, paths);
+      return {
+        id: definition.id,
+        name: definition.name,
+        // Listing profiles touches the source's own files, so skip it when the
+        // source is unusable anyway.
+        profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
+        ...(unavailable === undefined ? {} : { unavailable }),
+      } satisfies BrowserImportSource;
+    }),
+  ).pipe(Effect.provide(platformServices));
+
+  const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: {
+    readonly input: BrowserImportInput;
+    readonly scope: string;
+    readonly persistent: boolean;
+  }) {
+    const definition = BROWSER_IMPORT_SOURCES.find(
+      (candidate) => candidate.id === input.input.sourceId,
+    );
+    if (!definition) {
+      return yield* new BrowserImportFailedError({
+        sourceId: input.input.sourceId,
+        reason: "unknownSource",
+      });
+    }
+
+    const blocked = yield* unavailableReason(definition, platform, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    if (blocked !== undefined) {
+      return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
+    }
+
+    // macOS attributes the Keychain prompt and the resulting ACL grant to the
+    // executable that asks, so record which one that was β€” in a packaged build
+    // it is the signed app, in dev whatever binary hosts the main process.
+    yield* Effect.logInfo("Reading browser cookie key from the keychain", {
+      sourceId: definition.id,
+      executablePath,
+    });
+
+    // The profile directory arrives over IPC, so it is only honoured when the
+    // source itself reported it. Forwarding it unchecked would let `..`
+    // segments walk out of the browser's user-data directory and read any
+    // cookie database reachable on disk.
+    const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    const requestedProfile = sourceProfiles.find(
+      (profile) => profile.directory === input.input.sourceProfileDirectory,
+    );
+    if (requestedProfile === undefined) {
+      return yield* new BrowserImportFailedError({
+        sourceId: definition.id,
+        reason: "unknownSourceProfile",
+      });
+    }
+
+    const read = yield* readChromiumCookies({
+      cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory),
+      keychainService: definition.keychainService,
+      keychainAccount: definition.keychainAccount,
+      platform,
+    }).pipe(
+      Effect.scoped,
+      Effect.provide(platformServices),
+      Effect.mapError(
+        (cause) =>
+          new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
+      ),
+    );
+
+    const session = yield* browserSession.getSession(input.scope, input.persistent).pipe(
+      Effect.mapError(
+        (cause) =>
+          new BrowserImportFailedError({
+            sourceId: definition.id,
+            reason: "sessionUnavailable",
+            cause,
+          }),
+      ),
+    );
+
+    // Written one at a time rather than in parallel: Chromium's cookie store
+    // serialises writes anyway, and a rejected cookie should only cost itself.
+    let imported = 0;
+    // Rows the reader could not decrypt are already lost cookies, so they
+    // count as skipped rather than vanishing from the tally.
+    let skipped = read.undecryptable;
+    const skippedDomains = new Set(read.undecryptableHosts);
+    for (const cookie of read.cookies) {
+      const written = yield* Effect.tryPromise({
+        try: () =>
+          session.cookies.set({
+            url: cookie.url,
+            name: cookie.name,
+            value: cookie.value,
+            // Omitted for host-only cookies: Electron reads any `domain` as a
+            // domain cookie and re-adds the leading dot, which would widen the
+            // scope of every host-only cookie the source had.
+            ...(cookie.domain === undefined ? {} : { domain: cookie.domain }),
+            path: cookie.path,
+            secure: cookie.secure,
+            httpOnly: cookie.httpOnly,
+            sameSite: cookie.sameSite,
+            ...(cookie.expirationDate === undefined
+              ? {}
+              : { expirationDate: cookie.expirationDate }),
+          }),
+        catch: () => undefined,
+      }).pipe(
+        Effect.as(true),
+        Effect.catchCause(() => Effect.succeed(false)),
+      );
+      if (written) {
+        imported += 1;
+      } else {
+        skipped += 1;
+        skippedDomains.add(cookieHost(cookie.url));
+      }
+    }
+
+    // Capped: a broken key can skip thousands, and the user only needs a sense
+    // of which sites didn't come over, not an exhaustive list.
+    return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) };

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· right 222–222

M
macroscopeappBotAug 28, 2026

🟑 Medium BrowserImport/BrowserImport.ts:222

The import reports success before persistent cookies are flushed, so an import of fewer than 512 cookies can be lost on an app crash or forced termination despite cookies.set having succeeded. Call and await session.cookies.flushStore() before returning the result, and propagate a flush failure.

No longer relevant as of a7d72de122b9cf679eeed4ac8fada652fc3455fd

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision adef2961 Β· right 127–127

Original diff
@@ -0,0 +1,246 @@
+/**
+ * Browser import service - lists importable sources and writes their cookies
+ * into a T3 Code browser profile's Electron partition.
+ *
+ * @module BrowserImport
+ */
+import type {
+  BrowserImportInput,
+  BrowserImportResult,
+  BrowserImportSource,
+  BrowserImportUnavailableReason,
+} from "@t3tools/contracts";
+import { BrowserImportFailureReason } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import type { Session } from "electron";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+import * as BrowserSession from "../BrowserSession.ts";
+import { readChromiumCookies, type CookieReadResult } 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>()(
+  "BrowserImportFailedError",
+  {
+    sourceId: Schema.String,
+    reason: BrowserImportFailureReason,
+    /** Kept for the log; the user only ever sees the reason's copy. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  // The reason token is part of the message on purpose: IPC flattens the error
+  // to its message, and the renderer maps that token back to user-facing copy.
+  override get message(): string {
+    return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
+  }
+}
+
+export class BrowserCookieWriteError extends Schema.TaggedErrorClass<BrowserCookieWriteError>()(
+  "BrowserCookieWriteError",
+  {
+    url: Schema.String,
+    name: Schema.String,
+    cause: Schema.Defect(),
+  },
+) {
+  override get message(): string {
+    return `Could not write imported cookie ${this.name} for ${this.url}.`;
+  }
+}
+
+export class BrowserImport extends Context.Service<
+  BrowserImport,
+  {
+    readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
+    readonly importCookies: (input: {
+      readonly input: BrowserImportInput;
+      /** Partition scope of the target profile, derived by the caller in main. */
+      readonly scope: string;
+      readonly persistent: boolean;
+      readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace;
+    }) => Effect.Effect<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+  paths: SourcePaths,
+): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
+  if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
+  if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
+  if (yield* isSourceRunning(definition, paths)) return "browserRunning";
+  return undefined;
+});
+
+/** The host a constructed cookie URL points at, for naming what was skipped. */
+const cookieHost = (url: string): string => {
+  try {
+    return new URL(url).hostname;
+  } catch {
+    return url;
+  }
+};
+
+export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* (
+  session: { readonly cookies: Pick<Session["cookies"], "set"> },
+  read: CookieReadResult,
+) {
+  let imported = 0;
+  let skipped = read.undecryptable;
+  const skippedDomains = new Set(read.undecryptableHosts);
+  for (const cookie of read.cookies) {
+    const written = yield* Effect.tryPromise({
+      try: () =>
+        session.cookies.set({
+          url: cookie.url,
+          name: cookie.name,
+          value: cookie.value,
+          // Omitted for host-only cookies: Electron reads any `domain` as a
+          // domain cookie and re-adds the leading dot, widening its scope.
+          ...(cookie.domain === undefined ? {} : { domain: cookie.domain }),
+          path: cookie.path,
+          secure: cookie.secure,
+          httpOnly: cookie.httpOnly,
+          sameSite: cookie.sameSite,
+          ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }),
+        }),
+      catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }),
+    }).pipe(
+      Effect.as(true),
+      Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })),
+      Effect.catchTag("BrowserCookieWriteError", () => Effect.succeed(false)),

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· right 127–127

M
macroscopeappBotAug 29, 2026

Effect.catchTag isn't the convention here β€” statically known tagged failures should be recovered with Effect.catchTags({ ... }), including when only one tag is handled.

suggestion
1 Effect.catchTags({ BrowserCookieWriteError: () => Effect.succeed(false) }),

Posted via Macroscope β€” Effect Service Conventions

apps/web/src/components/settings/BrowserImportWizard.tsx Β· Earlier revision 09ce84d5 Β· right 356–356

Original diff
@@ -0,0 +1,406 @@
+import type { BrowserImportSource } from "@t3tools/contracts";
+import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
+import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { cn, randomUUID } from "~/lib/utils";
+
+import { Button } from "../ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogPanel,
+  DialogPopup,
+  DialogTitle,
+} from "../ui/dialog";
+import { Spinner } from "../ui/spinner";
+import {
+  initialWizardStep,
+  isRetryableReason,
+  formatSkippedDomains,
+  outcomeToStep,
+  refreshedSourceStep,
+  type ImportOutcome,
+  type WizardStep,
+} from "./browserImportWizard.logic";
+
+/** A profile the import can land in. */
+export interface WizardTargetProfile {
+  readonly id: string;
+  readonly name: string;
+}
+
+/** The target the user picked: a brand-new profile, or an existing one. */
+export type WizardTarget =
+  | { readonly kind: "new"; readonly profileId: string }
+  | { readonly kind: "existing"; readonly profileId: string; readonly name: string };
+
+const NEW_TARGET_VALUE = "new";
+
+interface BrowserImportWizardProps {
+  readonly source: BrowserImportSource;
+  /** Existing profiles the import can go into. Incognito is excluded upstream. */
+  readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
+  /** 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<ImportOutcome>;
+  /** Re-checks the source's availability after the user quits the browser. */
+  readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
+  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<WizardStep>(() => initialWizardStep(initialSource));
+  const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
+    () => initialSource.profiles[0]?.directory ?? "",
+  );
+  const [target, setTarget] = useState<string>(
+    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 (
+    <Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
+      <DialogPopup className="max-w-lg">
+        {step.step === "quit" ? (
+          <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
+        ) : step.step === "importing" ? (
+          <ImportingStep />
+        ) : step.step === "done" ? (
+          <DoneStep {...step} onClose={onClose} />
+        ) : step.step === "blocked" ? (
+          <BlockedStep
+            source={source}
+            reason={step.reason}
+            onClose={onClose}
+            onRetry={isRetryableReason(step.reason) ? runImport : undefined}
+          />
+        ) : (
+          <ConfigureStep
+            source={source}
+            targetProfiles={targetProfiles}
+            canCreateProfile={canCreateProfile}
+            sourceProfileDirectory={sourceProfileDirectory}
+            onSourceProfileChange={setSourceProfileDirectory}
+            target={target}
+            onTargetChange={setTarget}
+            onCancel={onClose}
+            onImport={runImport}
+          />
+        )}
+      </DialogPopup>
+    </Dialog>
+  );
+}
+
+function QuitStep({
+  source,
+  onCancel,
+  onRechecked,
+}: {
+  readonly source: BrowserImportSource;
+  readonly onCancel: () => void;
+  readonly onRechecked: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Quit {source.name} to import</DialogTitle>
+        <DialogDescription>
+          {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue.
+        </DialogDescription>
+      </DialogHeader>
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel}>
+          Cancel
+        </Button>
+        <Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
+      </DialogFooter>
+    </>
+  );
+}
+
+/** "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<WizardTargetProfile>;
+  readonly canCreateProfile: boolean;
+  readonly sourceProfileDirectory: string;
+  readonly onSourceProfileChange: (directory: string) => void;
+  readonly target: string;
+  readonly onTargetChange: (target: string) => void;
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+};
+
+// TEMP: an in-dialog layout switcher for comparing directions live β€” the ui.sh
+// picker can't load under the app's CSP. Collapse to the chosen variant and
+// delete this switcher before merge.
+function ConfigureStep({
+  source,
+  targetProfiles,
+  canCreateProfile,
+  sourceProfileDirectory,
+  onSourceProfileChange,
+  target,
+  onTargetChange,
+  onCancel,
+  onImport,
+}: ConfigureStepProps) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Import from {source.name}</DialogTitle>
+        <DialogDescription>Cookies flow from the browser into a profile here.</DialogDescription>
+      </DialogHeader>
+      <DialogPanel>
+        {/* Side by side when the dialog has room, stacked when it doesn't. */}
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              From
+            </p>
+            {source.profiles.map((profile) => (
+              <SelectableTile
+                key={profile.directory}
+                selected={sourceProfileDirectory === profile.directory}
+                title={profile.name}
+                subtitle={cookieCountLabel(profile.cookieCount)}
+                onSelect={() => onSourceProfileChange(profile.directory)}
+              />
+            ))}
+          </section>
+          <div className="flex shrink-0 items-center justify-center text-muted-foreground">
+            <ArrowDownIcon className="size-4 sm:hidden" />
+            <ArrowRightIcon className="hidden size-4 sm:block" />
+          </div>
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              Into
+            </p>
+            {canCreateProfile ? (
+              <SelectableTile
+                selected={target === NEW_TARGET_VALUE}
+                title="New profile"
+                subtitle="Created for these cookies"
+                onSelect={() => onTargetChange(NEW_TARGET_VALUE)}
+              />
+            ) : null}
+            {targetProfiles.map((profile) => (
+              <SelectableTile
+                key={profile.id}
+                selected={target === profile.id}
+                title={profile.name}
+                subtitle="Existing profile"
+                onSelect={() => onTargetChange(profile.id)}
+              />
+            ))}
+          </section>
+        </div>
+      </DialogPanel>
+      <ConfigureFooter onCancel={onCancel} onImport={onImport} />
+    </>
+  );
+}
+
+/** Shared footer so the step keeps one set of actions. */
+function ConfigureFooter({
+  onCancel,
+  onImport,
+}: {
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+}) {
+  return (
+    <DialogFooter>
+      <Button variant="outline" onClick={onCancel}>
+        Cancel
+      </Button>
+      <Button onClick={onImport}>Import</Button>
+    </DialogFooter>
+  );
+}
+
+/** 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 (
+    <button
+      type="button"
+      onClick={onSelect}
+      className={cn(
+        "flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
+        selected
+          ? "border-primary bg-primary/8"
+          : "border-border/60 hover:border-border hover:bg-muted/40",
+      )}
+    >
+      <span className="min-w-0">
+        <span className="block truncate text-sm font-medium text-foreground">{title}</span>
+        {subtitle ? (
+          <span className="block truncate text-xs tabular-nums text-muted-foreground">
+            {subtitle}
+          </span>
+        ) : null}
+      </span>
+      <span
+        className={cn(
+          "grid size-4 shrink-0 place-items-center rounded-full border",
+          selected ? "border-primary bg-primary text-primary-foreground" : "border-input",
+        )}
+      >
+        {selected ? <CheckIcon className="size-2.5" /> : null}
+      </span>
+    </button>
+  );
+}
+
+function ImportingStep() {
+  return (
+    <DialogPanel className="flex items-center gap-3 py-6">
+      <Spinner className="size-4 text-muted-foreground" />
+      <span className="text-sm text-muted-foreground">Importing cookies…</span>
+    </DialogPanel>
+  );
+}
+
+function DoneStep({
+  imported,
+  skipped,
+  skippedDomains,
+  targetName,
+  onClose,
+}: {
+  readonly imported: number;
+  readonly skipped: number;
+  readonly skippedDomains: ReadonlyArray<string>;
+  readonly targetName: string;
+  readonly onClose: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>
+          {imported > 0 ? `Imported ${imported} cookies` : "Nothing to import"}

apps/web/src/components/settings/BrowserImportWizard.tsx Β· right 356–356

M
macroscopeappBotAug 28, 2026

🟒 Low settings/BrowserImportWizard.tsx:356

The success title renders Imported 1 cookies when exactly one cookie is imported. Make the noun singular for imported === 1, consistent with cookieCountLabel.

suggestion
1 {imported > 0 ? `Imported ${imported} ${imported === 1 ? "cookie" : "cookies"}` : "Nothing to import"}
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/settings/BrowserImportWizard.tsx around line 356:
2
3The success title renders `Imported 1 cookies` when exactly one cookie is imported. Make the noun singular for `imported === 1`, consistent with `cookieCountLabel`.

apps/desktop/src/preview/BrowserImport/Sources.ts Β· Earlier revision 09ce84d5 Β· right 156

Original diff
@@ -0,0 +1,234 @@
+/**
+ * Importable browser sources.
+ *
+ * Each entry pins its own on-disk and keychain coordinates rather than
+ * deriving them: Chromium forks do not agree on the convention. Helium, for
+ * instance, uses the keychain service "Helium Storage Key" / account "Helium"
+ * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
+ *
+ * @module BrowserImportSources
+ */
+import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/**
+ * Where a source's files live, resolved once per call rather than read from
+ * the ambient process so the registry stays testable.
+ */
+export interface SourcePaths {
+  readonly path: Path.Path;
+  readonly home: string;
+}
+
+export const sourcePaths = Effect.gen(function* () {
+  const path = yield* Path.Path;
+  const environment = yield* HostProcessEnvironment;
+  return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
+});
+
+export interface BrowserImportSourceDefinition {
+  readonly id: BrowserImportSourceId;
+  readonly name: string;
+  /** Platforms the definition's paths are valid for. */
+  readonly platforms: ReadonlyArray<NodeJS.Platform>;
+  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly keychainService: string;
+  readonly keychainAccount: string;
+}
+
+export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
+  {
+    id: "helium",
+    name: "Helium",
+    platforms: ["darwin"],
+    userDataDirectory: ({ path, home }) =>
+      path.join(home, "Library", "Application Support", "net.imput.helium"),
+    keychainService: "Helium Storage Key",
+    keychainAccount: "Helium",
+  },
+];
+
+export const cookieDatabasePath = (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  profileDirectory: string,
+): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies");
+
+/** Shape of the slice of Chromium's `Local State` that names its profiles. */
+const LocalState = Schema.Struct({
+  profile: Schema.optional(
+    Schema.Struct({
+      info_cache: Schema.optional(
+        Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
+      ),
+    }),
+  ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/** A single plain path segment: no separators, no `.`/`..`, not empty. */
+const isSafeProfileDirectory = (directory: string): boolean =>
+  directory.length > 0 &&
+  directory !== "." &&
+  directory !== ".." &&
+  !/[\\/]/.test(directory) &&
+  !directory.includes("\u0000");
+
+const CookieCountRow = Schema.Struct({ count: Schema.Number });
+const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));
+
+/**
+ * How many cookies a profile holds, counted without decrypting anything β€” a
+ * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium
+ * database (Firefox's table is named differently, Safari's is not SQL) yields
+ * `undefined` rather than failing the listing.
+ */
+const countProfileCookies = Effect.fnUntraced(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  directory: string,
+): Effect.fn.Return<number | undefined, never> {
+  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<BrowserImportSourceProfile>,
+) =>
+  Effect.forEach(profiles, (profile) =>
+    countProfileCookies(definition, paths, profile.directory).pipe(
+      Effect.map((cookieCount) =>
+        cookieCount === undefined ? profile : { ...profile, cookieCount },
+      ),
+    ),
+  );
+
+/**
+ * Profiles the source browser knows about, read from its `Local State`.
+ *
+ * When that file is missing, unreadable or malformed, the user-data directory
+ * is scanned for directories that hold a cookie database. Assuming `Default`
+ * instead would report a browser whose cookies live in `Profile 1` as having
+ * nothing to import β€” and it is then left out of the menu entirely.
+ */
+export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+
+  const root = definition.userDataDirectory(paths);
+  const declared = yield* fileSystem.readFileString(localStatePath).pipe(
+    Effect.flatMap(decodeLocalState),
+    Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
+    // The keys are directory names from the browser's own metadata file, which
+    // is writable by anything running as the user. Anything but a single plain
+    // segment is dropped: `..` or a path separator would otherwise be handed
+    // to `cookieDatabasePath` and read a database outside the user-data
+    // directory.
+    Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))),
+    Effect.map((entries) =>
+      entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })),
+    ),
+    Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+  );
+  if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared);
M
macroscopeappBotAug 28, 2026

🟑 Medium BrowserImport/Sources.ts:156

isSourceInstalled reports false and hides the source when Local State contains a stale entry such as Default while an actual Profile 1/Cookies database exists. Because line 156 skips the directory scan whenever declared.length > 0, the real profile is never discovered; scan and merge filesystem profiles even when the cache is non-empty.

No longer relevant as of a7d72de122b9cf679eeed4ac8fada652fc3455fd

apps/web/src/components/settings/BrowserImportWizard.tsx Β· Earlier revision 0891a8c5 Β· right 355–362

Original diff
@@ -0,0 +1,406 @@
+import type { BrowserImportSource } from "@t3tools/contracts";
+import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
+import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { cn, randomUUID } from "~/lib/utils";
+
+import { Button } from "../ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogPanel,
+  DialogPopup,
+  DialogTitle,
+} from "../ui/dialog";
+import { Spinner } from "../ui/spinner";
+import {
+  initialWizardStep,
+  isRetryableReason,
+  formatSkippedDomains,
+  outcomeToStep,
+  refreshedSourceStep,
+  type ImportOutcome,
+  type WizardStep,
+} from "./browserImportWizard.logic";
+
+/** A profile the import can land in. */
+export interface WizardTargetProfile {
+  readonly id: string;
+  readonly name: string;
+}
+
+/** The target the user picked: a brand-new profile, or an existing one. */
+export type WizardTarget =
+  | { readonly kind: "new"; readonly profileId: string }
+  | { readonly kind: "existing"; readonly profileId: string; readonly name: string };
+
+const NEW_TARGET_VALUE = "new";
+
+interface BrowserImportWizardProps {
+  readonly source: BrowserImportSource;
+  /** Existing profiles the import can go into. Incognito is excluded upstream. */
+  readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
+  /** 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<ImportOutcome>;
+  /** Re-checks the source's availability after the user quits the browser. */
+  readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
+  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<WizardStep>(() => initialWizardStep(initialSource));
+  const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
+    () => initialSource.profiles[0]?.directory ?? "",
+  );
+  const [target, setTarget] = useState<string>(
+    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 (
+    <Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
+      <DialogPopup className="max-w-lg">
+        {step.step === "quit" ? (
+          <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
+        ) : step.step === "importing" ? (
+          <ImportingStep />
+        ) : step.step === "done" ? (
+          <DoneStep {...step} onClose={onClose} />
+        ) : step.step === "blocked" ? (
+          <BlockedStep
+            source={source}
+            reason={step.reason}
+            onClose={onClose}
+            onRetry={isRetryableReason(step.reason) ? runImport : undefined}
+          />
+        ) : (
+          <ConfigureStep
+            source={source}
+            targetProfiles={targetProfiles}
+            canCreateProfile={canCreateProfile}
+            sourceProfileDirectory={sourceProfileDirectory}
+            onSourceProfileChange={setSourceProfileDirectory}
+            target={target}
+            onTargetChange={setTarget}
+            onCancel={onClose}
+            onImport={runImport}
+          />
+        )}
+      </DialogPopup>
+    </Dialog>
+  );
+}
+
+function QuitStep({
+  source,
+  onCancel,
+  onRechecked,
+}: {
+  readonly source: BrowserImportSource;
+  readonly onCancel: () => void;
+  readonly onRechecked: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Quit {source.name} to import</DialogTitle>
+        <DialogDescription>
+          {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue.
+        </DialogDescription>
+      </DialogHeader>
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel}>
+          Cancel
+        </Button>
+        <Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
+      </DialogFooter>
+    </>
+  );
+}
+
+/** "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<WizardTargetProfile>;
+  readonly canCreateProfile: boolean;
+  readonly sourceProfileDirectory: string;
+  readonly onSourceProfileChange: (directory: string) => void;
+  readonly target: string;
+  readonly onTargetChange: (target: string) => void;
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+};
+
+// TEMP: an in-dialog layout switcher for comparing directions live β€” the ui.sh
+// picker can't load under the app's CSP. Collapse to the chosen variant and
+// delete this switcher before merge.
+function ConfigureStep({
+  source,
+  targetProfiles,
+  canCreateProfile,
+  sourceProfileDirectory,
+  onSourceProfileChange,
+  target,
+  onTargetChange,
+  onCancel,
+  onImport,
+}: ConfigureStepProps) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Import from {source.name}</DialogTitle>
+        <DialogDescription>Cookies flow from the browser into a profile here.</DialogDescription>
+      </DialogHeader>
+      <DialogPanel>
+        {/* Side by side when the dialog has room, stacked when it doesn't. */}
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              From
+            </p>
+            {source.profiles.map((profile) => (
+              <SelectableTile
+                key={profile.directory}
+                selected={sourceProfileDirectory === profile.directory}
+                title={profile.name}
+                subtitle={cookieCountLabel(profile.cookieCount)}
+                onSelect={() => onSourceProfileChange(profile.directory)}
+              />
+            ))}
+          </section>
+          <div className="flex shrink-0 items-center justify-center text-muted-foreground">
+            <ArrowDownIcon className="size-4 sm:hidden" />
+            <ArrowRightIcon className="hidden size-4 sm:block" />
+          </div>
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              Into
+            </p>
+            {canCreateProfile ? (
+              <SelectableTile
+                selected={target === NEW_TARGET_VALUE}
+                title="New profile"
+                subtitle="Created for these cookies"
+                onSelect={() => onTargetChange(NEW_TARGET_VALUE)}
+              />
+            ) : null}
+            {targetProfiles.map((profile) => (
+              <SelectableTile
+                key={profile.id}
+                selected={target === profile.id}
+                title={profile.name}
+                subtitle="Existing profile"
+                onSelect={() => onTargetChange(profile.id)}
+              />
+            ))}
+          </section>
+        </div>
+      </DialogPanel>
+      <ConfigureFooter onCancel={onCancel} onImport={onImport} />
+    </>
+  );
+}
+
+/** Shared footer so the step keeps one set of actions. */
+function ConfigureFooter({
+  onCancel,
+  onImport,
+}: {
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+}) {
+  return (
+    <DialogFooter>
+      <Button variant="outline" onClick={onCancel}>
+        Cancel
+      </Button>
+      <Button onClick={onImport}>Import</Button>
+    </DialogFooter>
+  );
+}
+
+/** 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 (
+    <button
+      type="button"
+      onClick={onSelect}
+      className={cn(
+        "flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
+        selected
+          ? "border-primary bg-primary/8"
+          : "border-border/60 hover:border-border hover:bg-muted/40",
+      )}
+    >
+      <span className="min-w-0">
+        <span className="block truncate text-sm font-medium text-foreground">{title}</span>
+        {subtitle ? (
+          <span className="block truncate text-xs tabular-nums text-muted-foreground">
+            {subtitle}
+          </span>
+        ) : null}
+      </span>
+      <span
+        className={cn(
+          "grid size-4 shrink-0 place-items-center rounded-full border",
+          selected ? "border-primary bg-primary text-primary-foreground" : "border-input",
+        )}
+      >
+        {selected ? <CheckIcon className="size-2.5" /> : null}
+      </span>
+    </button>
+  );
+}
+
+function ImportingStep() {
+  return (
+    <DialogPanel className="flex items-center gap-3 py-6">
+      <Spinner className="size-4 text-muted-foreground" />
+      <span className="text-sm text-muted-foreground">Importing cookies…</span>
+    </DialogPanel>
+  );
+}
+
+function DoneStep({
+  imported,
+  skipped,
+  skippedDomains,
+  targetName,
+  onClose,
+}: {
+  readonly imported: number;
+  readonly skipped: number;
+  readonly skippedDomains: ReadonlyArray<string>;
+  readonly targetName: string;
+  readonly onClose: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>
+          {imported > 0 ? `Imported ${imported} cookies` : "Nothing to import"}
+        </DialogTitle>
+        <DialogDescription>
+          {imported > 0
+            ? `Into ${targetName}.${skipped > 0 ? ` ${skipped} couldn't be brought over.` : ""}`
+            : "There were no cookies to bring over."}
+        </DialogDescription>

apps/web/src/components/settings/BrowserImportWizard.tsx Β· right 355–362

M
macroscopeappBotAug 28, 2026

When every cookie is skipped (imported === 0, skipped > 0 β€” e.g. an unusable key), this screen now contradicts itself: the header says "Nothing to import" / "There were no cookies to bring over", while the panel added just below lists the sites whose cookies were skipped, and the skipped count is never shown because it only appears on the imported > 0 branch. A user who picked "You β€” 5,065 cookies" on the configure step is told nothing was there.

Consider giving the all-skipped case its own copy so the header agrees with the panel.

suggestion
1 <DialogTitle>
2 {imported > 0
3 ? `Imported ${imported} cookies`
4 : skipped > 0
5 ? "No cookies could be brought over"
6 : "Nothing to import"}
7 </DialogTitle>
8 <DialogDescription>
9 {imported > 0
10 ? `Into ${targetName}.${skipped > 0 ? ` ${skipped} couldn't be brought over.` : ""}`
11 : skipped > 0
12 ? `None of the ${skipped} cookies found could be read.`
13 : "There were no cookies to bring over."}
14 </DialogDescription>

Posted via Macroscope β€” UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 37cdc249ab. All-skipped and genuinely empty results are separate cases, so zero imported does not claim the source had no cookies.

apps/web/src/components/settings/BrowserImportWizard.tsx Β· Earlier revision 2d762e53 Β· right 371

Original diff
@@ -0,0 +1,406 @@
+import type { BrowserImportSource } from "@t3tools/contracts";
+import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
+import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { cn, randomUUID } from "~/lib/utils";
+
+import { Button } from "../ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogPanel,
+  DialogPopup,
+  DialogTitle,
+} from "../ui/dialog";
+import { Spinner } from "../ui/spinner";
+import {
+  initialWizardStep,
+  isRetryableReason,
+  formatSkippedDomains,
+  outcomeToStep,
+  refreshedSourceStep,
+  type ImportOutcome,
+  type WizardStep,
+} from "./browserImportWizard.logic";
+
+/** A profile the import can land in. */
+export interface WizardTargetProfile {
+  readonly id: string;
+  readonly name: string;
+}
+
+/** The target the user picked: a brand-new profile, or an existing one. */
+export type WizardTarget =
+  | { readonly kind: "new"; readonly profileId: string }
+  | { readonly kind: "existing"; readonly profileId: string; readonly name: string };
+
+const NEW_TARGET_VALUE = "new";
+
+interface BrowserImportWizardProps {
+  readonly source: BrowserImportSource;
+  /** Existing profiles the import can go into. Incognito is excluded upstream. */
+  readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
+  /** 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<ImportOutcome>;
+  /** Re-checks the source's availability after the user quits the browser. */
+  readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
+  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<WizardStep>(() => initialWizardStep(initialSource));
+  const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
+    () => initialSource.profiles[0]?.directory ?? "",
+  );
+  const [target, setTarget] = useState<string>(
+    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 (
+    <Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
+      <DialogPopup className="max-w-lg">
+        {step.step === "quit" ? (
+          <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
+        ) : step.step === "importing" ? (
+          <ImportingStep />
+        ) : step.step === "done" ? (
+          <DoneStep {...step} onClose={onClose} />
+        ) : step.step === "blocked" ? (
+          <BlockedStep
+            source={source}
+            reason={step.reason}
+            onClose={onClose}
+            onRetry={isRetryableReason(step.reason) ? runImport : undefined}
+          />
+        ) : (
+          <ConfigureStep
+            source={source}
+            targetProfiles={targetProfiles}
+            canCreateProfile={canCreateProfile}
+            sourceProfileDirectory={sourceProfileDirectory}
+            onSourceProfileChange={setSourceProfileDirectory}
+            target={target}
+            onTargetChange={setTarget}
+            onCancel={onClose}
+            onImport={runImport}
+          />
+        )}
+      </DialogPopup>
+    </Dialog>
+  );
+}
+
+function QuitStep({
+  source,
+  onCancel,
+  onRechecked,
+}: {
+  readonly source: BrowserImportSource;
+  readonly onCancel: () => void;
+  readonly onRechecked: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Quit {source.name} to import</DialogTitle>
+        <DialogDescription>
+          {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue.
+        </DialogDescription>
+      </DialogHeader>
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel}>
+          Cancel
+        </Button>
+        <Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
+      </DialogFooter>
+    </>
+  );
+}
+
+/** "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<WizardTargetProfile>;
+  readonly canCreateProfile: boolean;
+  readonly sourceProfileDirectory: string;
+  readonly onSourceProfileChange: (directory: string) => void;
+  readonly target: string;
+  readonly onTargetChange: (target: string) => void;
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+};
+
+// TEMP: an in-dialog layout switcher for comparing directions live β€” the ui.sh
+// picker can't load under the app's CSP. Collapse to the chosen variant and
+// delete this switcher before merge.
+function ConfigureStep({
+  source,
+  targetProfiles,
+  canCreateProfile,
+  sourceProfileDirectory,
+  onSourceProfileChange,
+  target,
+  onTargetChange,
+  onCancel,
+  onImport,
+}: ConfigureStepProps) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Import from {source.name}</DialogTitle>
+        <DialogDescription>Cookies flow from the browser into a profile here.</DialogDescription>
+      </DialogHeader>
+      <DialogPanel>
+        {/* Side by side when the dialog has room, stacked when it doesn't. */}
+        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              From
+            </p>
+            {source.profiles.map((profile) => (
+              <SelectableTile
+                key={profile.directory}
+                selected={sourceProfileDirectory === profile.directory}
+                title={profile.name}
+                subtitle={cookieCountLabel(profile.cookieCount)}
+                onSelect={() => onSourceProfileChange(profile.directory)}
+              />
+            ))}
+          </section>
+          <div className="flex shrink-0 items-center justify-center text-muted-foreground">
+            <ArrowDownIcon className="size-4 sm:hidden" />
+            <ArrowRightIcon className="hidden size-4 sm:block" />
+          </div>
+          <section className="flex-1 space-y-2">
+            <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+              Into
+            </p>
+            {canCreateProfile ? (
+              <SelectableTile
+                selected={target === NEW_TARGET_VALUE}
+                title="New profile"
+                subtitle="Created for these cookies"
+                onSelect={() => onTargetChange(NEW_TARGET_VALUE)}
+              />
+            ) : null}
+            {targetProfiles.map((profile) => (
+              <SelectableTile
+                key={profile.id}
+                selected={target === profile.id}
+                title={profile.name}
+                subtitle="Existing profile"
+                onSelect={() => onTargetChange(profile.id)}
+              />
+            ))}
+          </section>
+        </div>
+      </DialogPanel>
+      <ConfigureFooter onCancel={onCancel} onImport={onImport} />
+    </>
+  );
+}
+
+/** Shared footer so the step keeps one set of actions. */
+function ConfigureFooter({
+  onCancel,
+  onImport,
+}: {
+  readonly onCancel: () => void;
+  readonly onImport: () => void;
+}) {
+  return (
+    <DialogFooter>
+      <Button variant="outline" onClick={onCancel}>
+        Cancel
+      </Button>
+      <Button onClick={onImport}>Import</Button>
+    </DialogFooter>
+  );
+}
+
+/** 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 (
+    <button
+      type="button"
+      onClick={onSelect}
+      className={cn(
+        "flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
+        selected
+          ? "border-primary bg-primary/8"
+          : "border-border/60 hover:border-border hover:bg-muted/40",
+      )}
+    >
+      <span className="min-w-0">
+        <span className="block truncate text-sm font-medium text-foreground">{title}</span>
+        {subtitle ? (
+          <span className="block truncate text-xs tabular-nums text-muted-foreground">
+            {subtitle}
+          </span>
+        ) : null}
+      </span>
+      <span
+        className={cn(
+          "grid size-4 shrink-0 place-items-center rounded-full border",
+          selected ? "border-primary bg-primary text-primary-foreground" : "border-input",
+        )}
+      >
+        {selected ? <CheckIcon className="size-2.5" /> : null}
+      </span>
+    </button>
+  );
+}
+
+function ImportingStep() {
+  return (
+    <DialogPanel className="flex items-center gap-3 py-6">
+      <Spinner className="size-4 text-muted-foreground" />
+      <span className="text-sm text-muted-foreground">Importing cookies…</span>
+    </DialogPanel>
+  );
+}
+
+function DoneStep({
+  imported,
+  skipped,
+  skippedDomains,
+  targetName,
+  onClose,
+}: {
+  readonly imported: number;
+  readonly skipped: number;
+  readonly skippedDomains: ReadonlyArray<string>;
+  readonly targetName: string;
+  readonly onClose: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>
+          {imported > 0 ? `Imported ${imported} cookies` : "Nothing to import"}
+        </DialogTitle>
+        <DialogDescription>
+          {imported > 0
+            ? `Into ${targetName}.${skipped > 0 ? ` ${skipped} couldn't be brought over.` : ""}`
+            : "There were no cookies to bring over."}
+        </DialogDescription>
+      </DialogHeader>
+      {skippedDomains.length > 0 ? (
+        <DialogPanel>
+          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
+            Skipped
+          </p>
+          <p className="mt-1 text-sm text-foreground">{formatSkippedDomains(skippedDomains)}</p>
+        </DialogPanel>
+      ) : null}
C
cursorBotAug 17, 2026

Done copy contradicts skips

Low Severity

When imported is 0 but cookies were skipped, DoneStep still says there were no cookies to bring over while also rendering the new Skipped hosts panel. That pairing is contradictory for all-decrypt or all-write failures.

Reviewed by Cursor Bugbot for commit 2d762e53d46f9f2bfbc38e1046a8af714f3f60e7. Configure here.

apps/web/src/components/settings/IntegrationsSettings.tsx Β· Earlier revision adef2961 Β· right 849–849

Original diff
@@ -584,90 +656,227 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
     setProfilePendingRemoval(null);
   };
 
+  // A browser that is not on this machine is left out rather than listed as a
+  // dead row: there is nothing to act on, and the menu is a list of things you
+  // can import from. Every other unavailable reason stays, since each names a
+  // step the user can take.
+  const importableSources = (sources ?? []).filter(
+    (source) => source.unavailable !== "notInstalled",
+  );
+
+  // Refreshed without blanking the last result: the menu shows the cached list
+  // straight away so it doesn't reflow on open, and the source list is stable
+  // (names only) since choosing what to import happens in the wizard, not here.
+  const loadSources = () => {
+    if (!previewBridge) return;
+    void previewBridge
+      .listBrowserImportSources()
+      .then(setSources)
+      .catch(() => setSources((previous) => previous ?? []));
+  };
+
+  // Loaded once so the first open is instant instead of flashing a spinner.
+  useEffect(() => {
+    loadSources();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Runs one import for the wizard. A new profile is registered only once the
+  // import succeeds β€” the cookies land in its partition first β€” so a blocked
+  // attempt never leaves an empty profile behind.
+  const runWizardImport = async (
+    source: BrowserImportSource,
+    input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget },
+  ): Promise<ImportOutcome> => {
+    if (!environmentId || !previewBridge) return { kind: "blocked", reason: "sessionUnavailable" };
+    if (importInFlightRef.current) return { kind: "blocked", reason: "readFailed" };
+    importInFlightRef.current = true;
+    setImportInFlight(true);
+    try {
+      const result = await previewBridge.importBrowserCookies({
+        environmentId,
+        sourceId: source.id,
+        sourceProfileDirectory: input.sourceProfileDirectory,
+        targetProfileId: input.target.profileId,
+      });
+      let targetName: string;
+      if (input.target.kind === "new") {
+        const latestProfiles = getClientSettings().browserProfiles;
+        const taken = new Set(
+          resolveBrowserProfiles(latestProfiles).map((profile) => profile.name),
+        );
+        targetName = source.name;
+        for (let index = 2; taken.has(targetName); index += 1) {
+          targetName = `${source.name} ${index}`;
+        }
+        // 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: [
+              ...latestProfiles,
+              { id: input.target.profileId, name: targetName, kind: "persistent" as const },
+            ],
+          });
+        }
+      } else {
+        targetName = input.target.name;
+      }
+      return {
+        kind: "imported",
+        imported: result.imported,
+        skipped: result.skipped,
+        skippedDomains: result.skippedDomains,
+        targetName,
+      };
+    } catch (cause) {
+      return { kind: "blocked", reason: importFailureReason(cause) };
+    } finally {
+      importInFlightRef.current = false;
+      setImportInFlight(false);
+    }
+  };
+
+  // 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<BrowserImportSource | undefined> => {
+    if (!previewBridge) return undefined;
+    try {
+      const latest = await previewBridge.listBrowserImportSources();
+      setSources(latest);
+      return latest.find((source) => source.id === sourceId);
+    } catch {
+      return undefined;
+    }
+  };
+
+  const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT;
+
   return (
     <SettingsRow
       {...searchableSetting("browser-profiles")}
-      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      description="Each profile has its own cookies and logins, so you can stay signed in to different accounts at once."
       control={
-        <Button
-          size="sm"
-          variant="outline"
-          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
-          onClick={addProfile}
-        >
-          <PlusIcon />
-          Add profile
-        </Button>
+        <Menu onOpenChange={(open) => open && loadSources()}>
+          <MenuTrigger
+            render={<Button size="sm" variant="outline" disabled={disabled || importInFlight} />}
+          >
+            <PlusIcon />
+            Add profile
+          </MenuTrigger>
+          <MenuPopup align="end" className="min-w-56">
+            <MenuItem disabled={atProfileLimit} onClick={() => createProfile("New profile")}>
+              Blank profile
+            </MenuItem>
+            <MenuSeparator />
+            <MenuGroup>
+              <MenuGroupLabel>Import from</MenuGroupLabel>
+              {sources === null ? (
+                <MenuItem disabled>Looking for browsers…</MenuItem>
+              ) : importableSources.length === 0 ? (
+                <MenuItem disabled>No supported browsers found</MenuItem>
+              ) : (
+                // 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) => (
+                  <MenuItem key={source.id} onClick={() => setImportSource(source)}>
+                    {source.name}
+                  </MenuItem>
+                ))
+              )}
+            </MenuGroup>
+          </MenuPopup>
+        </Menu>
       }
     >
       {/*
-        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`).
       */}
-      <div className="mt-2 space-y-1 pb-2">
-        {resolveBrowserProfiles(userProfiles).map((profile) => {
+      <div className="mt-2 mb-2 overflow-hidden rounded-lg border border-border/60">
+        {listedProfiles.map((profile, index) => {
           const builtIn = isBuiltInBrowserProfileId(profile.id);
+          const isDefault = profile.id === resolvedDefaultId;
           return (
             <div
               key={profile.id}
               className={cn(
-                ITEM_ROW_INNER_CLASSNAME,
-                "rounded-lg border border-border/60 px-3 py-2",
+                "flex items-center gap-3 px-3 py-2",
+                index > 0 && "border-t border-border/60",
               )}
             >
-              {builtIn ? (
-                // Dimmed here rather than on the list, which is the only
-                // content in the row without a disabled treatment of its own:
-                // a wrapper-level dim would stack with the rename field's and
-                // the remove button's, landing them near 0.41 while every
-                // other disabled control in the block sits at 0.64.
-                <span
-                  className={cn(
-                    "flex min-w-0 items-center gap-2 text-sm text-foreground",
-                    disabled && "opacity-64",
-                  )}
-                >
-                  {profile.name}
-                  <Badge variant="outline">
-                    {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}
-                  </Badge>
-                </span>
-              ) : (
-                <DraftInput
-                  nativeInput
-                  size="sm"
-                  className="w-full sm:w-64"
-                  aria-label={`Rename ${profile.name}`}
-                  disabled={disabled}
-                  maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
-                  value={profile.name}
-                  onCommit={(next) => renameProfile(profile.id, next)}
-                />
-              )}
-              {builtIn ? null : (
-                <Tooltip>
-                  <TooltipTrigger
-                    render={
-                      <Button
-                        size="icon-sm"
-                        variant="ghost-muted"
-                        disabled={disabled || !removalAvailable}
-                        aria-label={`Remove ${profile.name}`}
-                        onClick={() => setProfilePendingRemoval(profile)}
-                      >
-                        <Trash2Icon />
-                      </Button>
-                    }
+              <span className="flex min-w-0 flex-1 items-center gap-2">
+                {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.
+                  <span
+                    className={cn("truncate text-sm text-foreground", disabled && "opacity-64")}
+                  >
+                    {profile.name}
+                  </span>
+                ) : (
+                  <DraftInput
+                    nativeInput
+                    size="sm"
+                    className="w-full max-w-56"
+                    aria-label={`Rename ${profile.name}`}
+                    disabled={disabled || importInFlight}
+                    maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
+                    value={profile.name}
+                    onCommit={(next) => renameProfile(profile.id, next)}
                   />
-                  <TooltipPopup side="top">
-                    {removalAvailable
-                      ? "Remove profile and its data"
-                      : "Connect to an environment to remove this profile"}
-                  </TooltipPopup>
-                </Tooltip>
-              )}
+                )}
+                {/*
+                  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 ? <Badge className={cn(disabled && "opacity-64")}>Default</Badge> : null}
+              </span>
+              <Menu>
+                <MenuTrigger
+                  render={
+                    <Button
+                      size="icon-sm"
+                      variant="ghost-muted"
+                      disabled={disabled || !environmentsReady || importInFlight}

apps/web/src/components/settings/IntegrationsSettings.tsx Β· right 849–849

M
macroscopeappBotAug 29, 2026

The row menu now hosts Set as default, which is a purely local client setting, but the trigger stays disabled until an environment catalog is ready. This PR also deletes BrowserDefaultProfileSetting, whose select was enabled whenever isElectron β€” so on desktop before a server connects there is no longer any way to change the default profile, and the dead icon button carries no explanation (the removal button it replaced had a Tooltip saying why).

The two environment-dependent items already gate themselves with disabled={!removalAvailable} (which is stricter than environmentsReady), so the trigger gate is redundant for them.

suggestion
1 disabled={disabled || importInFlight}

Posted via Macroscope β€” UI Consistency

J
Julius MarmingeAug 29, 2026

Fixed in 649be8a030. The profile menu no longer requires environment readiness, so Set as default remains available. Clear/remove retain their stricter cleanup-availability guards, and import/runtime pending restrictions remain.

apps/desktop/src/preview/BrowserImport/Sources.ts Β· Earlier revision 7b8e2945 Β· right 105

Original diff
@@ -0,0 +1,185 @@
+/**
+ * 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 "<Name> Safe Storage" / "<Name>".
+ *
+ * @module BrowserImportSources
+ */
+import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
+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<NodeJS.Platform>;
+  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly keychainService: string;
+  readonly keychainAccount: string;
+}
+
+export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
+  {
+    id: "helium",
+    name: "Helium",
+    platforms: ["darwin"],
+    userDataDirectory: ({ path, home }) =>
+      path.join(home, "Library", "Application Support", "net.imput.helium"),
+    keychainService: "Helium Storage Key",
+    keychainAccount: "Helium",
+  },
+];
+
+export const cookieDatabasePath = (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  profileDirectory: string,
+): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies");
+
+/** Shape of the slice of Chromium's `Local State` that names its profiles. */
+const LocalState = Schema.Struct({
+  profile: Schema.optional(
+    Schema.Struct({
+      info_cache: Schema.optional(
+        Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
+      ),
+    }),
+  ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/** A single plain path segment: no separators, no `.`/`..`, not empty. */
+const isSafeProfileDirectory = (directory: string): boolean =>
+  directory.length > 0 &&
+  directory !== "." &&
+  directory !== ".." &&
+  !/[\\/]/.test(directory) &&
+  !directory.includes("\u0000");
+
+/**
+ * Profiles the source browser knows about, read from its `Local State`.
+ *
+ * When that file is missing, unreadable or malformed, the user-data directory
+ * is scanned for directories that hold a cookie database. Assuming `Default`
+ * instead would report a browser whose cookies live in `Profile 1` as having
+ * nothing to import β€” and it is then left out of the menu entirely.
+ */
+export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+
+  const root = definition.userDataDirectory(paths);
+  const declared = yield* fileSystem.readFileString(localStatePath).pipe(
+    Effect.flatMap(decodeLocalState),
+    Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
+    // The keys are directory names from the browser's own metadata file, which
+    // is writable by anything running as the user. Anything but a single plain
+    // segment is dropped: `..` or a path separator would otherwise be handed
+    // to `cookieDatabasePath` and read a database outside the user-data
+    // directory.
+    Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))),
M
macroscopeappBotAug 17, 2026

🟠 High BrowserImport/Sources.ts:105

A symlinked profile such as <userData>/Default lets cookieDatabasePath resolve and import <external>/Cookies outside the browser user-data directory. isSafeProfileDirectory only validates the key lexically, so it does not enforce the path-containment guarantee described here; resolve the profile path and verify it remains under root (or reject symlinked profile components) before returning it.

No longer relevant as of a7d72de122b9cf679eeed4ac8fada652fc3455fd

apps/desktop/src/preview/BrowserImport/Sources.ts Β· Earlier revision d35f0a79 Β· right 98

Original diff
@@ -0,0 +1,160 @@
+/**
+ * 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 "<Name> Safe Storage" / "<Name>".
+ *
+ * @module BrowserImportSources
+ */
+import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
+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<NodeJS.Platform>;
+  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly keychainService: string;
+  readonly keychainAccount: string;
+}
+
+export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
+  {
+    id: "helium",
+    name: "Helium",
+    platforms: ["darwin"],
+    userDataDirectory: ({ path, home }) =>
+      path.join(home, "Library", "Application Support", "net.imput.helium"),
+    keychainService: "Helium Storage Key",
+    keychainAccount: "Helium",
+  },
+];
+
+export const cookieDatabasePath = (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  profileDirectory: string,
+): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies");
+
+/** Shape of the slice of Chromium's `Local State` that names its profiles. */
+const LocalState = Schema.Struct({
+  profile: Schema.optional(
+    Schema.Struct({
+      info_cache: Schema.optional(
+        Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
+      ),
+    }),
+  ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+const DEFAULT_PROFILES: ReadonlyArray<BrowserImportSourceProfile> = [
+  { 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.
+ */
+export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+
+  const 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<BrowserImportSourceProfile>),
M
macroscopeappBotAug 17, 2026

🟠 High BrowserImport/Sources.ts:98

When Local State is unreadable or malformed, listSourceProfiles returns only Default, so isSourceInstalled checks only Default/Cookies and reports an installation with cookies in Profile 1 as notInstalled. This hides an otherwise valid source; the fallback should discover available profile directories (or otherwise check profile databases) instead of restricting the check to Default.

βœ… Resolved in 40385efaf3d51c5a7b12f40de484d364c39b63d0

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision 58530950 Β· right 191

Original diff
@@ -0,0 +1,207 @@
+/**
+ * Browser import service - lists importable sources and writes their cookies
+ * into a T3 Code browser profile's Electron partition.
+ *
+ * @module BrowserImport
+ */
+import type {
+  BrowserImportInput,
+  BrowserImportResult,
+  BrowserImportSource,
+  BrowserImportUnavailableReason,
+} from "@t3tools/contracts";
+import { BrowserImportFailureReason } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+
+import * as BrowserSession from "../BrowserSession.ts";
+import { readChromiumCookies } from "./ChromiumCookies.ts";
+import {
+  BROWSER_IMPORT_SOURCES,
+  cookieDatabasePath,
+  isSourceInstalled,
+  isSourceRunning,
+  listSourceProfiles,
+  sourcePaths,
+  type BrowserImportSourceDefinition,
+  type SourcePaths,
+} from "./Sources.ts";
+
+export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
+  "BrowserImportFailedError",
+  {
+    sourceId: Schema.String,
+    reason: BrowserImportFailureReason,
+    /** Kept for the log; the user only ever sees the reason's copy. */
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  // The reason token is part of the message on purpose: IPC flattens the error
+  // to its message, and the renderer maps that token back to user-facing copy.
+  override get message(): string {
+    return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
+  }
+}
+
+export class BrowserImport extends Context.Service<
+  BrowserImport,
+  {
+    readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+  paths: SourcePaths,
+): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
+  if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
+  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;
+  // 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<FileSystem.FileSystem | Path.Path>();
+  const paths = yield* sourcePaths;
+
+  const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
+    BROWSER_IMPORT_SOURCES,
+    Effect.fnUntraced(function* (definition) {
+      const unavailable = yield* unavailableReason(definition, platform, paths);
+      return {
+        id: definition.id,
+        name: definition.name,
+        // Listing profiles touches the source's own files, so skip it when the
+        // source is unusable anyway.
+        profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
+        ...(unavailable === undefined ? {} : { unavailable }),
+      } satisfies BrowserImportSource;
+    }),
+  ).pipe(Effect.provide(platformServices));
+
+  const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: {
+    readonly input: BrowserImportInput;
+    readonly scope: string;
+    readonly persistent: boolean;
+  }) {
+    const definition = BROWSER_IMPORT_SOURCES.find(
+      (candidate) => candidate.id === input.input.sourceId,
+    );
+    if (!definition) {
+      return yield* new BrowserImportFailedError({
+        sourceId: input.input.sourceId,
+        reason: "unknownSource",
+      });
+    }
+
+    const blocked = yield* unavailableReason(definition, platform, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    if (blocked !== undefined) {
+      return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
+    }
+
+    // macOS attributes the Keychain prompt and the resulting ACL grant to the
+    // executable that asks, so record which one that was β€” in a packaged build
+    // it is the signed app, in dev whatever binary hosts the main process.
+    yield* Effect.logInfo("Reading browser cookie key from the keychain", {
+      sourceId: definition.id,
+      executablePath,
+    });
+
+    // The profile directory arrives over IPC, so it is only honoured when the
+    // source itself reported it. Forwarding it unchecked would let `..`
+    // segments walk out of the browser's user-data directory and read any
+    // cookie database reachable on disk.
+    const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
+      Effect.provide(platformServices),
+    );
+    const requestedProfile = sourceProfiles.find(
+      (profile) => profile.directory === input.input.sourceProfileDirectory,
+    );
+    if (requestedProfile === undefined) {
+      return yield* new BrowserImportFailedError({
+        sourceId: definition.id,
+        reason: "unknownSourceProfile",
+      });
+    }
+
+    const 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(
+        (cause) =>
+          new BrowserImportFailedError({
+            sourceId: definition.id,
+            reason: "sessionUnavailable",
+            cause,
+          }),
+      ),
+    );
+
+    // Written one at a time rather than in parallel: Chromium's cookie store
+    // serialises writes anyway, and a rejected cookie should only cost itself.
+    let imported = 0;
+    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 }),
+          }),
C
cursorBotAug 16, 2026

Host-only cookies widened on import

High Severity

Imported cookies always pass domain into session.cookies.set. Electron only creates a host-only cookie when domain is omitted; any provided domain is normalized with a leading dot and becomes valid for subdomains. Chromium host-only rows (no leading dot in host_key) therefore get a wider scope after import than they had in the source browser.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 58530950a4e4ec0a1743ac0ee2826ceba41af020. Configure here.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 58530950 Β· right 220

Original diff
@@ -0,0 +1,258 @@
+// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
+// OSCrypt primitives Chromium uses; Effect has no equivalent.
+/**
+ * Chromium cookie extraction.
+ *
+ * Reads a Chromium-family browser's cookie database and decrypts it with the
+ * key the OS keychain hands us, which is the mechanism the browser itself
+ * uses. macOS mediates that with a per-app consent prompt, so the user
+ * explicitly approves T3 Code reading it.
+ *
+ * Deliberately no fallback when the keychain says no: the alternative
+ * techniques exist to defeat that consent, and this feature is not worth
+ * shipping them.
+ *
+ * @module ChromiumCookies
+ */
+import * as Keyring from "@napi-rs/keyring";
+import * as NodeCrypto from "node:crypto";
+
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/unstable/sql/SqlClient";
+
+/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
+const MAC_KEY_ITERATIONS = 1003;
+const MAC_KEY_SALT = "saltysalt";
+const MAC_KEY_LENGTH = 16;
+/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
+const AES_IV = Buffer.alloc(16, 0x20);
+const V10_PREFIX = "v10";
+
+export interface ChromiumCookie {
+  readonly url: string;
+  readonly name: string;
+  readonly value: string;
+  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 const ChromiumCookieReadReason = Schema.Literals([
+  "needsKeychainApproval",
+  "keychainItemMissing",
+  "browserRunning",
+  "unsupportedPlatform",
+  "readFailed",
+]);
+export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
+
+export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()(
+  "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
+ * would widen the cookie's scope on import.
+ */
+const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
+  if (value === 0) return "no_restriction";
+  if (value === 2) return "strict";
+  return "lax";
+};
+
+/**
+ * Chromium timestamps count microseconds from 1601-01-01; Electron wants
+ * seconds from the UNIX epoch.
+ *
+ * The microsecond value overflows JavaScript's safe integer range, and
+ * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
+ * only ever sees seconds.
+ */
+const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
+const toUnixSeconds = (webkitSeconds: number): number | undefined => {
+  if (webkitSeconds <= 0) return undefined;
+  return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS;
+};
+
+/**
+ * Reads the OSCrypt key from the login keychain.
+ *
+ * Uses the in-process Keychain API rather than shelling out to
+ * `/usr/bin/security`, because the keychain attributes both the consent prompt
+ * and the resulting ACL entry to the binary that asks. Via the CLI the prompt
+ * says "security" and "Always Allow" grants trust to a tool every process on
+ * the machine can invoke; in-process it names this app and the grant belongs
+ * to it. (In an unsigned dev build the name is the dev Electron binary rather
+ * than the shipped app identity.)
+ *
+ * Deliberately untimed: macOS answers this with a modal, and a timeout racing
+ * the user means the prompt can be approved while nothing is left listening β€”
+ * which reads as "approving did nothing".
+ */
+const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
+  service: string,
+  account: string,
+) {
+  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 === "") {
+    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.
+ */
+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);
+  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 const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* (
+  source: ChromiumCookieSource,
+) {
+  if (source.platform !== "darwin") {
+    // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on
+    // current Chrome) each need their own key path; only macOS is implemented.
+    return yield* new ChromiumCookieReadError({ reason: "unsupportedPlatform" });
+  }
+
+  const password = yield* readMacKeychainPassword(source.keychainService, source.keychainAccount);
+  const key = NodeCrypto.pbkdf2Sync(
+    password,
+    MAC_KEY_SALT,
+    MAC_KEY_ITERATIONS,
+    MAC_KEY_LENGTH,
+    "sha1",
+  );
+
+  const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe(
+    Effect.mapError((cause) => new ChromiumCookieReadError({ reason: "readFailed", cause })),
+  );
C
cursorBotAug 16, 2026

Snapshot races keychain consent wait

Medium Severity

browserRunning is checked once before the untimed Keychain modal, but the cookie DB snapshot runs only after that modal returns. If the source browser starts while the prompt is open, the import can copy a live WAL database anyway, which is the torn-read case the snapshot was meant to avoid.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 58530950a4e4ec0a1743ac0ee2826ceba41af020. Configure here.

apps/web/src/components/settings/IntegrationsSettings.tsx Β· Earlier revision 05fe2c1c Β· right 690

Original diff
@@ -523,91 +598,206 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
     }
     updateSettings({
       browserProfiles: userProfiles.filter((profile) => profile.id !== id),
-      // Reassign the default rather than leaving it pointing at nothing.
       ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
     });
   };
 
+  // A browser that is not on this machine is left out rather than listed as a
+  // dead row: there is nothing to act on, and the menu is a list of things you
+  // can import from. Every other unavailable reason stays, since each names a
+  // step the user can take.
+  const importableSources = (sources ?? []).filter(
+    (source) => source.unavailable !== "notInstalled",
+  );
+
+  // Refreshed without blanking the last result: the menu shows the cached list
+  // straight away so it doesn't reflow on open, and the source list is stable
+  // (names only) since choosing what to import happens in the wizard, not here.
+  const loadSources = () => {
+    if (!previewBridge) return;
+    void previewBridge
+      .listBrowserImportSources()
+      .then(setSources)
+      .catch(() => setSources((previous) => previous ?? []));
+  };
+
+  // Loaded once so the first open is instant instead of flashing a spinner.
+  useEffect(() => {
+    loadSources();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Runs one import for the wizard. A new profile is registered only once the
+  // import succeeds β€” the cookies land in its partition first β€” so a blocked
+  // attempt never leaves an empty profile behind.
+  const runWizardImport = async (
+    source: BrowserImportSource,
+    input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget },
+  ): Promise<ImportOutcome> => {
+    if (!environmentId || !previewBridge) return { kind: "blocked", reason: "sessionUnavailable" };
+    try {
+      const result = await previewBridge.importBrowserCookies({
+        environmentId,
+        sourceId: source.id,
+        sourceProfileDirectory: input.sourceProfileDirectory,
+        targetProfileId: input.target.profileId,
+      });
+      let targetName: string;
+      if (input.target.kind === "new") {
+        targetName = uniqueName(source.name);
+        // Registered only when something actually came over: an import that
+        // found no cookies should not leave a new, empty profile behind.
+        if (result.imported > 0) {
+          updateSettings({
+            browserProfiles: [
+              ...userProfiles,
+              { id: input.target.profileId, name: targetName, kind: "persistent" as const },
+            ],
+          });
+        }
+      } else {
+        targetName = input.target.name;
+      }
+      return { kind: "imported", imported: result.imported, skipped: result.skipped, targetName };
+    } catch (cause) {
+      return { kind: "blocked", reason: importFailureReason(cause) };
+    }
+  };
+
+  // Re-checks a source's availability after the user quits the browser, and
+  // keeps the cached list in step so the menu reflects it too.
+  const refreshImportSource = async (
+    sourceId: BrowserImportSource["id"],
+  ): Promise<BrowserImportSource | undefined> => {
+    if (!previewBridge) return undefined;
+    try {
+      const latest = await previewBridge.listBrowserImportSources();
+      setSources(latest);
+      return latest.find((source) => source.id === sourceId);
+    } catch {
+      return undefined;
+    }
+  };
+
+  const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT;
+
   return (
     <SettingsRow
       {...searchableSetting("browser-profiles")}
-      description="Each profile keeps its own cookies and logins, so a tab opened under one can't see another's. Incognito discards everything when the app closes."
+      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={
-        <Button
-          size="sm"
-          variant="outline"
-          disabled={disabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}
-          onClick={addProfile}
-        >
-          <PlusIcon />
-          Add profile
-        </Button>
+        <Menu onOpenChange={(open) => open && loadSources()}>
+          <MenuTrigger render={<Button size="sm" variant="outline" disabled={disabled} />}>
C
cursorBotAug 17, 2026

Untracked imports after wizard close

High Severity

The prior busy lock on Add profile was removed, and dismissing the wizard during importing only clears importSourceβ€”it does not cancel or track the IPC work. Users can start another import (or hit the profile cap) while the first run is still finishing, so registrations race and target can stay new after canCreateProfile flips false.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 05fe2c1c07af19afd480b84df6fc54f11a5ef45a. Configure here.

apps/web/src/components/settings/IntegrationsSettings.tsx Β· Earlier revision 05fe2c1c Β· right 656

Original diff
@@ -523,91 +598,206 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
     }
     updateSettings({
       browserProfiles: userProfiles.filter((profile) => profile.id !== id),
-      // Reassign the default rather than leaving it pointing at nothing.
       ...(defaultProfileId === id ? { browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID } : {}),
     });
   };
 
+  // A browser that is not on this machine is left out rather than listed as a
+  // dead row: there is nothing to act on, and the menu is a list of things you
+  // can import from. Every other unavailable reason stays, since each names a
+  // step the user can take.
+  const importableSources = (sources ?? []).filter(
+    (source) => source.unavailable !== "notInstalled",
+  );
+
+  // Refreshed without blanking the last result: the menu shows the cached list
+  // straight away so it doesn't reflow on open, and the source list is stable
+  // (names only) since choosing what to import happens in the wizard, not here.
+  const loadSources = () => {
+    if (!previewBridge) return;
+    void previewBridge
+      .listBrowserImportSources()
+      .then(setSources)
+      .catch(() => setSources((previous) => previous ?? []));
+  };
+
+  // Loaded once so the first open is instant instead of flashing a spinner.
+  useEffect(() => {
+    loadSources();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  // Runs one import for the wizard. A new profile is registered only once the
+  // import succeeds β€” the cookies land in its partition first β€” so a blocked
+  // attempt never leaves an empty profile behind.
+  const runWizardImport = async (
+    source: BrowserImportSource,
+    input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget },
+  ): Promise<ImportOutcome> => {
+    if (!environmentId || !previewBridge) return { kind: "blocked", reason: "sessionUnavailable" };
+    try {
+      const result = await previewBridge.importBrowserCookies({
+        environmentId,
+        sourceId: source.id,
+        sourceProfileDirectory: input.sourceProfileDirectory,
+        targetProfileId: input.target.profileId,
+      });
+      let targetName: string;
+      if (input.target.kind === "new") {
+        targetName = uniqueName(source.name);
+        // Registered only when something actually came over: an import that
+        // found no cookies should not leave a new, empty profile behind.
+        if (result.imported > 0) {
+          updateSettings({
+            browserProfiles: [
+              ...userProfiles,
+              { id: input.target.profileId, name: targetName, kind: "persistent" as const },
+            ],
+          });
C
cursorBotAug 17, 2026

Stale profiles wiped after import

High Severity

After importBrowserCookies returns, a new profile is registered by spreading a render-closed userProfiles into updateSettings, which replaces the whole browserProfiles list. The Keychain prompt and cookie write can outlive the wizard, so any profile created or removed in that window is overwritten when registration finally runs.

Reviewed by Cursor Bugbot for commit 05fe2c1c07af19afd480b84df6fc54f11a5ef45a. Configure here.

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· Earlier revision 757f0258 Β· right 50–58

Original diff
@@ -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;
+  }
+}

apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts Β· right 50–58

M
macroscopeappBotAug 16, 2026

BrowserImport/ChromiumCookies.ts:58

This failure is translated straight into the BrowserImport service error channel, but it is a plain Error (the only one in apps/desktop/src) whose only data is a nested failure object, and readMacKeychainPassword throws it without retaining the keychain error as cause. Consider declaring it with Schema.TaggedErrorClass and flat structural attributes, e.g. reason: Schema.Literals([...]) plus cause: Schema.optional(Schema.Defect()), with message derived from reason, and pass the caught keychain error through as cause.

No longer relevant as of 58530950a4e4ec0a1743ac0ee2826ceba41af020

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision 757f0258 Β· right 127–131

Original diff
@@ -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>()(
+  "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<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = async (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+): Promise<BrowserImportUnavailableReason | undefined> => {
+  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<ReadonlyArray<BrowserImportSource>> => {
+    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<ChromiumCookie> = 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",
+        }),

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· right 127–131

M
macroscopeappBotAug 16, 2026

BrowserImport/BrowserImport.ts:131

The ChromiumCookieReadError (and any unexpected defect) is reduced to a reason string and the original error is dropped, so the stack and keychain details are unrecoverable. Suggest keeping it as cause alongside the structural fields, as BrowserSession does.

suggestion
1 catch: (cause) =>
2 new BrowserImportFailedError({
3 sourceId: definition.id,
4 reason: cause instanceof ChromiumCookieReadError ? cause.failure.reason : "readFailed",
5 cause,
6 }),

No longer relevant as of 58530950a4e4ec0a1743ac0ee2826ceba41af020

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision 757f0258 Β· right 134–140

Original diff
@@ -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>()(
+  "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<ReadonlyArray<BrowserImportSource>>;
+    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<BrowserImportResult, BrowserImportFailedError>;
+  }
+>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
+
+const unavailableReason = async (
+  definition: BrowserImportSourceDefinition,
+  platform: NodeJS.Platform,
+): Promise<BrowserImportUnavailableReason | undefined> => {
+  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<ReadonlyArray<BrowserImportSource>> => {
+    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<ChromiumCookie> = 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" }),
+        ),
+      );

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· right 134–140

M
macroscopeappBotAug 16, 2026

BrowserImport/BrowserImport.ts:140

This discards a structured domain error (BrowserSessionPartitionDerivationError / BrowserSessionCreationError) and relabels it as "readFailed", which reports a cookie-read problem for a partition/session failure. Suggest preserving the upstream error as cause and using a distinct reason.

suggestion
1 const session = yield* browserSession.getSession(input.scope, input.persistent).pipe(
2 Effect.mapError(
3 (cause) =>
4 new BrowserImportFailedError({
5 sourceId: definition.id,
6 reason: "sessionUnavailable",
7 cause,
8 }),
9 ),
10 );

No longer relevant as of 58530950a4e4ec0a1743ac0ee2826ceba41af020

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· Earlier revision 757f0258 Β· right 35–45

Original diff
@@ -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>()(
+  "BrowserImportFailedError",
+  {
+    sourceId: Schema.String,
+    reason: Schema.String,
+  },
+) {
+  override get message(): string {
+    return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
+  }
+}

apps/desktop/src/preview/BrowserImport/BrowserImport.ts Β· right 35–45

M
macroscopeappBotAug 16, 2026

BrowserImport/BrowserImport.ts:45

reason: Schema.String is an unstructured attribute, and the error carries no cause, so the real failure (keychain error, session creation error, Electron rejection) is lost. Consider typing reason as a literal union and adding an optional cause; the renderer currently recovers the reason by substring-matching this message (IntegrationsSettings.tsx runImport), which is exactly the fragility a typed discriminator avoids.

diff
1 {
2- sourceId: Schema.String,
3- reason: Schema.String,
4+ sourceId: Schema.String,
5+ reason: Schema.Literals([
6+ "unknownSource",
7+ "notInstalled",
8+ "needsKeychainApproval",
9+ "keychainItemMissing",
10+ "browserRunning",
11+ "unsupportedPlatform",
12+ "readFailed",
13+ "sessionUnavailable",
14+ ]),
15+ cause: Schema.optional(Schema.Defect()),
16 },

The "unknown source" construction below would become "unknownSource".

No longer relevant as of 58530950a4e4ec0a1743ac0ee2826ceba41af020

apps/web/src/components/settings/BrowserImportWizard.tsx Β· Earlier revision 05fe2c1c Β· right 257–263

Original diff
@@ -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<WizardTargetProfile>;
+  /** 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<ImportOutcome>;
+  /** Re-checks the source's availability after the user quits the browser. */
+  readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
+  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<WizardStep>(() => initialWizardStep(initialSource));
+  const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
+    () => initialSource.profiles[0]?.directory ?? "",
+  );
+  const [target, setTarget] = useState<string>(
+    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 (
+    <Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
+      <DialogPopup className="max-w-md">
+        {step.step === "quit" ? (
+          <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
+        ) : step.step === "importing" ? (
+          <ImportingStep />
+        ) : step.step === "done" ? (
+          <DoneStep {...step} onClose={onClose} />
+        ) : step.step === "blocked" ? (
+          <BlockedStep
+            source={source}
+            reason={step.reason}
+            onClose={onClose}
+            onRetry={isRetryableReason(step.reason) ? runImport : undefined}
+          />
+        ) : (
+          <ConfigureStep
+            source={source}
+            targetProfiles={targetProfiles}
+            canCreateProfile={canCreateProfile}
+            sourceProfileDirectory={sourceProfileDirectory}
+            onSourceProfileChange={setSourceProfileDirectory}
+            target={target}
+            onTargetChange={setTarget}
+            onCancel={onClose}
+            onImport={runImport}
+          />
+        )}
+      </DialogPopup>
+    </Dialog>
+  );
+}
+
+function QuitStep({
+  source,
+  onCancel,
+  onRechecked,
+}: {
+  readonly source: BrowserImportSource;
+  readonly onCancel: () => void;
+  readonly onRechecked: () => void;
+}) {
+  return (
+    <>
+      <DialogHeader>
+        <DialogTitle>Quit {source.name} to import</DialogTitle>
+        <DialogDescription>
+          {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue.
+        </DialogDescription>
+      </DialogHeader>
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel}>
+          Cancel
+        </Button>
+        <Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
+      </DialogFooter>
+    </>
+  );
+}
+
+function ConfigureStep({
+  source,
+  targetProfiles,
+  canCreateProfile,
+  sourceProfileDirectory,
+  onSourceProfileChange,
+  target,
+  onTargetChange,
+  onCancel,
+  onImport,
+}: {
+  readonly source: BrowserImportSource;
+  readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
+  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 (
+    <>
+      <DialogHeader>
+        <DialogTitle>Import from {source.name}</DialogTitle>
+        <DialogDescription>
+          Copy {source.name}&rsquo;s cookies and logins into a browser profile here.
+        </DialogDescription>
+      </DialogHeader>
+      <DialogPanel className="space-y-5">
+        {hasMultipleSourceProfiles ? (
+          <section className="space-y-2">
+            <p className="text-sm font-medium text-foreground">Which {source.name} profile?</p>
+            <RadioGroup value={sourceProfileDirectory} onValueChange={onSourceProfileChange}>
+              {source.profiles.map((profile) => (
+                <label
+                  key={profile.directory}
+                  className="flex cursor-pointer items-center gap-2 text-sm text-foreground"
+                >
+                  <Radio value={profile.directory} />
+                  <span className="min-w-0 truncate">{profile.name}</span>
+                </label>
+              ))}
+            </RadioGroup>
+          </section>
+        ) : null}
+        <section className="space-y-2">
+          <p className="text-sm font-medium text-foreground">Import into</p>
+          <RadioGroup value={target} onValueChange={onTargetChange}>
+            {canCreateProfile ? (
+              <label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
+                <Radio value={NEW_TARGET_VALUE} />
+                <span>New profile</span>
+              </label>
+            ) : null}
+            {targetProfiles.map((profile) => (
+              <label
+                key={profile.id}
+                className="flex cursor-pointer items-center gap-2 text-sm text-foreground"
+              >
+                <Radio value={profile.id} />
+                <span className="min-w-0 truncate">{profile.name}</span>
+              </label>
+            ))}
+          </RadioGroup>
+        </section>
+      </DialogPanel>
+      <DialogFooter>
+        <Button variant="outline" onClick={onCancel}>
+          Cancel
+        </Button>
+        <Button onClick={onImport}>Import</Button>
+      </DialogFooter>
+    </>
+  );
+}
+
+function ImportingStep() {
+  return (
+    <DialogPanel className="flex items-center gap-3 py-6">
+      <Spinner className="size-4 text-muted-foreground" />
+      <span className="text-sm text-muted-foreground">Importing cookies…</span>
+    </DialogPanel>
+  );

apps/web/src/components/settings/BrowserImportWizard.tsx Β· right 257–263

M
macroscopeappBotAug 17, 2026

ImportingStep renders a bare DialogPanel, so while it is on screen the popup has no DialogTitle. Base UI derives the dialog's accessible name from that title, so the dialog goes unnamed for assistive tech mid-flow, and visually the popup collapses to a spinner-only box between the configure and done screens. Every other step here supplies a header.

suggestion
1function ImportingStep() {
2 return (
3 <>
4 <DialogHeader>
5 <DialogTitle>Importing cookies…</DialogTitle>
6 </DialogHeader>
7 <DialogPanel className="flex items-center gap-3 py-6">
8 <Spinner className="size-4 text-muted-foreground" />
9 <span className="text-sm text-muted-foreground">This can take a moment.</span>
10 </DialogPanel>
11 </>
12 );
13}

Posted via Macroscope β€” UI Consistency

apps/web/src/components/settings/BrowserImportWizard.tsx Β· Earlier revision ef25cffc Β· right 114–114

Original diff
@@ -0,0 +1,428 @@
+import type { BrowserImportSource } from "@t3tools/contracts";
+import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
+import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
+import { useRef, useState } from "react";
+
+import { cn, randomUUID } from "~/lib/utils";
+
+import { Button } from "../ui/button";
+import {
+  Dialog,
+  DialogClose,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogPanel,
+  DialogPopup,
+  DialogTitle,
+} from "../ui/dialog";
+import { Spinner } from "../ui/spinner";
+import {
+  initialWizardStep,
+  initialTargetSelection,
+  canCloseWizard,
+  isRetryableReason,
+  formatSkippedDomains,
+  outcomeToStep,
+  refreshedSourceProfileDirectory,
+  refreshedSourceStep,
+  resolveWizardTarget,
+  type ImportOutcome,
+  type WizardTarget,
+  type WizardTargetProfile,
+  type WizardTargetSelection,
+  type WizardStep,
+} from "./browserImportWizard.logic";
+
+export type { WizardTarget } from "./browserImportWizard.logic";
+
+interface BrowserImportWizardProps {
+  readonly source: BrowserImportSource;
+  /** Captured when the wizard opens so destination copy and writes stay stable. */
+  readonly destinationEnvironmentName: string;
+  /** Existing profiles the import can go into. Incognito is excluded upstream. */
+  readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
+  /** 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<ImportOutcome>;
+  /** Re-checks the source's availability after the user quits the browser. */
+  readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
+  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,
+  destinationEnvironmentName,
+  targetProfiles,
+  canCreateProfile,
+  onImport,
+  onRefreshSource,
+  onClose,
+}: BrowserImportWizardProps) {
+  const [source, setSource] = useState(initialSource);
+  const [step, setStep] = useState<WizardStep>(() => initialWizardStep(initialSource));
+  const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
+    () => initialSource.profiles[0]?.directory ?? "",
+  );
+  const [target, setTarget] = useState<WizardTargetSelection>(() =>
+    initialTargetSelection(canCreateProfile, targetProfiles),
+  );
+  // Stable across retries so 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 = resolveWizardTarget(target, newProfileId.current, targetProfiles);
+    void onImport({ sourceProfileDirectory, target: chosen })
+      .then((outcome) => setStep(outcomeToStep(outcome)))
+      .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
+  };
+
+  const recheckAfterQuit = () => {
+    setStep({ step: "checking" });
+    void onRefreshSource()
+      .then((refreshed) => {
+        if (refreshed) {
+          setSource(refreshed);
+          setSourceProfileDirectory((current) =>
+            refreshedSourceProfileDirectory(current, refreshed),
+          );
+        }
+        setStep(refreshedSourceStep(refreshed));
+      })
+      .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
+  };
+
+  return (
+    <Dialog open onOpenChange={(open) => (open || !canCloseWizard(step) ? undefined : onClose())}>
+      <DialogPopup className="max-w-lg">

apps/web/src/components/settings/BrowserImportWizard.tsx Β· right 114–114

M
macroscopeappBotAug 29, 2026

DialogPopup renders its close button by default, but onOpenChange refuses to close while step.step === "importing" β€” so during the import the X is visible, focusable and enabled, and clicking it (or pressing Escape) silently does nothing. The repo's other non-dismissible-while-busy dialog ties the two together instead (RelayClientInstallDialog uses showCloseButton={isConfirming}, and SshPasswordPromptDialog/RenameEntryDialog pass false), so the affordance is only shown when it works.

suggestion
1 <DialogPopup className="max-w-lg" showCloseButton={canCloseWizard(step)}>

Posted via Macroscope β€” UI Consistency

apps/desktop/src/preview/BrowserImport/Sources.ts Β· Earlier revision 40385efa Β· right 111

Original diff
@@ -0,0 +1,185 @@
+/**
+ * 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 "<Name> Safe Storage" / "<Name>".
+ *
+ * @module BrowserImportSources
+ */
+import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
+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<NodeJS.Platform>;
+  readonly userDataDirectory: (paths: SourcePaths) => string;
+  readonly keychainService: string;
+  readonly keychainAccount: string;
+}
+
+export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
+  {
+    id: "helium",
+    name: "Helium",
+    platforms: ["darwin"],
+    userDataDirectory: ({ path, home }) =>
+      path.join(home, "Library", "Application Support", "net.imput.helium"),
+    keychainService: "Helium Storage Key",
+    keychainAccount: "Helium",
+  },
+];
+
+export const cookieDatabasePath = (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+  profileDirectory: string,
+): string => paths.path.join(definition.userDataDirectory(paths), profileDirectory, "Cookies");
+
+/** Shape of the slice of Chromium's `Local State` that names its profiles. */
+const LocalState = Schema.Struct({
+  profile: Schema.optional(
+    Schema.Struct({
+      info_cache: Schema.optional(
+        Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
+      ),
+    }),
+  ),
+});
+const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
+
+/** A single plain path segment: no separators, no `.`/`..`, not empty. */
+const isSafeProfileDirectory = (directory: string): boolean =>
+  directory.length > 0 &&
+  directory !== "." &&
+  directory !== ".." &&
+  !/[\\/]/.test(directory) &&
+  !directory.includes("\u0000");
+
+/**
+ * Profiles the source browser knows about, read from its `Local State`.
+ *
+ * When that file is missing, unreadable or malformed, the user-data directory
+ * is scanned for directories that hold a cookie database. Assuming `Default`
+ * instead would report a browser whose cookies live in `Profile 1` as having
+ * nothing to import β€” and it is then left out of the menu entirely.
+ */
+export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
+  definition: BrowserImportSourceDefinition,
+  paths: SourcePaths,
+) {
+  const fileSystem = yield* FileSystem.FileSystem;
+  const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
+
+  const root = definition.userDataDirectory(paths);
+  const declared = yield* fileSystem.readFileString(localStatePath).pipe(
+    Effect.flatMap(decodeLocalState),
+    Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
+    // The keys are directory names from the browser's own metadata file, which
+    // is writable by anything running as the user. Anything but a single plain
+    // segment is dropped: `..` or a path separator would otherwise be handed
+    // to `cookieDatabasePath` and read a database outside the user-data
+    // directory.
+    Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))),
+    Effect.map((entries) =>
+      entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })),
+    ),
+    Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
+  );
+  if (declared.length > 0) return declared;
C
cursorBotAug 17, 2026

Stale Local State profiles listed

Medium Severity

When Local State is present, listSourceProfiles returns every declared profile without checking for a Cookies database. The filesystem scan fallback already requires a cookie DB. Profiles that remain in metadata after their directory was removed still appear in the import menu and then fail with a generic readFailed.

Reviewed by Cursor Bugbot for commit 40385efaf3d51c5a7b12f40de484d364c39b63d0. Configure here.