authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 16:30:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 16:30:20-07:00
log39449e53e31a56103192aa7905e89fc92c977a4a
tree38d0bb667e7f09198d90184f2e6a452a967c6d57
parente01c153c18a94a2aa33df7115c6cfee1eb09880f
signature Signed by PGP key B5690EEEBB952194

feat(desktop): import browser cookies into a profile (#7255)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

27 files changed, 3913 insertions(+), 183 deletions(-)

apps/desktop/package.json+1
...@@ -15,6 +15,7 @@...@@ -15,6 +15,7 @@
15 "@clerk/electron": "catalog:",15 "@clerk/electron": "catalog:",
16 "@clerk/electron-passkeys": "catalog:",16 "@clerk/electron-passkeys": "catalog:",
17 "@effect/platform-node": "catalog:",17 "@effect/platform-node": "catalog:",
18 "@napi-rs/keyring": "^1.3.0",
18 "@t3tools/client-runtime": "workspace:*",19 "@t3tools/client-runtime": "workspace:*",
19 "@t3tools/contracts": "workspace:*",20 "@t3tools/contracts": "workspace:*",
20 "@t3tools/shared": "workspace:*",21 "@t3tools/shared": "workspace:*",
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -103,4 +103,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -103,4 +103,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
103 for (const previewMethod of PreviewIpc.methods) {103 for (const previewMethod of PreviewIpc.methods) {
104 yield* ipc.handle(previewMethod);104 yield* ipc.handle(previewMethod);
105 }105 }
106 yield* ipc.handle(PreviewIpc.listBrowserImportSources);
107 yield* ipc.handle(PreviewIpc.importBrowserCookies);
106});108});
apps/desktop/src/ipc/channels.ts+2
...@@ -64,6 +64,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";...@@ -64,6 +64,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
64export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";64export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
65export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";65export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
66export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";66export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
67export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources";
68export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies";
67export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";69export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";
68export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";70export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
69export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";71export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
apps/desktop/src/ipc/methods/preview.test.ts+33
...@@ -12,6 +12,7 @@ import * as Schema from "effect/Schema";...@@ -12,6 +12,7 @@ import * as Schema from "effect/Schema";
12import { beforeEach, describe, expect, it, vi } from "vite-plus/test";12import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1313
14import * as PreviewManager from "../../preview/Manager.ts";14import * as PreviewManager from "../../preview/Manager.ts";
15import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
15import * as PreviewIpc from "./preview.ts";16import * as PreviewIpc from "./preview.ts";
1617
17const { fromPartition } = vi.hoisted(() => ({18const { fromPartition } = vi.hoisted(() => ({
...@@ -80,6 +81,38 @@ describe("preview IPC methods", () => {...@@ -80,6 +81,38 @@ describe("preview IPC methods", () => {
80 });81 });
81 });82 });
8283
84 effectIt.effect("targets imports at the same partition tuple as the renderer", () => {
85 const received: Array<Parameters<BrowserImport.BrowserImport["Service"]["importCookies"]>[0]> =
86 [];
87 const browserImport = BrowserImport.BrowserImport.of({
88 listSources: Effect.succeed([]),
89 importCookies: (input) =>
90 Effect.sync(() => {
91 received.push(input);
92 return { imported: 0, skipped: 0, skippedDomains: [] };
93 }),
94 });
95 const request = (environmentId: string, targetProfileId: string) =>
96 PreviewIpc.importBrowserCookies.handler({
97 environmentId,
98 sourceId: "helium",
99 sourceProfileDirectory: "Default",
100 targetProfileId,
101 });
102
103 return Effect.gen(function* () {
104 yield* request("a", "b");
105 yield* request("a::b", DEFAULT_BROWSER_PROFILE_ID);
106
107 expect(received[0]).toMatchObject(PreviewIpc.resolvePartitionScope("a", "b"));
108 expect(received[1]).toMatchObject(
109 PreviewIpc.resolvePartitionScope("a::b", DEFAULT_BROWSER_PROFILE_ID),
110 );
111 expect(received[0]?.namespace).toBe("profile");
112 expect(received[1]?.namespace).toBeUndefined();
113 }).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport));
114 });
115
83 effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>116 effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
84 Effect.map(117 Effect.map(
85 PreviewIpc.registerWebview118 PreviewIpc.registerWebview
apps/desktop/src/ipc/methods/preview.ts+43
...@@ -16,7 +16,10 @@ import {...@@ -16,7 +16,10 @@ import {
16 DesktopPreviewScreenshotArtifactSchema,16 DesktopPreviewScreenshotArtifactSchema,
17 DesktopPreviewSetAudioMutedInputSchema,17 DesktopPreviewSetAudioMutedInputSchema,
18 DesktopPreviewSetColorSchemeInputSchema,18 DesktopPreviewSetColorSchemeInputSchema,
19 BrowserImportResult,
20 BrowserImportSource,
19 DesktopPreviewClearDataInputSchema,21 DesktopPreviewClearDataInputSchema,
22 DesktopPreviewImportCookiesInputSchema,
20 DesktopPreviewCreateTabInputSchema,23 DesktopPreviewCreateTabInputSchema,
21 DesktopPreviewTabInputSchema,24 DesktopPreviewTabInputSchema,
22 DesktopPreviewWebviewConfigSchema,25 DesktopPreviewWebviewConfigSchema,
...@@ -30,6 +33,7 @@ import * as Schema from "effect/Schema";...@@ -30,6 +33,7 @@ import * as Schema from "effect/Schema";
30import * as NodeURL from "node:url";33import * as NodeURL from "node:url";
3134
32import * as ElectronWindow from "../../electron/ElectronWindow.ts";35import * as ElectronWindow from "../../electron/ElectronWindow.ts";
36import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
33import * as PreviewManager from "../../preview/Manager.ts";37import * as PreviewManager from "../../preview/Manager.ts";
34import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";38import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
35import * as IpcChannels from "../channels.ts";39import * as IpcChannels from "../channels.ts";
...@@ -284,6 +288,45 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({...@@ -284,6 +288,45 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({
284 }),288 }),
285});289});
286290
291/**
292 * Registered separately from `methods`: these carry `BrowserImport` in their
293 * context and their own failure type, so they do not unify with the
294 * manager-backed handlers the shared loop iterates.
295 */
296export const listBrowserImportSources = DesktopIpc.makeIpcMethod({
297 channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL,
298 payload: Schema.Void,
299 result: Schema.Array(BrowserImportSource),
300 handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () {
301 const browserImport = yield* BrowserImport.BrowserImport;
302 return yield* browserImport.listSources;
303 }),
304});
305
306export const importBrowserCookies = DesktopIpc.makeIpcMethod({
307 channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL,
308 payload: DesktopPreviewImportCookiesInputSchema,
309 result: BrowserImportResult,
310 handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({
311 environmentId,
312 ...importInput
313 }) {
314 const browserImport = yield* BrowserImport.BrowserImport;
315 // Derived in main from the same helper the webview config uses, so cookies
316 // land in exactly the partition the profile's tabs attach to.
317 const { scope, persistent, namespace } = resolvePartitionScope(
318 environmentId,
319 importInput.targetProfileId,
320 );
321 return yield* browserImport.importCookies({
322 input: importInput,
323 scope,
324 persistent,
325 ...(namespace === undefined ? {} : { namespace }),
326 });
327 }),
328});
329
287export const setAnnotationTheme = DesktopIpc.makeIpcMethod({330export const setAnnotationTheme = DesktopIpc.makeIpcMethod({
288 channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL,331 channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL,
289 payload: DesktopPreviewAnnotationThemeInputSchema,332 payload: DesktopPreviewAnnotationThemeInputSchema,
apps/desktop/src/main.ts+4
...@@ -58,6 +58,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";...@@ -58,6 +58,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
58import * as DesktopState from "./app/DesktopState.ts";58import * as DesktopState from "./app/DesktopState.ts";
59import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";59import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
60import * as DesktopUpdates from "./updates/DesktopUpdates.ts";60import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
61import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
61import * as BrowserSession from "./preview/BrowserSession.ts";62import * as BrowserSession from "./preview/BrowserSession.ts";
62import * as PreviewManager from "./preview/Manager.ts";63import * as PreviewManager from "./preview/Manager.ts";
63import * as DesktopWindow from "./window/DesktopWindow.ts";64import * as DesktopWindow from "./window/DesktopWindow.ts";
...@@ -149,6 +150,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(...@@ -149,6 +150,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
149);150);
150151
151const desktopPreviewLayer = PreviewManager.layer.pipe(152const desktopPreviewLayer = PreviewManager.layer.pipe(
153 // Merged rather than provided so the IPC handlers can reach the import
154 // service alongside the manager; both sit on the same BrowserSession.
155 Layer.provideMerge(BrowserImport.layer),
152 Layer.provideMerge(BrowserSession.layer),156 Layer.provideMerge(BrowserSession.layer),
153 Layer.provideMerge(desktopFoundationLayer),157 Layer.provideMerge(desktopFoundationLayer),
154);158);
apps/desktop/src/preload.ts+3
...@@ -223,6 +223,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -223,6 +223,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
223 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),223 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
224 openDevTools: (tabId) =>224 openDevTools: (tabId) =>
225 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),225 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
226 listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL),
227 importBrowserCookies: (input) =>
228 ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input),
226 clearCookies: (environmentId, profileId) =>229 clearCookies: (environmentId, profileId) =>
227 ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),230 ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
228 clearCache: (environmentId, profileId) =>231 clearCache: (environmentId, profileId) =>
apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts created+211
...@@ -0,0 +1,211 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import {
4 HostProcessEnvironment,
5 HostProcessExecutablePath,
6 HostProcessPlatform,
7} from "@t3tools/shared/hostProcess";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Fiber from "effect/Fiber";
11import * as Layer from "effect/Layer";
12import * as Ref from "effect/Ref";
13
14import * as BrowserSession from "../BrowserSession.ts";
15import * as BrowserImport from "./BrowserImport.ts";
16import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";
17
18const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
19
20const cookie = {
21 url: "https://rejected.example/path",
22 name: "session",
23 value: "value",
24 domain: undefined,
25 path: "/",
26 secure: true,
27 httpOnly: true,
28 expirationDate: undefined,
29 sameSite: "lax" as const,
30};
31
32/**
33 * Dies if the import reaches session work: every case here covers a request
34 * that must be rejected before a cookie is read or written.
35 */
36const rejectedBeforeSession = Layer.succeed(
37 BrowserSession.BrowserSession,
38 BrowserSession.BrowserSession.of({
39 getPartition: () => Effect.die("getPartition must not be reached"),
40 isPartition: () => false,
41 getSession: () => Effect.die("getSession must not be reached"),
42 clearCookies: () => Effect.die("clearCookies must not be reached"),
43 clearCache: () => Effect.die("clearCache must not be reached"),
44 }),
45);
46
47/**
48 * Builds the service against a scratch home containing an installed, closed
49 * copy of the source browser.
50 */
51const withImporter = Effect.fnUntraced(function* () {
52 const fileSystem = yield* FileSystem.FileSystem;
53 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
54 const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
55 const paths = yield* sourcePaths.pipe(
56 Effect.provideService(HostProcessEnvironment, { HOME: home }),
57 );
58 yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {
59 recursive: true,
60 });
61 // The cookie database is what marks a source as installed, so a fixture
62 // without one is reported as absent before any other check runs.
63 yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db");
64
65 const importer = yield* BrowserImport.BrowserImport.pipe(
66 Effect.provide(
67 BrowserImport.layer.pipe(
68 Layer.provide(rejectedBeforeSession),
69 Layer.provide(environment),
70 Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")),
71 Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")),
72 Layer.provide(NodeServices.layer),
73 ),
74 ),
75 );
76 return { importer, home, paths };
77});
78
79describe("BrowserImport.importCookies", () => {
80 it.effect("rejects a source profile the browser never reported", () =>
81 Effect.gen(function* () {
82 const fileSystem = yield* FileSystem.FileSystem;
83 const { importer, home } = yield* withImporter();
84
85 // A cookie database reachable on disk but outside the browser's
86 // user-data directory — the payoff a traversal would be after.
87 yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true });
88 yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db");
89
90 const error = yield* importer
91 .importCookies({
92 input: {
93 sourceId: "helium",
94 sourceProfileDirectory: "../../../../secrets",
95 targetProfileId: "default",
96 },
97 scope: "persist:t3code-preview-test",
98 persistent: true,
99 })
100 .pipe(Effect.flip);
101
102 assert.instanceOf(error, BrowserImport.BrowserImportFailedError);
103 assert.equal(error.reason, "unknownSourceProfile");
104 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
105 );
106
107 it.effect("refuses to import while the source browser holds its profile", () =>
108 Effect.gen(function* () {
109 const fileSystem = yield* FileSystem.FileSystem;
110 const { importer, paths } = yield* withImporter();
111 // The lock Chromium leaves while it is running, dangling target and
112 // all. This must stop the import before it ever asks the keychain.
113 yield* fileSystem.symlink(
114 "host-that-does-not-exist-1234",
115 `${helium.userDataDirectory(paths)}/SingletonLock`,
116 );
117
118 const error = yield* importer
119 .importCookies({
120 input: {
121 sourceId: "helium",
122 sourceProfileDirectory: "Default",
123 targetProfileId: "default",
124 },
125 scope: "persist:t3code-preview-test",
126 persistent: true,
127 })
128 .pipe(Effect.flip);
129
130 assert.equal(error.reason, "browserRunning");
131 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
132 );
133});
134
135describe("BrowserImport.writeCookies", () => {
136 it.effect("counts a rejected cookie and its domain as skipped", () =>
137 Effect.gen(function* () {
138 let flushes = 0;
139 const result = yield* BrowserImport.writeCookies(
140 {
141 cookies: {
142 set: () => Promise.reject(new Error("fixture rejection")),
143 flushStore: () => {
144 flushes += 1;
145 return Promise.resolve();
146 },
147 },
148 },
149 { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] },
150 );
151
152 assert.deepEqual(result, {
153 imported: 0,
154 skipped: 1,
155 skippedDomains: ["rejected.example"],
156 });
157 // Nothing landed, so there is nothing to persist.
158 assert.equal(flushes, 0);
159 }),
160 );
161
162 it.effect("flushes the store after writing, and reports success if the flush fails", () =>
163 Effect.gen(function* () {
164 const events: Array<string> = [];
165 const result = yield* BrowserImport.writeCookies(
166 {
167 cookies: {
168 set: () => {
169 events.push("set");
170 return Promise.resolve();
171 },
172 flushStore: () => {
173 events.push("flush");
174 return Promise.reject(new Error("fixture flush failure"));
175 },
176 },
177 },
178 { cookies: [cookie, cookie], undecryptable: 0, undecryptableHosts: [] },
179 );
180
181 // One flush after every write, not one per cookie; the cookies are in
182 // the session either way, so a failed flush is not a failed import.
183 assert.deepEqual(events, ["set", "set", "flush"]);
184 assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] });
185 }),
186 );
187
188 it.effect("propagates interruption while writing a cookie", () =>
189 Effect.gen(function* () {
190 const write = BrowserImport.writeCookies(
191 {
192 cookies: {
193 set: () => new Promise<void>(() => {}),
194 flushStore: () => Promise.resolve(),
195 },
196 },
197 { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] },
198 );
199
200 const interrupted = yield* Ref.make(false);
201 const fiber = yield* write.pipe(
202 Effect.onInterrupt(() => Ref.set(interrupted, true)),
203 Effect.forkChild,
204 );
205 yield* Effect.yieldNow;
206 yield* Fiber.interrupt(fiber);
207
208 assert.isTrue(yield* Ref.get(interrupted));
209 }),
210 );
211});
apps/desktop/src/preview/BrowserImport/BrowserImport.ts created+270
...@@ -0,0 +1,270 @@
1/**
2 * Browser import service - lists importable sources and writes their cookies
3 * into a T3 Code browser profile's Electron partition.
4 *
5 * @module BrowserImport
6 */
7import type {
8 BrowserImportInput,
9 BrowserImportResult,
10 BrowserImportSource,
11 BrowserImportUnavailableReason,
12} from "@t3tools/contracts";
13import { BrowserImportFailureReason } from "@t3tools/contracts";
14import * as Context from "effect/Context";
15import type { Session } from "electron";
16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";
19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21
22import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
23
24import * as BrowserSession from "../BrowserSession.ts";
25import { readChromiumCookies, type CookieReadResult } from "./ChromiumCookies.ts";
26import {
27 BROWSER_IMPORT_SOURCES,
28 resolveCookieDatabase,
29 isSourceInstalled,
30 isSourceRunning,
31 listSourceProfiles,
32 sourcePaths,
33 type BrowserImportSourceDefinition,
34 type SourcePaths,
35} from "./Sources.ts";
36
37export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
38 "BrowserImportFailedError",
39 {
40 sourceId: Schema.String,
41 reason: BrowserImportFailureReason,
42 /** Kept for the log; the user only ever sees the reason's copy. */
43 cause: Schema.optional(Schema.Defect()),
44 },
45) {
46 // The reason token is part of the message on purpose: IPC flattens the error
47 // to its message, and the renderer maps that token back to user-facing copy.
48 override get message(): string {
49 return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
50 }
51}
52
53export class BrowserCookieWriteError extends Schema.TaggedErrorClass<BrowserCookieWriteError>()(
54 "BrowserCookieWriteError",
55 {
56 url: Schema.String,
57 name: Schema.String,
58 cause: Schema.Defect(),
59 },
60) {
61 override get message(): string {
62 return `Could not write imported cookie ${this.name} for ${this.url}.`;
63 }
64}
65
66export class BrowserImport extends Context.Service<
67 BrowserImport,
68 {
69 readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
70 readonly importCookies: (input: {
71 readonly input: BrowserImportInput;
72 /** Partition scope of the target profile, derived by the caller in main. */
73 readonly scope: string;
74 readonly persistent: boolean;
75 readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace;
76 }) => Effect.Effect<BrowserImportResult, BrowserImportFailedError>;
77 }
78>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
79
80const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
81 definition: BrowserImportSourceDefinition,
82 platform: NodeJS.Platform,
83 paths: SourcePaths,
84): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
85 if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
86 if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
87 if (yield* isSourceRunning(definition, paths)) return "browserRunning";
88 return undefined;
89});
90
91/** The host a constructed cookie URL points at, for naming what was skipped. */
92const cookieHost = (url: string): string => {
93 try {
94 return new URL(url).hostname;
95 } catch {
96 return url;
97 }
98};
99
100export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* (
101 session: { readonly cookies: Pick<Session["cookies"], "set" | "flushStore"> },
102 read: CookieReadResult,
103) {
104 let imported = 0;
105 let skipped = read.undecryptable;
106 const skippedDomains = new Set(read.undecryptableHosts);
107 for (const cookie of read.cookies) {
108 const written = yield* Effect.tryPromise({
109 try: () =>
110 session.cookies.set({
111 url: cookie.url,
112 name: cookie.name,
113 value: cookie.value,
114 // Omitted for host-only cookies: Electron reads any `domain` as a
115 // domain cookie and re-adds the leading dot, widening its scope.
116 ...(cookie.domain === undefined ? {} : { domain: cookie.domain }),
117 path: cookie.path,
118 secure: cookie.secure,
119 httpOnly: cookie.httpOnly,
120 sameSite: cookie.sameSite,
121 ...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }),
122 }),
123 catch: (cause) => new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }),
124 }).pipe(
125 Effect.as(true),
126 Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })),
127 Effect.catchTags({ BrowserCookieWriteError: () => Effect.succeed(false) }),
128 );
129 if (written) {
130 imported += 1;
131 } else {
132 skipped += 1;
133 skippedDomains.add(cookieHost(cookie.url));
134 }
135 }
136 // `set` resolves once the cookie is in memory; Chromium writes the store to
137 // disk on its own schedule. Flush before reporting "Done", so a crash right
138 // after does not lose what the user was just told was imported. A failed
139 // flush is logged rather than surfaced: the cookies are still in the
140 // session and land on disk at the next scheduled write.
141 if (imported > 0) {
142 yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe(
143 Effect.tapError((error) =>
144 Effect.logWarning("Imported cookies could not be flushed to disk", { cause: error.cause }),
145 ),
146 Effect.ignore,
147 );
148 }
149 return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) };
150});
151
152export const make = Effect.gen(function* BrowserImportMake() {
153 const browserSession = yield* BrowserSession.BrowserSession;
154 const platform = yield* HostProcessPlatform;
155 const executablePath = yield* HostProcessExecutablePath;
156 // Captured here so the service's methods stay free of a requirements
157 // channel: the layer is built where NodeServices is already in scope.
158 const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
159 const paths = yield* sourcePaths;
160
161 const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
162 BROWSER_IMPORT_SOURCES,
163 Effect.fnUntraced(function* (definition) {
164 const unavailable = yield* unavailableReason(definition, platform, paths);
165 return {
166 id: definition.id,
167 name: definition.name,
168 // Listing profiles touches the source's own files, so skip it when the
169 // source is unusable anyway.
170 profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
171 ...(unavailable === undefined ? {} : { unavailable }),
172 } satisfies BrowserImportSource;
173 }),
174 ).pipe(Effect.provide(platformServices));
175
176 const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: {
177 readonly input: BrowserImportInput;
178 readonly scope: string;
179 readonly persistent: boolean;
180 readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace;
181 }) {
182 const definition = BROWSER_IMPORT_SOURCES.find(
183 (candidate) => candidate.id === input.input.sourceId,
184 );
185 if (!definition) {
186 return yield* new BrowserImportFailedError({
187 sourceId: input.input.sourceId,
188 reason: "unknownSource",
189 });
190 }
191
192 const blocked = yield* unavailableReason(definition, platform, paths).pipe(
193 Effect.provide(platformServices),
194 );
195 if (blocked !== undefined) {
196 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
197 }
198
199 // macOS attributes the Keychain prompt and the resulting ACL grant to the
200 // executable that asks, so record which one that was — in a packaged build
201 // it is the signed app, in dev whatever binary hosts the main process.
202 yield* Effect.logInfo("Reading browser cookie key from the keychain", {
203 sourceId: definition.id,
204 executablePath,
205 });
206
207 // The profile directory arrives over IPC, so it is only honoured when the
208 // source itself reported it. Forwarding it unchecked would let `..`
209 // segments walk out of the browser's user-data directory and read any
210 // cookie database reachable on disk.
211 const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
212 Effect.provide(platformServices),
213 );
214 const requestedProfile = sourceProfiles.find(
215 (profile) => profile.directory === input.input.sourceProfileDirectory,
216 );
217 if (requestedProfile === undefined) {
218 return yield* new BrowserImportFailedError({
219 sourceId: definition.id,
220 reason: "unknownSourceProfile",
221 });
222 }
223
224 // The profile was listed against a database moments ago; resolve it again
225 // rather than assume a path, since the live jar may sit under `Network/`.
226 const databasePath = yield* resolveCookieDatabase(
227 definition,
228 paths,
229 requestedProfile.directory,
230 ).pipe(Effect.provide(platformServices));
231 if (databasePath === undefined) {
232 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" });
233 }
234
235 const read = yield* readChromiumCookies({
236 cookieDatabasePath: databasePath,
237 keychainService: definition.keychainService,
238 keychainAccount: definition.keychainAccount,
239 platform,
240 }).pipe(
241 Effect.scoped,
242 Effect.provide(platformServices),
243 Effect.mapError(
244 (cause) =>
245 new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
246 ),
247 );
248
249 const session = yield* browserSession
250 .getSession(input.scope, input.persistent, input.namespace)
251 .pipe(
252 Effect.mapError(
253 (cause) =>
254 new BrowserImportFailedError({
255 sourceId: definition.id,
256 reason: "sessionUnavailable",
257 cause,
258 }),
259 ),
260 );
261
262 // Written one at a time rather than in parallel: Chromium's cookie store
263 // serialises writes anyway, and a rejected cookie should only cost itself.
264 return yield* writeCookies(session, read);
265 });
266
267 return BrowserImport.of({ listSources, importCookies });
268});
269
270export const layer = Layer.effect(BrowserImport, make);
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts created+379
...@@ -0,0 +1,379 @@
1// @effect-diagnostics nodeBuiltinImport:off - Encrypts a fixture with the same
2// OSCrypt primitive as Chromium.
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { describe, expect, it } from "@effect/vitest";
5import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
6import * as NodeCrypto from "node:crypto";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Scope from "effect/Scope";
11import * as SqlClient from "effect/unstable/sql/SqlClient";
12
13import {
14 cookieScope,
15 readChromiumCookieDatabase,
16 snapshotCookieDatabase,
17} from "./ChromiumCookies.ts";
18
19const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
20 const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
21 return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]);
22};
23
24const runNode = <A, E>(
25 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
26) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
27
28describe("snapshotCookieDatabase", () => {
29 it.effect("includes committed WAL data in one consistent database", () =>
30 runNode(
31 Effect.gen(function* () {
32 const fileSystem = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
35 prefix: "t3code-cookie-source-",
36 });
37 const source = path.join(sourceDirectory, "Cookies");
38
39 const snapshot = yield* Effect.gen(function* () {
40 const sql = yield* SqlClient.SqlClient;
41 yield* sql`PRAGMA journal_mode = WAL`;
42 yield* sql`PRAGMA wal_autocheckpoint = 0`;
43 yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
44 yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`;
45
46 expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true);
47 return yield* snapshotCookieDatabase(source);
48 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));
49
50 const rows = yield* Effect.gen(function* () {
51 const sql = yield* SqlClient.SqlClient;
52 return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`;
53 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true })));
54
55 expect(rows).toEqual([{ name: "committed-in-wal" }]);
56 }),
57 ),
58 );
59
60 it.effect("propagates snapshot failures and removes its temporary directory", () =>
61 runNode(
62 Effect.gen(function* () {
63 const fileSystem = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
66 prefix: "t3code-cookie-invalid-source-",
67 });
68 const source = path.join(sourceDirectory, "Cookies");
69 yield* fileSystem.writeFileString(source, "not a sqlite database");
70
71 const prefix = `t3code-cookie-failed-${process.pid}-`;
72 const error = yield* snapshotCookieDatabase(source, prefix).pipe(
73 Effect.scoped,
74 Effect.flip,
75 );
76
77 expect(error._tag).toBe("SqlError");
78 const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory));
79 expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false);
80 }),
81 ),
82 );
83
84 it.effect("removes a successful snapshot when its scope closes", () =>
85 runNode(
86 Effect.gen(function* () {
87 const fileSystem = yield* FileSystem.FileSystem;
88 const path = yield* Path.Path;
89 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
90 prefix: "t3code-cookie-cleanup-source-",
91 });
92 const source = path.join(sourceDirectory, "Cookies");
93
94 yield* Effect.gen(function* () {
95 const sql = yield* SqlClient.SqlClient;
96 yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
97 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));
98
99 const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped);
100 expect(yield* fileSystem.exists(snapshot)).toBe(false);
101 }),
102 ),
103 );
104});
105
106describe("cookieScope", () => {
107 it("keeps a host-only cookie host-only", () => {
108 // Chromium stores a host-only cookie without a leading dot. Passing any
109 // `domain` to Electron makes it a domain cookie and re-adds the dot, which
110 // would expose the cookie to every subdomain it was never scoped to.
111 expect(cookieScope("example.test", "/", true)).toEqual({
112 url: "https://example.test/",
113 domain: undefined,
114 });
115 });
116
117 it("preserves a domain cookie's leading dot", () => {
118 expect(cookieScope(".example.test", "/app", true)).toEqual({
119 url: "https://example.test/app",
120 domain: ".example.test",
121 });
122 });
123
124 it("matches the scheme to the secure flag", () => {
125 expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/");
126 });
127
128 it("brackets bare IPv6 hosts without duplicating existing brackets", () => {
129 expect(cookieScope("::1", "/", false)).toEqual({
130 url: "http://[::1]/",
131 domain: undefined,
132 });
133 expect(cookieScope("[::1]", "/app", true)).toEqual({
134 url: "https://[::1]/app",
135 domain: undefined,
136 });
137 });
138});
139
140describe("readChromiumCookieDatabase", () => {
141 it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () =>
142 Effect.gen(function* () {
143 const fileSystem = yield* FileSystem.FileSystem;
144 const directory = yield* fileSystem.makeTempDirectoryScoped({
145 prefix: "t3code-chromium-cookies-",
146 });
147 const filename = `${directory}/Cookies`;
148 const key = Buffer.from("0123456789abcdef");
149
150 yield* Effect.gen(function* () {
151 const sql = yield* SqlClient.SqlClient;
152 yield* sql`create table meta (key text primary key, value text not null)`;
153 yield* sql`insert into meta values ('version', 23)`;
154 yield* sql`
155 create table cookies (
156 host_key text not null,
157 name text not null,
158 value text not null,
159 encrypted_value blob not null,
160 path text not null,
161 expires_utc integer not null,
162 is_secure integer not null,
163 is_httponly integer not null,
164 samesite integer not null,
165 top_frame_site_key text not null default ''
166 )
167 `;
168 yield* sql`
169 insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
170 ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1)
171 `;
172 yield* sql`
173 insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
174 ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2)
175 `;
176 yield* sql`
177 insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
178 ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0)
179 `;
180 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
181
182 const result = yield* readChromiumCookieDatabase(filename, key, "darwin");
183
184 expect(result.undecryptable).toBe(0);
185 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
186 { name: "plain", value: "stored plaintext" },
187 { name: "encrypted", value: "stored encrypted" },
188 { name: "empty", value: "" },
189 ]);
190 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
191 );
192
193 it.effect("enforces domain binding only for schema 24 and newer", () =>
194 Effect.gen(function* () {
195 const fileSystem = yield* FileSystem.FileSystem;
196 const directory = yield* fileSystem.makeTempDirectoryScoped({
197 prefix: "t3code-chromium-cookies-",
198 });
199 const filename = `${directory}/Cookies`;
200 const key = Buffer.from("0123456789abcdef");
201 const boundValue = (host: string, value: string) =>
202 Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]);
203
204 yield* Effect.gen(function* () {
205 const sql = yield* SqlClient.SqlClient;
206 yield* sql`create table meta (key text primary key, value text not null)`;
207 yield* sql`insert into meta values ('version', 24)`;
208 yield* sql`
209 create table cookies (
210 host_key text not null, name text not null, value text not null,
211 encrypted_value blob not null, path text not null, expires_utc integer not null,
212 is_secure integer not null, is_httponly integer not null, samesite integer not null,
213 top_frame_site_key text not null default ''
214 )
215 `;
216 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
217 ('bound.example', 'valid', '', ${encryptV10(boundValue("bound.example", "kept"), key)}, '/', 0, 1, 0, 0)`;
218 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
219 ('wrong.example', 'mismatch', '', ${encryptV10(boundValue("another.example", "drop"), key)}, '/', 0, 1, 0, 0)`;
220 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
221 ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`;
222 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
223
224 const result = yield* readChromiumCookieDatabase(filename, key, "darwin");
225
226 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
227 { name: "valid", value: "kept" },
228 ]);
229 expect(result.undecryptable).toBe(2);
230 expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]);
231 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
232 );
233
234 it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () =>
235 Effect.gen(function* () {
236 const fileSystem = yield* FileSystem.FileSystem;
237 const directory = yield* fileSystem.makeTempDirectoryScoped({
238 prefix: "t3code-chromium-cookies-",
239 });
240 const filename = `${directory}/Cookies`;
241 const key = Buffer.from("0123456789abcdef");
242 const value = "x".repeat(32) + " legacy value";
243
244 yield* Effect.gen(function* () {
245 const sql = yield* SqlClient.SqlClient;
246 yield* sql`create table meta (key text primary key, value text not null)`;
247 yield* sql`insert into meta values ('version', 23)`;
248 yield* sql`
249 create table cookies (
250 host_key text not null, name text not null, value text not null,
251 encrypted_value blob not null, path text not null, expires_utc integer not null,
252 is_secure integer not null, is_httponly integer not null, samesite integer not null,
253 top_frame_site_key text not null default ''
254 )
255 `;
256 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
257 ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`;
258 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
259
260 const result = yield* readChromiumCookieDatabase(filename, key, "darwin");
261 expect(result.cookies[0]?.value).toBe(value);
262 expect(result.undecryptable).toBe(0);
263 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
264 );
265
266 it.effect("rejects a malformed text schema version", () =>
267 Effect.gen(function* () {
268 const fileSystem = yield* FileSystem.FileSystem;
269 const directory = yield* fileSystem.makeTempDirectoryScoped({
270 prefix: "t3code-chromium-cookies-",
271 });
272 const filename = `${directory}/Cookies`;
273
274 yield* Effect.gen(function* () {
275 const sql = yield* SqlClient.SqlClient;
276 yield* sql`create table meta (key text primary key, value text not null)`;
277 yield* sql`insert into meta values ('version', 'not-a-version')`;
278 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
279
280 const error = yield* readChromiumCookieDatabase(
281 filename,
282 Buffer.from("0123456789abcdef"),
283 "darwin",
284 ).pipe(Effect.flip);
285
286 expect(error._tag).toBe("SchemaError");
287 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
288 );
289
290 it.effect("treats unversioned encrypted values as legacy plaintext only on macOS", () =>
291 Effect.gen(function* () {
292 const fileSystem = yield* FileSystem.FileSystem;
293 const directory = yield* fileSystem.makeTempDirectoryScoped({
294 prefix: "t3code-chromium-cookies-",
295 });
296 const filename = `${directory}/Cookies`;
297 const key = Buffer.from("0123456789abcdef");
298
299 yield* Effect.gen(function* () {
300 const sql = yield* SqlClient.SqlClient;
301 yield* sql`create table meta (key text primary key, value integer not null)`;
302 yield* sql`insert into meta values ('version', 23)`;
303 yield* sql`
304 create table cookies (
305 host_key text not null, name text not null, value text not null,
306 encrypted_value blob not null, path text not null, expires_utc integer not null,
307 is_secure integer not null, is_httponly integer not null, samesite integer not null,
308 top_frame_site_key text not null default ''
309 )
310 `;
311 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
312 ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`;
313 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
314
315 const mac = yield* readChromiumCookieDatabase(filename, key, "darwin");
316 const linux = yield* readChromiumCookieDatabase(filename, key, "linux");
317
318 expect(mac.cookies[0]?.value).toBe("legacy cleartext");
319 expect(mac.undecryptable).toBe(0);
320 expect(linux.cookies).toEqual([]);
321 expect(linux.undecryptable).toBe(1);
322 expect(linux.undecryptableHosts).toEqual(["legacy.example"]);
323 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
324 );
325
326 it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () =>
327 Effect.gen(function* () {
328 const fileSystem = yield* FileSystem.FileSystem;
329 const directory = yield* fileSystem.makeTempDirectoryScoped({
330 prefix: "t3code-chromium-cookies-",
331 });
332 const legacyFilename = `${directory}/LegacyCookies`;
333 const chipsFilename = `${directory}/ChipsCookies`;
334 const key = Buffer.from("0123456789abcdef");
335
336 yield* Effect.gen(function* () {
337 const sql = yield* SqlClient.SqlClient;
338 yield* sql`create table meta (key text primary key, value integer not null)`;
339 yield* sql`insert into meta values ('version', 14)`;
340 yield* sql`
341 create table cookies (
342 host_key text not null, name text not null, value text not null,
343 encrypted_value blob not null, path text not null, expires_utc integer not null,
344 is_secure integer not null, is_httponly integer not null, samesite integer not null
345 )
346 `;
347 yield* sql`insert into cookies values
348 ('legacy.example', 'legacy', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0)`;
349 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacyFilename })));
350
351 yield* Effect.gen(function* () {
352 const sql = yield* SqlClient.SqlClient;
353 yield* sql`create table meta (key text primary key, value integer not null)`;
354 yield* sql`insert into meta values ('version', 23)`;
355 yield* sql`
356 create table cookies (
357 host_key text not null, name text not null, value text not null,
358 encrypted_value blob not null, path text not null, expires_utc integer not null,
359 is_secure integer not null, is_httponly integer not null, samesite integer not null,
360 top_frame_site_key text not null
361 )
362 `;
363 yield* sql`insert into cookies values
364 ('plain.example', 'plain', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0, '')`;
365 yield* sql`insert into cookies values
366 ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`;
367 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename })));
368
369 const legacy = yield* readChromiumCookieDatabase(legacyFilename, key, "darwin");
370 const chips = yield* readChromiumCookieDatabase(chipsFilename, key, "darwin");
371
372 expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]);
373 expect(legacy.undecryptable).toBe(0);
374 expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]);
375 expect(chips.undecryptable).toBe(1);
376 expect(chips.undecryptableHosts).toEqual(["partitioned.example"]);
377 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
378 );
379});
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts created+409
...@@ -0,0 +1,409 @@
1// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
2// OSCrypt primitives Chromium uses; Effect has no equivalent.
3/**
4 * Chromium cookie extraction.
5 *
6 * Reads a Chromium-family browser's cookie database and decrypts it with the
7 * key the OS keychain hands us, which is the mechanism the browser itself
8 * uses. macOS mediates that with a per-app consent prompt, so the user
9 * explicitly approves T3 Code reading it.
10 *
11 * Deliberately no fallback when the keychain says no: the alternative
12 * techniques exist to defeat that consent, and this feature is not worth
13 * shipping them.
14 *
15 * @module ChromiumCookies
16 */
17import * as Keyring from "@napi-rs/keyring";
18import * as NodeCrypto from "node:crypto";
19
20import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Path from "effect/Path";
24import * as Schema from "effect/Schema";
25import * as SqlClient from "effect/unstable/sql/SqlClient";
26
27/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
28const MAC_KEY_ITERATIONS = 1003;
29const MAC_KEY_SALT = "saltysalt";
30const MAC_KEY_LENGTH = 16;
31/** OSCrypt uses a fixed IV of 16 spaces rather than a per-record one. */
32const AES_IV = Buffer.alloc(16, 0x20);
33const V10_PREFIX = "v10";
34
35export interface ChromiumCookie {
36 readonly url: string;
37 readonly name: string;
38 readonly value: string;
39 /**
40 * Set only for domain cookies, which Chromium stores with a leading dot.
41 * A host-only cookie leaves this undefined: Electron treats any `domain` it
42 * is given as a domain cookie and re-adds the dot, which would widen the
43 * cookie to every subdomain of the host it was scoped to.
44 */
45 readonly domain: string | undefined;
46 readonly path: string;
47 readonly secure: boolean;
48 readonly httpOnly: boolean;
49 /** Seconds since the UNIX epoch, or undefined for a session cookie. */
50 readonly expirationDate: number | undefined;
51 readonly sameSite: "no_restriction" | "lax" | "strict";
52}
53
54export const ChromiumCookieReadReason = Schema.Literals([
55 "needsKeychainApproval",
56 "keychainItemMissing",
57 "browserRunning",
58 "unsupportedPlatform",
59 "readFailed",
60]);
61export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
62
63export class ChromiumCookieReadError extends Schema.TaggedErrorClass<ChromiumCookieReadError>()(
64 "ChromiumCookieReadError",
65 {
66 reason: ChromiumCookieReadReason,
67 /**
68 * Which database the read was for. Without it every `readFailed` and
69 * keychain failure logs identically, and a user with several browsers
70 * installed has no way to tell which one refused.
71 */
72 cookieDatabasePath: Schema.String,
73 /** Kept for the log; never surfaced to the user. */
74 cause: Schema.optional(Schema.Defect()),
75 },
76) {
77 override get message(): string {
78 return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`;
79 }
80}
81
82/** Row shape of the cookie table, decoded rather than cast. */
83const CookieRow = Schema.Struct({
84 host_key: Schema.String,
85 name: Schema.String,
86 value: Schema.String,
87 encrypted_value: Schema.Uint8Array,
88 path: Schema.String,
89 expires_seconds: Schema.Number,
90 is_secure: Schema.Number,
91 is_httponly: Schema.Number,
92 samesite: Schema.Number,
93 top_frame_site_key: Schema.String,
94});
95
96const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
97const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
98const SchemaVersion = Schema.Union([
99 NonNegativeInt,
100 Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
101]);
102const decodeSchemaVersion = Schema.decodeUnknownEffect(
103 Schema.Tuple([Schema.Struct({ value: SchemaVersion })]),
104);
105
106/**
107 * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in
108 * modern Chromium, so it maps there rather than to `no_restriction`, which
109 * would widen the cookie's scope on import.
110 */
111const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
112 if (value === 0) return "no_restriction";
113 if (value === 2) return "strict";
114 return "lax";
115};
116
117/**
118 * Chromium timestamps count microseconds from 1601-01-01; Electron wants
119 * seconds from the UNIX epoch.
120 *
121 * The microsecond value overflows JavaScript's safe integer range, and
122 * `node:sqlite` refuses to narrow it, so the division happens in SQL and this
123 * only ever sees seconds.
124 */
125const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
126const toUnixSeconds = (webkitSeconds: number): number | undefined => {
127 if (webkitSeconds <= 0) return undefined;
128 return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS;
129};
130
131/**
132 * Reads the OSCrypt key from the login keychain.
133 *
134 * Uses the in-process Keychain API rather than shelling out to
135 * `/usr/bin/security`, because the keychain attributes both the consent prompt
136 * and the resulting ACL entry to the binary that asks. Via the CLI the prompt
137 * says "security" and "Always Allow" grants trust to a tool every process on
138 * the machine can invoke; in-process it names this app and the grant belongs
139 * to it. (In an unsigned dev build the name is the dev Electron binary rather
140 * than the shipped app identity.)
141 *
142 * Deliberately untimed: macOS answers this with a modal, and a timeout racing
143 * the user means the prompt can be approved while nothing is left listening —
144 * which reads as "approving did nothing".
145 */
146const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPassword")(function* (
147 service: string,
148 account: string,
149 cookieDatabasePath: string,
150) {
151 const password = yield* Effect.try({
152 try: () => new Keyring.Entry(service, account).getPassword(),
153 catch: (cause) => {
154 const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
155 // Distinguish the causes rather than telling the user to approve a
156 // prompt when approving cannot fix the failure.
157 const missing = /no (matching )?entry|not found/i.test(message);
158 return new ChromiumCookieReadError({
159 reason: missing ? "keychainItemMissing" : "needsKeychainApproval",
160 cookieDatabasePath,
161 cause,
162 });
163 },
164 });
165 if (password === null || password === "") {
166 return yield* new ChromiumCookieReadError({
167 reason: "keychainItemMissing",
168 cookieDatabasePath,
169 });
170 }
171 return password;
172});
173
174/**
175 * Chromium keeps the cookie DB open with WAL. SQLite must create the snapshot
176 * itself so the main database and WAL are read from one transactionally
177 * consistent generation. Copying those files one after another can pair a
178 * newer database with an older WAL (or the reverse).
179 *
180 * Scoped: the temp directory is removed when the caller's scope closes.
181 */
182export const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(
183 function* (cookiePath: string, tempPrefix = "t3code-cookie-import-") {
184 const fileSystem = yield* FileSystem.FileSystem;
185 const path = yield* Path.Path;
186
187 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix });
188 const target = path.join(directory, "Cookies");
189
190 yield* Effect.gen(function* () {
191 const sql = yield* SqlClient.SqlClient;
192 yield* sql`VACUUM INTO ${target}`;
193 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true })));
194
195 return target;
196 },
197);
198
199/**
200 * The URL and domain Electron should register a stored row under.
201 *
202 * Chromium marks a domain cookie with a leading dot on `host_key`. Electron
203 * matches on a URL, so the dot comes off for that; `domain` is passed through
204 * only for domain cookies, because supplying it at all makes Electron treat
205 * the cookie as one and re-add the dot — widening a host-only cookie to every
206 * subdomain of the host it was scoped to, and rejecting `__Host-` cookies,
207 * which require it to be absent.
208 */
209export const cookieScope = (
210 hostKey: string,
211 path: string,
212 secure: boolean,
213): { readonly url: string; readonly domain: string | undefined } => {
214 const isDomainCookie = hostKey.startsWith(".");
215 const host = isDomainCookie ? hostKey.slice(1) : hostKey;
216 const urlAuthority =
217 host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
218 return {
219 url: `${secure ? "https" : "http"}://${urlAuthority}${path}`,
220 ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }),
221 };
222};
223
224/** The host without Chromium's domain-cookie leading dot, for display. */
225const bareHost = (hostKey: string): string =>
226 hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;
227
228const decryptValue = (
229 encrypted: Uint8Array,
230 key: Buffer,
231 domain: string,
232 schemaVersion: number,
233 platform: NodeJS.Platform,
234): string | null => {
235 const buffer = Buffer.from(encrypted);
236 const version = buffer.subarray(0, 3).toString("latin1");
237
238 try {
239 let plaintext: Buffer;
240 if (version === V10_PREFIX) {
241 const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_IV);
242 decipher.setAutoPadding(true);
243 plaintext = Buffer.concat([decipher.update(buffer.subarray(3)), decipher.final()]);
244 } else if (platform === "darwin") {
245 // macOS OSCrypt explicitly treats an unversioned encrypted_value as
246 // legacy cleartext. This is platform-specific: Linux uses other version
247 // prefixes, which must not be widened into plaintext cookies.
248 plaintext = buffer;
249 } else {
250 return null;
251 }
252 // Cookie schema 24 requires SHA-256(host_key) at the front of every
253 // encrypted value. Treat a missing or mismatched binding as undecryptable;
254 // older schemas stored arbitrary plaintext here, including long values
255 // whose first 32 bytes must not be interpreted as a hash.
256 if (schemaVersion >= 24) {
257 const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();
258 if (plaintext.length < domainHash.length || !plaintext.subarray(0, 32).equals(domainHash)) {
259 return null;
260 }
261 plaintext = plaintext.subarray(32);
262 }
263 return plaintext.toString("utf8");
264 } catch {
265 return null;
266 }
267};
268
269/** Reads and decodes one snapshotted Chromium cookie database. */
270export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")(
271 function* (snapshotPath: string, key: Buffer, platform: NodeJS.Platform) {
272 const rows = yield* Effect.gen(function* () {
273 const sql = yield* SqlClient.SqlClient;
274 const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe(
275 Effect.flatMap(decodeSchemaVersion),
276 Effect.map(([row]) => row.value),
277 );
278 // CHIPS added top_frame_site_key in schema 15. Keep the old query valid
279 // for earlier databases, which do not have the column at all.
280 const raw =
281 schemaVersion >= 15
282 ? yield* sql`
283 select host_key, name, value, encrypted_value, path,
284 expires_utc / 1000000 as expires_seconds,
285 is_secure, is_httponly, samesite, top_frame_site_key
286 from cookies
287 `
288 : yield* sql`
289 select host_key, name, value, encrypted_value, path,
290 expires_utc / 1000000 as expires_seconds,
291 is_secure, is_httponly, samesite, '' as top_frame_site_key
292 from cookies
293 `;
294 return { rows: yield* decodeCookieRows(raw), schemaVersion };
295 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
296
297 const cookies: ChromiumCookie[] = [];
298 let undecryptable = 0;
299 const undecryptableHosts = new Set<string>();
300 for (const row of rows.rows) {
301 // Electron's cookies.set contract cannot represent a CHIPS partition
302 // key. Importing this row without one would widen it into an ordinary
303 // unpartitioned cookie, so count it as skipped instead.
304 if (row.top_frame_site_key !== "") {
305 undecryptable += 1;
306 undecryptableHosts.add(bareHost(row.host_key));
307 continue;
308 }
309 // Chromium stores legacy/plaintext cookies in `value` with an empty
310 // encrypted blob. Preserve an actually empty cookie by falling back to
311 // `value`, rather than treating every empty blob as the empty string.
312 const value =
313 row.encrypted_value.length === 0
314 ? row.value
315 : decryptValue(row.encrypted_value, key, row.host_key, rows.schemaVersion, platform);
316 if (value === null) {
317 undecryptable += 1;
318 undecryptableHosts.add(bareHost(row.host_key));
319 continue;
320 }
321 const secure = row.is_secure === 1;
322 const scope = cookieScope(row.host_key, row.path, secure);
323 cookies.push({
324 url: scope.url,
325 name: row.name,
326 value,
327 domain: scope.domain,
328 path: row.path,
329 secure,
330 httpOnly: row.is_httponly === 1,
331 expirationDate: toUnixSeconds(row.expires_seconds),
332 sameSite: sameSiteFromColumn(row.samesite),
333 });
334 }
335 return {
336 cookies,
337 undecryptable,
338 undecryptableHosts: [...undecryptableHosts],
339 } satisfies CookieReadResult;
340 },
341);
342
343/**
344 * What a reader produces: the cookies it could recover, and how many stored
345 * rows it could not. The count reaches the user as part of the skipped total
346 * rather than disappearing.
347 */
348export interface CookieReadResult {
349 readonly cookies: ReadonlyArray<ChromiumCookie>;
350 readonly undecryptable: number;
351 /** Distinct hosts of the rows that could not be decrypted. */
352 readonly undecryptableHosts: ReadonlyArray<string>;
353}
354
355export interface ChromiumCookieSource {
356 readonly cookieDatabasePath: string;
357 readonly keychainService: string;
358 readonly keychainAccount: string;
359 /** Supplied by the caller from `HostProcessPlatform` rather than read here. */
360 readonly platform: NodeJS.Platform;
361}
362
363export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* (
364 source: ChromiumCookieSource,
365) {
366 if (source.platform !== "darwin") {
367 // Linux (libsecret) and Windows (DPAPI, and App-Bound Encryption on
368 // current Chrome) each need their own key path; only macOS is implemented.
369 return yield* new ChromiumCookieReadError({
370 reason: "unsupportedPlatform",
371 cookieDatabasePath: source.cookieDatabasePath,
372 });
373 }
374
375 const password = yield* readMacKeychainPassword(
376 source.keychainService,
377 source.keychainAccount,
378 source.cookieDatabasePath,
379 );
380 const key = NodeCrypto.pbkdf2Sync(
381 password,
382 MAC_KEY_SALT,
383 MAC_KEY_ITERATIONS,
384 MAC_KEY_LENGTH,
385 "sha1",
386 );
387
388 const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe(
389 Effect.mapError(
390 (cause) =>
391 new ChromiumCookieReadError({
392 reason: "readFailed",
393 cookieDatabasePath: source.cookieDatabasePath,
394 cause,
395 }),
396 ),
397 );
398
399 return yield* readChromiumCookieDatabase(snapshotPath, key, source.platform).pipe(
400 Effect.mapError(
401 (cause) =>
402 new ChromiumCookieReadError({
403 reason: "readFailed",
404 cookieDatabasePath: source.cookieDatabasePath,
405 cause,
406 }),
407 ),
408 );
409});
apps/desktop/src/preview/BrowserImport/Sources.test.ts created+365
...@@ -0,0 +1,365 @@
1// @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie
2// table with the same native bindings the source reads.
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, it } from "@effect/vitest";
5import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import * as Scope from "effect/Scope";
10import * as NodeSqlite from "node:sqlite";
11
12import {
13 BROWSER_IMPORT_SOURCES,
14 chromiumProcessIsAlive,
15 chromiumSingletonLockIsHeld,
16 cookieDatabaseCandidatePaths,
17 resolveCookieDatabase,
18 isSourceInstalled,
19 isSourceRunning,
20 listSourceProfiles,
21 sourcePaths,
22} from "./Sources.ts";
23
24const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
25
26/** A scratch home with the source's user-data directory already created. */
27const withSourceHome = Effect.fnUntraced(function* () {
28 const fileSystem = yield* FileSystem.FileSystem;
29 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" });
30 const paths = yield* sourcePaths.pipe(
31 Effect.provideService(HostProcessEnvironment, { HOME: home }),
32 );
33 yield* fileSystem.makeDirectory(helium.userDataDirectory(paths), { recursive: true });
34 return paths;
35});
36
37const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>) =>
38 effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
39
40/** Writes a Chromium-shaped cookie table with `count` rows. */
41const writeCookieDatabase = (file: string, count: number) =>
42 Effect.sync(() => {
43 const database = new NodeSqlite.DatabaseSync(file);
44 database.exec("create table cookies (host_key text, name text)");
45 const insert = database.prepare("insert into cookies (host_key, name) values (?, ?)");
46 for (let index = 0; index < count; index += 1) insert.run("example.test", `c${index}`);
47 database.close();
48 });
49
50describe("isSourceRunning", () => {
51 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>
52 run(
53 Effect.gen(function* () {
54 const fileSystem = yield* FileSystem.FileSystem;
55 const paths = yield* withSourceHome();
56 assert.isFalse(yield* isSourceRunning(helium, paths));
57
58 // Chromium points the lock at `<host>-<pid>`, a target that never
59 // exists on disk. A check that follows the link reports a running
60 // browser as closed, letting an import read a live, mid-write database.
61 yield* fileSystem.symlink(
62 "host-that-does-not-exist-1234",
63 `${helium.userDataDirectory(paths)}/SingletonLock`,
64 );
65
66 assert.isTrue(yield* isSourceRunning(helium, paths));
67 }),
68 ),
69 );
70
71 it.effect("uses the provided hostname to classify Chromium locks", () =>
72 run(
73 Effect.gen(function* () {
74 const fileSystem = yield* FileSystem.FileSystem;
75 const paths = yield* withSourceHome();
76 yield* fileSystem.symlink(
77 "lock-owner-99999999",
78 `${helium.userDataDirectory(paths)}/SingletonLock`,
79 );
80
81 assert.isTrue(
82 yield* isSourceRunning(helium, paths).pipe(
83 Effect.provideService(HostProcessHostname, "another-host"),
84 ),
85 );
86 assert.isFalse(
87 yield* isSourceRunning(helium, paths).pipe(
88 Effect.provideService(HostProcessHostname, "lock-owner"),
89 ),
90 );
91 }),
92 ),
93 );
94});
95
96describe("chromiumSingletonLockIsHeld", () => {
97 it.effect("ignores a positively dead PID on the current host", () =>
98 Effect.gen(function* () {
99 const checked: number[] = [];
100 const held = yield* chromiumSingletonLockIsHeld("current-host-4321", "current-host", (pid) =>
101 Effect.sync(() => {
102 checked.push(pid);
103 return false;
104 }),
105 );
106 assert.isFalse(held);
107 assert.deepEqual(checked, [4321]);
108 }),
109 );
110
111 it.effect("keeps a live PID on the current host", () =>
112 chromiumSingletonLockIsHeld("current-host-4321", "current-host", () =>
113 Effect.succeed(true),
114 ).pipe(Effect.tap((held) => Effect.sync(() => assert.isTrue(held)))),
115 );
116
117 it.effect("keeps foreign-host and malformed targets without probing a PID", () =>
118 Effect.gen(function* () {
119 let probes = 0;
120 const probe = (_pid: number) =>
121 Effect.sync(() => {
122 probes += 1;
123 return false;
124 });
125 assert.isTrue(yield* chromiumSingletonLockIsHeld("another-host-4321", "current-host", probe));
126 assert.isTrue(
127 yield* chromiumSingletonLockIsHeld("current-host-no-pid", "current-host", probe),
128 );
129 assert.isTrue(yield* chromiumSingletonLockIsHeld("current-host-0", "current-host", probe));
130 assert.strictEqual(probes, 0);
131 }),
132 );
133});
134
135describe("chromiumProcessIsAlive", () => {
136 it.effect("returns false only when signal 0 reports a missing process", () =>
137 Effect.gen(function* () {
138 const missing = Object.assign(new Error("missing"), { code: "ESRCH" });
139 const denied = Object.assign(new Error("denied"), { code: "EPERM" });
140 assert.isFalse(
141 yield* chromiumProcessIsAlive(4321, () => {
142 throw missing;
143 }),
144 );
145 assert.isTrue(
146 yield* chromiumProcessIsAlive(4321, () => {
147 throw denied;
148 }),
149 );
150 assert.isTrue(
151 yield* chromiumProcessIsAlive(4321, () => {
152 throw undefined;
153 }),
154 );
155 assert.isTrue(
156 yield* chromiumProcessIsAlive(4321, () => {
157 throw "unknown failure";
158 }),
159 );
160 assert.isTrue(
161 yield* chromiumProcessIsAlive(4321, () => {
162 throw null;
163 }),
164 );
165 assert.isTrue(yield* chromiumProcessIsAlive(4321, () => true));
166 }),
167 );
168});
169
170describe("isSourceInstalled", () => {
171 it.effect("ignores a user-data directory that holds no cookie database", () =>
172 run(
173 Effect.gen(function* () {
174 const fileSystem = yield* FileSystem.FileSystem;
175 const paths = yield* withSourceHome();
176 const root = helium.userDataDirectory(paths);
177
178 // Installers for native messaging hosts create an empty user-data
179 // directory for every Chromium fork they know about, so treating the
180 // directory as evidence lists browsers the user does not have.
181 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
182 assert.isFalse(yield* isSourceInstalled(helium, paths));
183
184 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
185 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
186 assert.isTrue(yield* isSourceInstalled(helium, paths));
187
188 // A real install whose cookies live outside `Default` still counts:
189 // reporting it as absent hides the source from the menu entirely.
190 yield* fileSystem.remove(`${root}/Default`, { recursive: true });
191 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
192 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
193 assert.isTrue(yield* isSourceInstalled(helium, paths));
194
195 yield* fileSystem.remove(root, { recursive: true });
196 assert.isFalse(yield* isSourceInstalled(helium, paths));
197 }),
198 ),
199 );
200});
201
202describe("listSourceProfiles", () => {
203 it.effect("ignores a profile whose Cookies entry is not a file", () =>
204 run(
205 Effect.gen(function* () {
206 const fileSystem = yield* FileSystem.FileSystem;
207 const paths = yield* withSourceHome();
208 const root = helium.userDataDirectory(paths);
209 // A directory named `Cookies` would list as importable and then fail
210 // the SQLite open, so only a regular file counts as a database.
211 yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true });
212 yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true });
213 yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db");
214
215 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
216 { directory: "Real", name: "Real" },
217 ]);
218 assert.isTrue(yield* isSourceInstalled(helium, paths));
219 }),
220 ),
221 );
222
223 it.effect("discovers profiles by their cookie database when Local State is absent", () =>
224 run(
225 Effect.gen(function* () {
226 const fileSystem = yield* FileSystem.FileSystem;
227 const paths = yield* withSourceHome();
228 const root = helium.userDataDirectory(paths);
229 // Assuming `Default` would report a browser whose cookies live in
230 // `Profile 1` as having nothing to import, and it is then hidden.
231 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
232 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
233 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
234
235 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
236 { directory: "Profile 1", name: "Profile 1" },
237 ]);
238 }),
239 ),
240 );
241
242 it.effect("reads the profile names the browser shows", () =>
243 run(
244 Effect.gen(function* () {
245 const fileSystem = yield* FileSystem.FileSystem;
246 const paths = yield* withSourceHome();
247 yield* fileSystem.writeFileString(
248 `${helium.userDataDirectory(paths)}/Local State`,
249 `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`,
250 );
251
252 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
253 { directory: "Default", name: "You" },
254 // Blank display name falls back to the directory rather than
255 // rendering an empty row.
256 { directory: "Profile 2", name: "Profile 2" },
257 ]);
258 }),
259 ),
260 );
261
262 it.effect("scans for profiles when Local State is malformed", () =>
263 run(
264 Effect.gen(function* () {
265 const fileSystem = yield* FileSystem.FileSystem;
266 const paths = yield* withSourceHome();
267 const root = helium.userDataDirectory(paths);
268 yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json");
269 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
270 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
271
272 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
273 { directory: "Default", name: "Default" },
274 ]);
275 }),
276 ),
277 );
278
279 it.effect("reports nothing when no directory holds a cookie database", () =>
280 run(
281 Effect.gen(function* () {
282 const paths = yield* withSourceHome();
283 assert.deepEqual(yield* listSourceProfiles(helium, paths), []);
284 }),
285 ),
286 );
287
288 it.effect("counts a profile's cookies without decrypting them", () =>
289 run(
290 Effect.gen(function* () {
291 const fileSystem = yield* FileSystem.FileSystem;
292 const paths = yield* withSourceHome();
293 const root = helium.userDataDirectory(paths);
294 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
295 yield* writeCookieDatabase(`${root}/Default/Cookies`, 3);
296
297 const [profile] = yield* listSourceProfiles(helium, paths);
298 assert.equal(profile?.cookieCount, 3);
299 }),
300 ),
301 );
302});
303
304describe("cookieDatabaseCandidatePaths", () => {
305 it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () =>
306 run(
307 Effect.gen(function* () {
308 const paths = yield* withSourceHome();
309 const profile = `${paths.home}/Library/Application Support/net.imput.helium/Profile 1`;
310 assert.deepEqual(cookieDatabaseCandidatePaths(helium, paths, "Profile 1"), [
311 `${profile}/Network/Cookies`,
312 `${profile}/Cookies`,
313 ]);
314 }),
315 ),
316 );
317
318 it.effect("resolves the live Network/ jar over a leftover root Cookies", () =>
319 run(
320 Effect.gen(function* () {
321 const fileSystem = yield* FileSystem.FileSystem;
322 const paths = yield* withSourceHome();
323 const root = helium.userDataDirectory(paths);
324 // Chromium 96+ keeps sessions in Network/; a root Cookies left behind
325 // by the move is stale and must not be the one imported.
326 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
327 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live");
328 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale");
329
330 assert.equal(
331 yield* resolveCookieDatabase(helium, paths, "Default"),
332 `${root}/Default/Network/Cookies`,
333 );
334 // A fresh install with only the Network/ jar is installed, not hidden.
335 yield* fileSystem.remove(`${root}/Default/Cookies`);
336 assert.isTrue(yield* isSourceInstalled(helium, paths));
337 }),
338 ),
339 );
340});
341
342describe("listSourceProfiles hardening", () => {
343 it.effect("drops profile directories that are not a single plain segment", () =>
344 run(
345 Effect.gen(function* () {
346 const fileSystem = yield* FileSystem.FileSystem;
347 const paths = yield* withSourceHome();
348 // `Local State` is writable by anything running as the user, so a
349 // crafted key must not reach `cookieDatabasePath` and read a database
350 // outside the browser's user-data directory.
351 yield* fileSystem.writeFileString(
352 `${helium.userDataDirectory(paths)}/Local State`,
353 `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`,
354 );
355
356 const profiles = yield* listSourceProfiles(helium, paths);
357
358 assert.deepEqual(
359 profiles.map((profile) => profile.directory),
360 ["Default"],
361 );
362 }),
363 ),
364 );
365});
apps/desktop/src/preview/BrowserImport/Sources.ts created+307
...@@ -0,0 +1,307 @@
1/**
2 * Importable browser sources.
3 *
4 * Each entry pins its own on-disk and keychain coordinates rather than
5 * deriving them: Chromium forks do not agree on the convention. Helium, for
6 * instance, uses the keychain service "Helium Storage Key" / account "Helium"
7 * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
8 *
9 * @module BrowserImportSources
10 */
11import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
12import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
13import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import * as SqlClient from "effect/unstable/sql/SqlClient";
19
20/**
21 * Where a source's files live, resolved once per call rather than read from
22 * the ambient process so the registry stays testable.
23 */
24export interface SourcePaths {
25 readonly path: Path.Path;
26 readonly home: string;
27}
28
29export const sourcePaths = Effect.gen(function* () {
30 const path = yield* Path.Path;
31 const environment = yield* HostProcessEnvironment;
32 return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
33});
34
35export interface BrowserImportSourceDefinition {
36 readonly id: BrowserImportSourceId;
37 readonly name: string;
38 /** Platforms the definition's paths are valid for. */
39 readonly platforms: ReadonlyArray<NodeJS.Platform>;
40 readonly userDataDirectory: (paths: SourcePaths) => string;
41 readonly keychainService: string;
42 readonly keychainAccount: string;
43}
44
45export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
46 {
47 id: "helium",
48 name: "Helium",
49 platforms: ["darwin"],
50 userDataDirectory: ({ path, home }) =>
51 path.join(home, "Library", "Application Support", "net.imput.helium"),
52 keychainService: "Helium Storage Key",
53 keychainAccount: "Helium",
54 },
55];
56
57/**
58 * Where a profile's cookie database may live, most current first. Chromium 96
59 * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a
60 * pre-96 install or a leftover from before the move. Importing the leftover
61 * while sessions live in `Network/` would snapshot a stale or empty database,
62 * and a fresh install with only `Network/Cookies` would read as not installed.
63 */
64export const cookieDatabaseCandidatePaths = (
65 definition: BrowserImportSourceDefinition,
66 paths: SourcePaths,
67 profileDirectory: string,
68): ReadonlyArray<string> => {
69 const profile = paths.path.join(definition.userDataDirectory(paths), profileDirectory);
70 return [paths.path.join(profile, "Network", "Cookies"), paths.path.join(profile, "Cookies")];
71};
72
73/** The first candidate that is a regular file, or undefined when none is. */
74export const resolveCookieDatabase = Effect.fnUntraced(function* (
75 definition: BrowserImportSourceDefinition,
76 paths: SourcePaths,
77 profileDirectory: string,
78) {
79 for (const candidate of cookieDatabaseCandidatePaths(definition, paths, profileDirectory)) {
80 if (yield* databaseFileExists(candidate)) return candidate;
81 }
82 return undefined;
83});
84
85/** Shape of the slice of Chromium's `Local State` that names its profiles. */
86const LocalState = Schema.Struct({
87 profile: Schema.optional(
88 Schema.Struct({
89 info_cache: Schema.optional(
90 Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),
91 ),
92 }),
93 ),
94});
95const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState));
96
97/** A single plain path segment: no separators, no `.`/`..`, not empty. */
98const isSafeProfileDirectory = (directory: string): boolean =>
99 directory.length > 0 &&
100 directory !== "." &&
101 directory !== ".." &&
102 !/[\\/]/.test(directory) &&
103 !directory.includes("\u0000");
104
105const CookieCountRow = Schema.Struct({ count: Schema.Number });
106const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));
107
108/**
109 * How many cookies a profile holds, counted without decrypting anything — a
110 * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium
111 * database (Firefox's table is named differently, Safari's is not SQL) yields
112 * `undefined` rather than failing the listing.
113 */
114const countProfileCookies = Effect.fnUntraced(function* (
115 definition: BrowserImportSourceDefinition,
116 paths: SourcePaths,
117 directory: string,
118): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> {
119 const database = yield* resolveCookieDatabase(definition, paths, directory);
120 if (database === undefined) return undefined;
121 return yield* Effect.gen(function* () {
122 const sql = yield* SqlClient.SqlClient;
123 const rows = yield* sql`select count(*) as count from cookies`;
124 const [row] = yield* decodeCookieCount(rows);
125 return row?.count;
126 }).pipe(
127 Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })),
128 Effect.orElseSucceed(() => undefined),
129 );
130});
131
132const withCookieCounts = (
133 definition: BrowserImportSourceDefinition,
134 paths: SourcePaths,
135 profiles: ReadonlyArray<BrowserImportSourceProfile>,
136) =>
137 Effect.forEach(profiles, (profile) =>
138 countProfileCookies(definition, paths, profile.directory).pipe(
139 Effect.map((cookieCount) =>
140 cookieCount === undefined ? profile : { ...profile, cookieCount },
141 ),
142 ),
143 );
144
145/**
146 * Profiles the source browser knows about, read from its `Local State`.
147 *
148 * When that file is missing, unreadable or malformed, the user-data directory
149 * is scanned for directories that hold a cookie database. Assuming `Default`
150 * instead would report a browser whose cookies live in `Profile 1` as having
151 * nothing to import — and it is then left out of the menu entirely.
152 */
153export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
154 definition: BrowserImportSourceDefinition,
155 paths: SourcePaths,
156) {
157 const fileSystem = yield* FileSystem.FileSystem;
158 const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
159
160 const root = definition.userDataDirectory(paths);
161 const declared = yield* fileSystem.readFileString(localStatePath).pipe(
162 Effect.flatMap(decodeLocalState),
163 Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
164 // The keys are directory names from the browser's own metadata file, which
165 // is writable by anything running as the user. Anything but a single plain
166 // segment is dropped: `..` or a path separator would otherwise be handed
167 // to `cookieDatabasePath` and read a database outside the user-data
168 // directory.
169 Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))),
170 Effect.map((entries) =>
171 entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })),
172 ),
173 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
174 );
175 if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared);
176
177 // `Local State` is missing, unreadable or malformed. Scanning for
178 // directories that hold a cookie database finds the profiles anyway;
179 // assuming `Default` would report a browser whose cookies live in
180 // `Profile 1` as having nothing to import, and it is then hidden entirely.
181 const entries = yield* fileSystem
182 .readDirectory(root)
183 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
184 const candidates = entries.filter(isSafeProfileDirectory);
185 const found = yield* Effect.forEach(candidates, (directory) =>
186 resolveCookieDatabase(definition, paths, directory).pipe(
187 Effect.map((database) =>
188 database === undefined ? undefined : { directory, name: directory },
189 ),
190 ),
191 );
192 return yield* withCookieCounts(
193 definition,
194 paths,
195 found.filter((profile) => profile !== undefined),
196 );
197});
198
199/**
200 * Whether a cookie database candidate is a regular file. Presence alone is
201 * not enough: a directory at the path would list as an importable profile and
202 * then fail the SQLite open, so anything but a file is treated as absent.
203 */
204const databaseFileExists = Effect.fnUntraced(function* (path: string) {
205 const fileSystem = yield* FileSystem.FileSystem;
206 return yield* fileSystem.stat(path).pipe(
207 Effect.map((info) => info.type === "File"),
208 Effect.orElseSucceed(() => false),
209 );
210});
211
212type ProcessLivenessProbe = (pid: number) => Effect.Effect<boolean>;
213
214export const chromiumProcessIsAlive = (
215 pid: number,
216 signalProcess: (pid: number, signal: 0) => unknown = process.kill.bind(process),
217) =>
218 Effect.sync(() => {
219 try {
220 // Signal 0 performs a read-only existence/permission check.
221 signalProcess(pid, 0);
222 return true;
223 } catch (cause) {
224 // Only ESRCH positively proves the process is gone. Permission errors
225 // and unknown failures stay conservative so an active browser is never
226 // mistaken for a stale lock.
227 return !(
228 typeof cause === "object" &&
229 cause !== null &&
230 "code" in cause &&
231 cause.code === "ESRCH"
232 );
233 }
234 });
235
236const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid);
237
238/** Whether a Chromium `<host>-<pid>` lock target may still name its owner. */
239export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
240 target: string,
241 currentHost: string,
242 isProcessAlive: ProcessLivenessProbe,
243) {
244 const separator = target.lastIndexOf("-");
245 if (separator <= 0) return true;
246 const host = target.slice(0, separator);
247 const pidText = target.slice(separator + 1);
248 if (!/^\d+$/.test(pidText)) return true;
249 const pid = Number(pidText);
250 if (!Number.isSafeInteger(pid) || pid <= 0) return true;
251 // A PID is meaningful only on this host. A foreign hostname can come from a
252 // shared home directory, and cannot safely be declared stale from here.
253 if (host !== currentHost) return true;
254 return yield* isProcessAlive(pid);
255});
256
257/** Whether the browser is running, which leaves its cookie DB mid-write. */
258export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
259 definition: BrowserImportSourceDefinition,
260 paths: SourcePaths,
261) {
262 const fileSystem = yield* FileSystem.FileSystem;
263 const currentHost = yield* HostProcessHostname;
264 const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock");
265 // Chromium writes a `SingletonLock` symlink for as long as an instance holds
266 // the profile. Its presence is a far cheaper and more targeted signal than
267 // scanning the process table for a name.
268 //
269 // The link points at `<host>-<pid>`, a target that never exists, and both
270 // `stat` and `exists` follow links — so they report every running browser as
271 // closed, which would let an import read a live, mid-write database.
272 // `readLink` is the one probe that answers for the entry itself. Chromium
273 // can leave this link behind after a crash, so a positively dead local PID
274 // is stale. Every ambiguous target or liveness result stays conservative.
275 return yield* fileSystem.readLink(lock).pipe(
276 Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),
277 Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),
278 );
279});
280
281/**
282 * Whether the source has cookies to import.
283 *
284 * Keyed off the cookie database rather than the user-data directory, because
285 * that directory is not evidence the browser exists: installers for native
286 * messaging hosts create an empty one for every Chromium fork they know about,
287 * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as
288 * present. The database is the thing an import actually needs, so its absence
289 * is the honest answer either way.
290 *
291 * Existence is checked without opening the file, which matters for Safari: TCC
292 * permits `stat` on the jar inside its container but refuses a read, so this
293 * still sees it and the user gets the Full Disk Access prompt rather than
294 * having Safari disappear.
295 */
296export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* (
297 definition: BrowserImportSourceDefinition,
298 paths: SourcePaths,
299) {
300 const profiles = yield* listSourceProfiles(definition, paths);
301 const found = yield* Effect.forEach(profiles, (profile) =>
302 resolveCookieDatabase(definition, paths, profile.directory).pipe(
303 Effect.map((database) => database !== undefined),
304 ),
305 );
306 return found.some(Boolean);
307});
apps/web/src/components/settings/BrowserImportWizard.tsx created+475
...@@ -0,0 +1,475 @@
1import type { BrowserImportSource } from "@t3tools/contracts";
2import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
3import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
4import { useRef, useState } from "react";
5
6import { cn, randomUUID } from "~/lib/utils";
7
8import { Button } from "../ui/button";
9import {
10 Dialog,
11 DialogClose,
12 DialogDescription,
13 DialogFooter,
14 DialogHeader,
15 DialogPanel,
16 DialogPopup,
17 DialogTitle,
18} from "../ui/dialog";
19import { Spinner } from "../ui/spinner";
20import {
21 initialWizardStep,
22 initialTargetSelection,
23 canCloseWizard,
24 isRetryableReason,
25 formatSkippedDomains,
26 outcomeToStep,
27 refreshedSourceProfileDirectory,
28 refreshedSourceStep,
29 resolveWizardTarget,
30 type ImportOutcome,
31 type WizardTarget,
32 type WizardTargetProfile,
33 type WizardTargetSelection,
34 type WizardStep,
35} from "./browserImportWizard.logic";
36
37export type { WizardTarget } from "./browserImportWizard.logic";
38
39interface BrowserImportWizardProps {
40 readonly source: BrowserImportSource;
41 /** Captured when the wizard opens so destination copy and writes stay stable. */
42 readonly destinationEnvironmentName: string;
43 /** Existing profiles the import can go into. Incognito is excluded upstream. */
44 readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
45 /** Whether a new profile can still be created (profile cap). */
46 readonly canCreateProfile: boolean;
47 /**
48 * Runs the import and returns how it went. For a new target the caller only
49 * registers the profile once the import succeeds, so a blocked attempt never
50 * leaves an empty profile behind.
51 */
52 readonly onImport: (input: {
53 readonly sourceProfileDirectory: string;
54 readonly target: WizardTarget;
55 }) => Promise<ImportOutcome>;
56 /** Re-checks the source's availability after the user quits the browser. */
57 readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
58 readonly onClose: () => void;
59}
60
61/**
62 * Guides one browser's cookies into a profile.
63 *
64 * Every state the import can be in — the browser is open, a profile has to be
65 * chosen, the read failed — is a screen the user can move forward from, rather
66 * than a disabled row that only says no.
67 */
68export function BrowserImportWizard({
69 source: initialSource,
70 destinationEnvironmentName,
71 targetProfiles,
72 canCreateProfile,
73 onImport,
74 onRefreshSource,
75 onClose,
76}: BrowserImportWizardProps) {
77 const [source, setSource] = useState(initialSource);
78 const [step, setStep] = useState<WizardStep>(() => initialWizardStep(initialSource));
79 const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
80 () => initialSource.profiles[0]?.directory ?? "",
81 );
82 const [target, setTarget] = useState<WizardTargetSelection>(() =>
83 initialTargetSelection(canCreateProfile, targetProfiles),
84 );
85 const [targetError, setTargetError] = useState<string>();
86 // Stable across retries so a keychain re-approval lands in one profile, not
87 // a new one each time.
88 const newProfileId = useRef(`profile-${randomUUID()}`);
89 // A second Import click before React has left the configure screen would
90 // start a second run; the parent refuses it, and applying that refusal here
91 // would drop the wizard out of the importing step while the first write is
92 // still going. The ref settles synchronously where state does not.
93 const importInFlight = useRef(false);
94
95 const runImport = () => {
96 if (importInFlight.current) return;
97 const chosen = resolveWizardTarget(target, newProfileId.current, targetProfiles);
98 if (chosen === undefined) {
99 setTargetError("That profile is no longer available. Choose where to import these cookies.");
100 setStep({ step: "configure" });
101 return;
102 }
103 setTargetError(undefined);
104 importInFlight.current = true;
105 setStep({ step: "importing" });
106 void onImport({ sourceProfileDirectory, target: chosen })
107 .then((outcome) => setStep(outcomeToStep(outcome)))
108 .catch(() => setStep({ step: "blocked", reason: "readFailed" }))
109 .finally(() => {
110 importInFlight.current = false;
111 });
112 };
113
114 const recheckAfterQuit = () => {
115 setStep({ step: "checking" });
116 void onRefreshSource()
117 .then((refreshed) => {
118 if (refreshed) {
119 setSource(refreshed);
120 setSourceProfileDirectory((current) =>
121 refreshedSourceProfileDirectory(current, refreshed),
122 );
123 }
124 setStep(refreshedSourceStep(refreshed));
125 })
126 .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
127 };
128
129 return (
130 <Dialog open onOpenChange={(open) => (open || !canCloseWizard(step) ? undefined : onClose())}>
131 <DialogPopup className="max-w-lg" showCloseButton={canCloseWizard(step)}>
132 {step.step === "quit" ? (
133 <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
134 ) : step.step === "importing" ? (
135 <ImportingStep />
136 ) : step.step === "checking" ? (
137 <CheckingStep sourceName={source.name} />
138 ) : step.step === "done" ? (
139 <DoneStep
140 {...step}
141 destinationEnvironmentName={destinationEnvironmentName}
142 onClose={onClose}
143 />
144 ) : step.step === "blocked" ? (
145 <BlockedStep
146 source={source}
147 reason={step.reason}
148 onClose={onClose}
149 onRetry={isRetryableReason(step.reason) ? runImport : undefined}
150 />
151 ) : (
152 <ConfigureStep
153 source={source}
154 destinationEnvironmentName={destinationEnvironmentName}
155 targetProfiles={targetProfiles}
156 canCreateProfile={canCreateProfile}
157 sourceProfileDirectory={sourceProfileDirectory}
158 onSourceProfileChange={setSourceProfileDirectory}
159 target={target}
160 onTargetChange={(selection) => {
161 setTarget(selection);
162 setTargetError(undefined);
163 }}
164 targetError={targetError}
165 onCancel={onClose}
166 onImport={runImport}
167 />
168 )}
169 </DialogPopup>
170 </Dialog>
171 );
172}
173
174function QuitStep({
175 source,
176 onCancel,
177 onRechecked,
178}: {
179 readonly source: BrowserImportSource;
180 readonly onCancel: () => void;
181 readonly onRechecked: () => void;
182}) {
183 return (
184 <>
185 <DialogHeader>
186 <DialogTitle>Quit {source.name} to import</DialogTitle>
187 <DialogDescription>
188 {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue.
189 </DialogDescription>
190 </DialogHeader>
191 <DialogFooter>
192 <Button variant="outline" onClick={onCancel}>
193 Cancel
194 </Button>
195 <Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
196 </DialogFooter>
197 </>
198 );
199}
200
201/** "5,065 cookies", or "no cookies", or nothing when the store is unreadable. */
202function cookieCountLabel(count: number | undefined): string | undefined {
203 if (count === undefined) return undefined;
204 if (count === 0) return "no cookies";
205 return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`;
206}
207
208function cookieResultCount(count: number): string {
209 return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`;
210}
211
212type ConfigureStepProps = {
213 readonly source: BrowserImportSource;
214 readonly destinationEnvironmentName: string;
215 readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
216 readonly canCreateProfile: boolean;
217 readonly sourceProfileDirectory: string;
218 readonly onSourceProfileChange: (directory: string) => void;
219 readonly target: WizardTargetSelection;
220 readonly targetError: string | undefined;
221 readonly onTargetChange: (target: WizardTargetSelection) => void;
222 readonly onCancel: () => void;
223 readonly onImport: () => void;
224};
225
226function ConfigureStep({
227 source,
228 destinationEnvironmentName,
229 targetProfiles,
230 canCreateProfile,
231 sourceProfileDirectory,
232 onSourceProfileChange,
233 target,
234 targetError,
235 onTargetChange,
236 onCancel,
237 onImport,
238}: ConfigureStepProps) {
239 const targetMissing =
240 target.kind === "existing" &&
241 !targetProfiles.some((profile) => profile.id === target.profileId);
242 // The "New profile" tile is unrendered once the cap is reached, so a target
243 // chosen before that leaves nothing selected in "Into" — say so, the same
244 // way a vanished existing target is explained.
245 const targetUncreatable = target.kind === "new" && !canCreateProfile;
246 const targetFeedback =
247 targetError ??
248 (targetMissing
249 ? "That profile is no longer available. Choose where to import these cookies."
250 : targetUncreatable
251 ? "You've reached the profile limit. Choose an existing profile to import into."
252 : undefined);
253 return (
254 <>
255 <DialogHeader>
256 <DialogTitle>Import from {source.name}</DialogTitle>
257 <DialogDescription>
258 Choose which cookies to import for {destinationEnvironmentName}.
259 </DialogDescription>
260 </DialogHeader>
261 <DialogPanel>
262 {/* Side by side when the dialog has room, stacked when it doesn't. */}
263 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
264 <section className="flex-1 space-y-2">
265 <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
266 From
267 </p>
268 {source.profiles.map((profile) => (
269 <SelectableTile
270 key={profile.directory}
271 selected={sourceProfileDirectory === profile.directory}
272 title={profile.name}
273 subtitle={cookieCountLabel(profile.cookieCount)}
274 onSelect={() => onSourceProfileChange(profile.directory)}
275 />
276 ))}
277 </section>
278 <div className="flex shrink-0 items-center justify-center text-muted-foreground">
279 <ArrowDownIcon className="size-4 sm:hidden" />
280 <ArrowRightIcon className="hidden size-4 sm:block" />
281 </div>
282 <section className="flex-1 space-y-2">
283 <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
284 Into
285 </p>
286 {canCreateProfile ? (
287 <SelectableTile
288 selected={target.kind === "new"}
289 title="New profile"
290 subtitle="Created for these cookies"
291 onSelect={() => onTargetChange({ kind: "new" })}
292 />
293 ) : null}
294 {targetProfiles.map((profile) => (
295 <SelectableTile
296 key={profile.id}
297 selected={target.kind === "existing" && target.profileId === profile.id}
298 title={profile.name}
299 subtitle="Existing profile"
300 onSelect={() => onTargetChange({ kind: "existing", profileId: profile.id })}
301 />
302 ))}
303 </section>
304 </div>
305 {targetFeedback ? (
306 <p role="alert" className="mt-3 text-sm text-destructive">
307 {targetFeedback}
308 </p>
309 ) : null}
310 </DialogPanel>
311 <DialogFooter>
312 <Button variant="outline" onClick={onCancel}>
313 Cancel
314 </Button>
315 <Button
316 disabled={sourceProfileDirectory === "" || targetMissing || targetUncreatable}
317 onClick={onImport}
318 >
319 Import
320 </Button>
321 </DialogFooter>
322 </>
323 );
324}
325
326/** One selectable option: a name, an optional detail line, and a check. */
327function SelectableTile({
328 selected,
329 title,
330 subtitle,
331 onSelect,
332}: {
333 readonly selected: boolean;
334 readonly title: string;
335 readonly subtitle?: string | undefined;
336 readonly onSelect: () => void;
337}) {
338 return (
339 <button
340 type="button"
341 aria-pressed={selected}
342 onClick={onSelect}
343 className={cn(
344 "flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
345 selected
346 ? "border-primary bg-primary/8"
347 : "border-border/60 hover:border-border hover:bg-muted/40",
348 )}
349 >
350 <span className="min-w-0">
351 <span className="block truncate text-sm font-medium text-foreground">{title}</span>
352 {subtitle ? (
353 <span className="block truncate text-xs tabular-nums text-muted-foreground">
354 {subtitle}
355 </span>
356 ) : null}
357 </span>
358 <span
359 className={cn(
360 "grid size-4 shrink-0 place-items-center rounded-full border",
361 selected ? "border-primary bg-primary text-primary-foreground" : "border-input",
362 )}
363 >
364 {selected ? <CheckIcon className="size-2.5" /> : null}
365 </span>
366 </button>
367 );
368}
369
370function ImportingStep() {
371 return (
372 <>
373 <DialogHeader>
374 <DialogTitle>Importing cookies</DialogTitle>
375 <DialogDescription>This may take a moment.</DialogDescription>
376 </DialogHeader>
377 <DialogPanel className="flex items-center gap-3 py-6">
378 <Spinner className="size-4 text-muted-foreground" />
379 <span className="text-sm text-muted-foreground">Importing…</span>
380 </DialogPanel>
381 </>
382 );
383}
384
385function CheckingStep({ sourceName }: { readonly sourceName: string }) {
386 return (
387 <>
388 <DialogHeader>
389 <DialogTitle>Checking {sourceName}</DialogTitle>
390 <DialogDescription>Checking whether the browser has closed.</DialogDescription>
391 </DialogHeader>
392 <DialogPanel className="flex items-center gap-3 py-6">
393 <Spinner className="size-4 text-muted-foreground" />
394 <span className="text-sm text-muted-foreground">Checking…</span>
395 </DialogPanel>
396 </>
397 );
398}
399
400function DoneStep({
401 imported,
402 skipped,
403 skippedDomains,
404 targetName,
405 destinationEnvironmentName,
406 onClose,
407}: {
408 readonly imported: number;
409 readonly skipped: number;
410 readonly skippedDomains: ReadonlyArray<string>;
411 readonly targetName: string;
412 readonly destinationEnvironmentName: string;
413 readonly onClose: () => void;
414}) {
415 return (
416 <>
417 <DialogHeader>
418 <DialogTitle>
419 {imported > 0
420 ? `Imported ${cookieResultCount(imported)}`
421 : skipped > 0
422 ? `Skipped ${cookieResultCount(skipped)}`
423 : "No cookies found"}
424 </DialogTitle>
425 <DialogDescription>
426 {imported > 0
427 ? `Added to ${targetName} for ${destinationEnvironmentName}.${skipped > 0 ? ` ${cookieResultCount(skipped)} skipped.` : ""}`
428 : skipped > 0
429 ? `No cookies were imported for ${destinationEnvironmentName}.`
430 : `There were no cookies to import for ${destinationEnvironmentName}.`}
431 </DialogDescription>
432 </DialogHeader>
433 {skippedDomains.length > 0 ? (
434 <DialogPanel>
435 <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
436 Skipped
437 </p>
438 <p className="mt-1 text-sm text-foreground">{formatSkippedDomains(skippedDomains)}</p>
439 </DialogPanel>
440 ) : null}
441 <DialogFooter>
442 <DialogClose render={<Button />} onClick={onClose}>
443 Done
444 </DialogClose>
445 </DialogFooter>
446 </>
447 );
448}
449
450function BlockedStep({
451 source,
452 reason,
453 onClose,
454 onRetry,
455}: {
456 readonly source: BrowserImportSource;
457 readonly reason: keyof typeof BROWSER_IMPORT_FAILURE_COPY;
458 readonly onClose: () => void;
459 readonly onRetry: (() => void) | undefined;
460}) {
461 return (
462 <>
463 <DialogHeader>
464 <DialogTitle>Couldn&rsquo;t import from {source.name}</DialogTitle>
465 <DialogDescription>{BROWSER_IMPORT_FAILURE_COPY[reason]}</DialogDescription>
466 </DialogHeader>
467 <DialogFooter>
468 <Button variant="outline" onClick={onClose}>
469 Close
470 </Button>
471 {onRetry ? <Button onClick={onRetry}>Try again</Button> : null}
472 </DialogFooter>
473 </>
474 );
475}
apps/web/src/components/settings/IntegrationsSettings.logic.test.ts+27-1
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId } from "@t3tools/contracts";
33
4import { browserProfileRemovalAvailable, clearBrowserProfileData } from "./IntegrationsSettings";4import {
5 browserProfileRemovalAvailable,
6 clearBrowserProfileData,
7 importFailureReason,
8} from "./IntegrationsSettings";
59
6const environmentId = "environment-a" as EnvironmentId;10const environmentId = "environment-a" as EnvironmentId;
7const secondEnvironmentId = "environment-b" as EnvironmentId;11const secondEnvironmentId = "environment-b" as EnvironmentId;
...@@ -72,3 +76,25 @@ describe("browserProfileRemovalAvailable", () => {...@@ -72,3 +76,25 @@ describe("browserProfileRemovalAvailable", () => {
72 expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false);76 expect(browserProfileRemovalAvailable(false, true, 1)).toBe(false);
73 });77 });
74});78});
79
80// Mirrors `BrowserImportFailedError.message`, which IPC flattens to a string
81// before the renderer sees it.
82const failure = (reason: string) => ({
83 message: `Importing cookies from safari failed: ${reason}.`,
84});
85
86describe("importFailureReason", () => {
87 it("recovers the reason token from the flattened message", () => {
88 expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");
89 expect(importFailureReason(failure("readFailed"))).toBe("readFailed");
90 // A settings write that fails after the cookies landed is its own case,
91 // not a read failure over a database that was in fact read.
92 expect(importFailureReason(failure("profileNotSaved"))).toBe("profileNotSaved");
93 expect(importFailureReason(failure("profileLimitReached"))).toBe("profileLimitReached");
94 });
95
96 it("falls back to readFailed for anything it cannot classify", () => {
97 expect(importFailureReason(new Error("something else entirely"))).toBe("readFailed");
98 expect(importFailureReason(undefined)).toBe("readFailed");
99 });
100});
apps/web/src/components/settings/IntegrationsSettings.tsx+432-165
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7 * @module IntegrationsSettings7 * @module IntegrationsSettings
8 */8 */
9import {9import {
10 BrowserImportFailureReason,
10 BROWSER_PROFILE_MAX_COUNT,11 BROWSER_PROFILE_MAX_COUNT,
11 type BrowserLinkTarget,12 type BrowserLinkTarget,
12 type BrowserProfile,13 type BrowserProfile,
...@@ -29,21 +30,32 @@ import {...@@ -29,21 +30,32 @@ import {
29 findBrowserProfile,30 findBrowserProfile,
30 isBuiltInBrowserProfileId,31 isBuiltInBrowserProfileId,
31 resolveBrowserProfiles,32 resolveBrowserProfiles,
33 type BrowserImportSource,
32 type PreviewAppearancePreference,34 type PreviewAppearancePreference,
33 type PreviewViewportSetting,35 type PreviewViewportSetting,
34} from "@t3tools/contracts";36} from "@t3tools/contracts";
35import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";37import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
36import { InfoIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react";38import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react";
37import { useState } from "react";39import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
38import type { ReactNode } from "react";
3940
40import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";41import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
42import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic";
41import { previewBridge } from "~/components/preview/previewBridge";43import { previewBridge } from "~/components/preview/previewBridge";
42import { cn, randomUUID } from "~/lib/utils";44import { cn, randomUUID } from "~/lib/utils";
43import { useEnvironments } from "~/state/environments";45import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
44import { isElectron } from "../../env";46import { isElectron } from "../../env";
4547
46import { Badge } from "../ui/badge";48import { Badge } from "../ui/badge";
49import {
50 Menu,
51 MenuGroup,
52 MenuGroupLabel,
53 MenuItem,
54 MenuPopup,
55 MenuSeparator,
56 MenuTrigger,
57} from "../ui/menu";
58import { toastManager } from "../ui/toast";
47import {59import {
48 AlertDialog,60 AlertDialog,
49 AlertDialogClose,61 AlertDialogClose,
...@@ -69,6 +81,7 @@ import { Switch } from "../ui/switch";...@@ -69,6 +81,7 @@ import { Switch } from "../ui/switch";
69import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";81import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
70import {82import {
71 getClientSettings,83 getClientSettings,
84 persistClientSettingsUpdate,
72 useClientSettings,85 useClientSettings,
73 useClientSettingsHydrated,86 useClientSettingsHydrated,
74 usePrimarySettings,87 usePrimarySettings,
...@@ -81,8 +94,9 @@ import {...@@ -81,8 +94,9 @@ import {
81 SettingsRow,94 SettingsRow,
82 SettingsSection,95 SettingsSection,
83} from "./settingsLayout";96} from "./settingsLayout";
84import { ITEM_ROW_INNER_CLASSNAME } from "./itemRows";
85import { searchableSetting } from "./settingsSearch";97import { searchableSetting } from "./settingsSearch";
98import { BrowserImportWizard, type WizardTarget } from "./BrowserImportWizard";
99import type { ImportOutcome } from "./browserImportWizard.logic";
86100
87const FILL_VALUE = "fill";101const FILL_VALUE = "fill";
88const RESPONSIVE_VALUE = "responsive";102const RESPONSIVE_VALUE = "responsive";
...@@ -133,6 +147,27 @@ const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> =...@@ -133,6 +147,27 @@ const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> =
133147
134const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;148const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;
135149
150/**
151 * IPC flattens the failure to its message, so the reason token travels inside
152 * it. Anything unrecognised reads as a plain read failure rather than leaking
153 * the raw message into a toast.
154 */
155/** Thrown from the post-import settings updater when the cap was hit meanwhile. */
156class ProfileLimitReachedError extends Error {
157 constructor() {
158 super("Browser profile limit reached.");
159 this.name = "ProfileLimitReachedError";
160 }
161}
162
163export const importFailureReason = (cause: unknown): BrowserImportFailureReason => {
164 const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
165 return (
166 BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ??
167 "readFailed"
168 );
169};
170
136const viewportSelectValue = (viewport: PreviewViewportSetting): string => {171const viewportSelectValue = (viewport: PreviewViewportSetting): string => {
137 if (viewport._tag === "fill") return FILL_VALUE;172 if (viewport._tag === "fill") return FILL_VALUE;
138 if (173 if (
...@@ -614,17 +649,34 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode...@@ -614,17 +649,34 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
614}649}
615650
616/**651/**
617 * Create, rename, and remove browser profiles.652 * Profile list, its header menu, and the import flow.
618 *653 *
619 * Built-ins render without controls: they are synthesized rather than stored,654 * One menu creates profiles and imports into them, because the two are the
620 * so there is nothing to rename and removing them would strand every tab that655 * same decision from the user's side: "I want a profile that has my Helium
621 * opened under them.656 * logins in it". Import targets include "New profile" so that case does not
657 * require creating one first and then finding a second control.
658 *
659 * Built-ins render without a rename field: they are synthesized rather than
660 * stored, so there is nothing to rename and removing them would strand every
661 * tab that opened under them.
662 *
663 * Sources are listed lazily on open: detection touches the other browser's
664 * files, and the answer changes while the app is running (quitting the browser
665 * clears `browserRunning`), so a value cached at mount would go stale.
622 */666 */
623function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {667function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
624 const userProfiles = useClientSettings((settings) => settings.browserProfiles);668 const userProfiles = useClientSettings((settings) => settings.browserProfiles);
669 const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
625 const settingsHydrated = useClientSettingsHydrated();670 const settingsHydrated = useClientSettingsHydrated();
626 const updateSettings = useUpdatePrimarySettings();671 const updateSettings = useUpdatePrimarySettings();
627 const { environments, isReady: environmentsReady } = useEnvironments();672 const { environments, isReady: environmentsReady } = useEnvironments();
673 const primaryEnvironment = usePrimaryEnvironment();
674 const [sources, setSources] = useState<ReadonlyArray<BrowserImportSource> | null>(null);
675 const [importSession, setImportSession] = useState<{
676 readonly source: BrowserImportSource;
677 readonly environmentId: EnvironmentId;
678 readonly environmentName: string;
679 } | null>(null);
628 const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);680 const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
629 const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);681 const [profileRemovalError, setProfileRemovalError] = useState<string | null>(null);
630 const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);682 const [profileRemovalInFlight, setProfileRemovalInFlight] = useState(false);
...@@ -633,25 +685,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -633,25 +685,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
633 environmentsReady,685 environmentsReady,
634 environments.length,686 environments.length,
635 );687 );
688 const importInFlightRef = useRef(false);
689 const [importInFlight, setImportInFlight] = useState(false);
636 const profileWritesDisabled = disabled || !settingsHydrated;690 const profileWritesDisabled = disabled || !settingsHydrated;
637691
638 const addProfile = () => {692 const profiles = resolveBrowserProfiles(userProfiles);
639 if (!settingsHydrated) return;693 // Incognito is deliberately not a row — it holds nothing to manage — so the
694 // default has to resolve against the list that renders. A stored
695 // `browserDefaultProfileId` of "incognito" would otherwise leave the section
696 // with no Default badge at all.
697 const listedProfiles = profiles.filter((profile) => profile.kind !== "incognito");
698 const resolvedDefaultId =
699 findBrowserProfile(listedProfiles, defaultProfileId)?.id ?? DEFAULT_BROWSER_PROFILE_ID;
700
701 const createProfile = (baseName: string) => {
702 if (!settingsHydrated || importInFlightRef.current) return undefined;
640 const currentProfiles = getClientSettings().browserProfiles;703 const currentProfiles = getClientSettings().browserProfiles;
641 if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return;704 // Checked against the live settings, not the rendered list: two clicks
642 const taken = new Set(resolveBrowserProfiles(currentProfiles).map((profile) => profile.name));705 // before a re-render would otherwise both pass the disabled control.
643 let name = "New profile";706 if (currentProfiles.length >= BROWSER_PROFILE_MAX_COUNT) return undefined;
644 for (let index = 2; taken.has(name); index += 1) name = `New profile ${index}`;707 const resolvedProfiles = resolveBrowserProfiles(currentProfiles);
645 updateSettings({708 const taken = new Set(resolvedProfiles.map((profile) => profile.name));
646 browserProfiles: [709 let name = baseName;
647 ...currentProfiles,710 for (let index = 2; taken.has(name); index += 1) name = `${baseName} ${index}`;
648 { id: `profile-${randomUUID()}`, name, kind: "persistent" as const },711 const profile = { id: `profile-${randomUUID()}`, name, kind: "persistent" as const };
649 ],712 updateSettings({ browserProfiles: [...currentProfiles, profile] });
650 });713 return profile;
651 };714 };
652715
653 const renameProfile = (id: string, next: string) => {716 const renameProfile = (id: string, next: string) => {
654 if (!settingsHydrated) return;717 if (!settingsHydrated || importInFlightRef.current) return;
655 const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);718 const name = next.trim().slice(0, BROWSER_PROFILE_NAME_MAX_LENGTH);
656 if (name === "") return;719 if (name === "") return;
657 const currentProfiles = getClientSettings().browserProfiles;720 const currentProfiles = getClientSettings().browserProfiles;
...@@ -662,8 +725,31 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -662,8 +725,31 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
662 });725 });
663 };726 };
664727
728 const clearProfileData = (id: string, name: string) => {
729 if (!settingsHydrated || importInFlightRef.current) return;
730 if (!previewBridge || !environmentsReady || environments.length === 0) {
731 toastManager.add({
732 type: "error",
733 title: `Could not clear ${name}'s data`,
734 description: "You're not connected to a server yet.",
735 });
736 return;
737 }
738 void clearBrowserProfileData(
739 previewBridge,
740 environments.map((environment) => environment.environmentId),
741 id,
742 )
743 .then(() => {
744 toastManager.add({ type: "success", title: `Cleared ${name}'s cookies and cache` });
745 })
746 .catch(() => {
747 toastManager.add({ type: "error", title: `Could not clear ${name}'s data` });
748 });
749 };
750
665 const removeProfile = async (id: string) => {751 const removeProfile = async (id: string) => {
666 if (!settingsHydrated) return;752 if (!settingsHydrated || importInFlightRef.current) return;
667 if (!removalAvailable) {753 if (!removalAvailable) {
668 setProfileRemovalError("Connect to an environment before removing this profile.");754 setProfileRemovalError("Connect to an environment before removing this profile.");
669 return;755 return;
...@@ -695,92 +781,330 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -695,92 +781,330 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
695 setProfilePendingRemoval(null);781 setProfilePendingRemoval(null);
696 };782 };
697783
784 // A browser that is not on this machine, or that this platform cannot import
785 // from at all, is left out rather than listed as a dead row: there is nothing
786 // to act on, and the menu is a list of things you can import from. Every
787 // other unavailable reason stays, since each names a step the user can take.
788 const importableSources = (sources ?? []).filter(
789 (source) =>
790 source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform",
791 );
792
793 // Refreshed without blanking the last result: the menu shows the cached list
794 // straight away so it doesn't reflow on open, and the source list is stable
795 // (names only) since choosing what to import happens in the wizard, not here.
796 const loadSources = useCallback(() => {
797 if (!previewBridge) return;
798 void previewBridge
799 .listBrowserImportSources()
800 .then(setSources)
801 .catch(() => setSources((previous) => previous ?? []));
802 }, []);
803
804 // Loaded once so the first open is instant instead of flashing a spinner.
805 useEffect(() => {
806 loadSources();
807 }, [loadSources]);
808
809 // Runs one import for the wizard. A new profile is registered only once the
810 // import succeeds — the cookies land in its partition first — so a blocked
811 // attempt never leaves an empty profile behind.
812 const runWizardImport = async (
813 source: BrowserImportSource,
814 environmentId: EnvironmentId,
815 input: { readonly sourceProfileDirectory: string; readonly target: WizardTarget },
816 ): Promise<ImportOutcome> => {
817 if (!previewBridge) return { kind: "blocked", reason: "sessionUnavailable" };
818 if (!settingsHydrated) return { kind: "blocked", reason: "sessionUnavailable" };
819 if (
820 input.target.kind === "existing" &&
821 !resolveBrowserProfiles(getClientSettings().browserProfiles).some(
822 (profile) => profile.id === input.target.profileId,
823 )
824 ) {
825 return { kind: "blocked", reason: "readFailed" };
826 }
827 if (importInFlightRef.current) return { kind: "blocked", reason: "readFailed" };
828 importInFlightRef.current = true;
829 setImportInFlight(true);
830 try {
831 const result = await previewBridge.importBrowserCookies({
832 environmentId,
833 sourceId: source.id,
834 sourceProfileDirectory: input.sourceProfileDirectory,
835 targetProfileId: input.target.profileId,
836 });
837 if (
838 input.target.kind === "existing" &&
839 !resolveBrowserProfiles(getClientSettings().browserProfiles).some(
840 (profile) => profile.id === input.target.profileId,
841 )
842 ) {
843 return { kind: "blocked", reason: "readFailed" };
844 }
845 let targetName: string;
846 if (input.target.kind === "new") {
847 // Registered only when something actually came over: an import that
848 // found no cookies should not leave a new, empty profile behind.
849 if (result.imported > 0) {
850 try {
851 const persisted = await persistClientSettingsUpdate((current) => {
852 const existing = current.browserProfiles.find(
853 (profile) => profile.id === input.target.profileId,
854 );
855 if (existing) return current;
856 // The wizard refuses a new target at the cap, but the cap can be
857 // reached while the import runs; the updater sees the newest
858 // settings, so this is the check that holds.
859 if (current.browserProfiles.length >= BROWSER_PROFILE_MAX_COUNT) {
860 throw new ProfileLimitReachedError();
861 }
862 const taken = new Set(
863 resolveBrowserProfiles(current.browserProfiles).map((profile) => profile.name),
864 );
865 let name = source.name;
866 for (let index = 2; taken.has(name); index += 1) name = `${source.name} ${index}`;
867 return {
868 ...current,
869 browserProfiles: [
870 ...current.browserProfiles,
871 { id: input.target.profileId, name, kind: "persistent" as const },
872 ],
873 };
874 });
875 targetName =
876 persisted.browserProfiles.find((profile) => profile.id === input.target.profileId)
877 ?.name ?? source.name;
878 } catch (cause) {
879 // This target id belongs only to the attempted new profile. Clear
880 // its partition so a failed registration cannot strand imported
881 // cookies behind a profile that disappears on restart.
882 await clearBrowserProfileData(
883 previewBridge,
884 [environmentId],
885 input.target.profileId,
886 ).catch(() => undefined);
887 // Not a read failure: the cookies came over and were cleared again
888 // because the profile could not be kept. Name that, in the same
889 // token form `importFailureReason` recovers from a bridge error.
890 const reason =
891 cause instanceof ProfileLimitReachedError ? "profileLimitReached" : "profileNotSaved";
892 throw new Error(`Importing cookies from ${source.id} failed: ${reason}.`, { cause });
893 }
894 } else {
895 targetName = source.name;
896 }
897 } else {
898 targetName = input.target.name;
899 }
900 return {
901 kind: "imported",
902 imported: result.imported,
903 skipped: result.skipped,
904 skippedDomains: result.skippedDomains,
905 targetName,
906 };
907 } catch (cause) {
908 return { kind: "blocked", reason: importFailureReason(cause) };
909 } finally {
910 importInFlightRef.current = false;
911 setImportInFlight(false);
912 }
913 };
914
915 // Re-checks a source's availability after the user quits the browser, and
916 // keeps the cached list in step so the menu reflects it too.
917 const refreshImportSource = async (
918 sourceId: BrowserImportSource["id"],
919 ): Promise<BrowserImportSource | undefined> => {
920 if (!previewBridge) return undefined;
921 try {
922 const latest = await previewBridge.listBrowserImportSources();
923 setSources(latest);
924 return latest.find((source) => source.id === sourceId);
925 } catch {
926 return undefined;
927 }
928 };
929
930 const atProfileLimit = userProfiles.length >= BROWSER_PROFILE_MAX_COUNT;
931
698 return (932 return (
699 <SettingsRow933 <SettingsRow
700 {...searchableSetting("browser-profiles")}934 {...searchableSetting("browser-profiles")}
701 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."935 description="Each profile has its own cookies and logins, so you can stay signed in to different accounts at once."
702 control={936 control={
703 <Button937 <Menu onOpenChange={(open) => open && loadSources()}>
704 size="sm"938 <MenuTrigger
705 variant="outline"939 render={
706 disabled={profileWritesDisabled || userProfiles.length >= BROWSER_PROFILE_MAX_COUNT}940 <Button
707 onClick={addProfile}941 size="sm"
708 >942 variant="outline"
709 <PlusIcon />943 disabled={profileWritesDisabled || importInFlight}
710 Add profile944 />
711 </Button>945 }
946 >
947 <PlusIcon />
948 Add profile
949 </MenuTrigger>
950 <MenuPopup align="end" className="min-w-56">
951 <MenuItem
952 disabled={!settingsHydrated || atProfileLimit}
953 onClick={() => createProfile("New profile")}
954 >
955 Blank profile
956 </MenuItem>
957 {atProfileLimit ? (
958 <MenuItem disabled>You&rsquo;ve reached the profile limit</MenuItem>
959 ) : null}
960 <MenuSeparator />
961 <MenuGroup>
962 <MenuGroupLabel>Import from</MenuGroupLabel>
963 {sources === null ? (
964 <MenuItem disabled>Looking for browsers…</MenuItem>
965 ) : importableSources.length === 0 ? (
966 <MenuItem disabled>No supported browsers found</MenuItem>
967 ) : (
968 // Every source is a plain row — running, needs-permission and
969 // ready all look the same here. The wizard picks up whatever
970 // state the source is in and walks the user forward from there.
971 <>
972 {importableSources.map((source) => (
973 <MenuItem
974 key={source.id}
975 disabled={!settingsHydrated || primaryEnvironment == null}
976 onClick={() => {
977 if (!settingsHydrated || primaryEnvironment == null) return;
978 setImportSession({
979 source,
980 environmentId: primaryEnvironment.environmentId,
981 environmentName: resolveEnvironmentOptionLabel({
982 isPrimary: true,
983 environmentId: primaryEnvironment.environmentId,
984 runtimeLabel: primaryEnvironment.label,
985 }),
986 });
987 }}
988 >
989 {source.name}
990 </MenuItem>
991 ))}
992 {primaryEnvironment == null ? (
993 <MenuItem disabled>Connect to an environment to import cookies</MenuItem>
994 ) : null}
995 </>
996 )}
997 </MenuGroup>
998 </MenuPopup>
999 </Menu>
712 }1000 }
713 >1001 >
714 {/*1002 {/*
715 Each profile is its own bounded row, and the list carries the bottom1003 The bordered container groups rows unambiguously at any width, and
716 spacing `SettingsRow` leaves to its children (`pt-3 pb-1`). Bare rows1004 carries the bottom spacing `SettingsRow` leaves to its children
717 stack on narrow viewports with a larger gap inside a row than between1005 (`pt-3 pb-1`).
718 rows, which reads as the remove button belonging to the profile below.
719 */}1006 */}
720 <div className="mt-2 space-y-1 pb-2">1007 <div className="mt-2 mb-2 overflow-hidden rounded-lg border border-border/60">
721 {resolveBrowserProfiles(userProfiles).map((profile) => {1008 {listedProfiles.map((profile, index) => {
722 const builtIn = isBuiltInBrowserProfileId(profile.id);1009 const builtIn = isBuiltInBrowserProfileId(profile.id);
1010 const isDefault = profile.id === resolvedDefaultId;
723 return (1011 return (
724 <div1012 <div
725 key={profile.id}1013 key={profile.id}
726 className={cn(1014 className={cn(
727 ITEM_ROW_INNER_CLASSNAME,1015 "flex items-center gap-3 px-3 py-2",
728 "rounded-lg border border-border/60 px-3 py-2",1016 index > 0 && "border-t border-border/60",
729 )}1017 )}
730 >1018 >
731 {builtIn ? (1019 <span className="flex min-w-0 flex-1 items-center gap-2">
732 // Dimmed here rather than on the list, which is the only1020 {builtIn ? (
733 // content in the row without a disabled treatment of its own:1021 // Dimmed here rather than on the table: a wrapper-level dim
734 // a wrapper-level dim would stack with the rename field's and1022 // stacks with the rename field's and the row menu button's
735 // the remove button's, landing them near 0.41 while every1023 // own, landing them near 0.41 while every other disabled
736 // other disabled control in the block sits at 0.64.1024 // control in the block sits at 0.64.
737 <span1025 <span
738 className={cn(1026 className={cn(
739 "flex min-w-0 items-center gap-2 text-sm text-foreground",1027 "truncate text-sm text-foreground",
740 profileWritesDisabled && "opacity-64",1028 profileWritesDisabled && "opacity-64",
741 )}1029 )}
742 >1030 >
743 {profile.name}1031 {profile.name}
744 <Badge variant="outline">1032 </span>
745 {profile.kind === "incognito" ? "Ephemeral" : "Built-in"}1033 ) : (
746 </Badge>1034 <DraftInput
747 </span>1035 nativeInput
748 ) : (1036 size="sm"
749 <DraftInput1037 className="w-full max-w-56"
750 nativeInput1038 aria-label={`Rename ${profile.name}`}
751 size="sm"1039 disabled={profileWritesDisabled || importInFlight}
752 className="w-full sm:w-64"1040 maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
753 aria-label={`Rename ${profile.name}`}1041 value={profile.name}
754 disabled={profileWritesDisabled}1042 onCommit={(next) => renameProfile(profile.id, next)}
755 maxLength={BROWSER_PROFILE_NAME_MAX_LENGTH}
756 value={profile.name}
757 onCommit={(next) => renameProfile(profile.id, next)}
758 />
759 )}
760 {builtIn ? null : (
761 <Tooltip>
762 <TooltipTrigger
763 render={
764 <span className="inline-flex" {...(!removalAvailable ? { tabIndex: 0 } : {})}>
765 <Button
766 size="icon-xs"
767 variant="ghost-muted"
768 disabled={profileWritesDisabled || !removalAvailable}
769 aria-label={`Remove ${profile.name}`}
770 onClick={() => setProfilePendingRemoval(profile)}
771 >
772 <Trash2Icon />
773 </Button>
774 </span>
775 }
776 />1043 />
777 <TooltipPopup side="top">1044 )}
778 {removalAvailable1045 {/*
779 ? "Remove profile and its data"1046 Dimmed with the rest of the row: a `Badge` has no disabled
780 : "Connect to an environment to remove this profile"}1047 treatment of its own, so a solid `bg-primary` pill would
781 </TooltipPopup>1048 otherwise sit at full strength beside a name, rename field
782 </Tooltip>1049 and menu button that are all at 0.64.
783 )}1050 */}
1051 {isDefault ? (
1052 <Badge className={cn(profileWritesDisabled && "opacity-64")}>Default</Badge>
1053 ) : null}
1054 </span>
1055 <Menu>
1056 <MenuTrigger
1057 render={
1058 <Button
1059 size="icon-xs"
1060 variant="ghost-muted"
1061 disabled={profileWritesDisabled || importInFlight}
1062 aria-label={`${profile.name} options`}
1063 />
1064 }
1065 >
1066 <MoreVertical />
1067 </MenuTrigger>
1068 <MenuPopup align="end" className="min-w-44">
1069 <MenuItem
1070 disabled={!settingsHydrated || isDefault}
1071 onClick={() => {
1072 if (settingsHydrated) {
1073 updateSettings({ browserDefaultProfileId: profile.id });
1074 }
1075 }}
1076 >
1077 Set as default
1078 </MenuItem>
1079 <MenuItem
1080 disabled={!settingsHydrated || !removalAvailable}
1081 onClick={() => clearProfileData(profile.id, profile.name)}
1082 >
1083 Clear cookies and cache
1084 </MenuItem>
1085 {builtIn ? null : (
1086 <MenuItem
1087 variant="destructive"
1088 disabled={!settingsHydrated || !removalAvailable}
1089 onClick={() => {
1090 if (settingsHydrated) setProfilePendingRemoval(profile);
1091 }}
1092 >
1093 Remove profile and data
1094 </MenuItem>
1095 )}
1096 {!removalAvailable ? (
1097 <>
1098 <MenuSeparator />
1099 <MenuItem disabled>
1100 {environmentsReady
1101 ? "Connect to an environment to clear profile data"
1102 : "Checking environments…"}
1103 </MenuItem>
1104 </>
1105 ) : null}
1106 </MenuPopup>
1107 </Menu>
784 </div>1108 </div>
785 );1109 );
786 })}1110 })}
...@@ -798,8 +1122,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -798,8 +1122,8 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
798 <AlertDialogHeader>1122 <AlertDialogHeader>
799 <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>1123 <AlertDialogTitle>Remove “{profilePendingRemoval?.name}”?</AlertDialogTitle>
800 <AlertDialogDescription>1124 <AlertDialogDescription>
801 Its cookies, logins, and cache are deleted with it. Tabs already open in this profile1125 Its cookies and logins are deleted. Tabs already open in this profile stay open until
802 stay open until you close them.1126 you close them.
803 </AlertDialogDescription>1127 </AlertDialogDescription>
804 {profileRemovalError ? (1128 {profileRemovalError ? (
805 <p aria-live="polite" className="text-sm text-destructive">1129 <p aria-live="polite" className="text-sm text-destructive">
...@@ -833,86 +1157,29 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -833,86 +1157,29 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
833 </AlertDialogFooter>1157 </AlertDialogFooter>
834 </AlertDialogPopup>1158 </AlertDialogPopup>
835 </AlertDialog>1159 </AlertDialog>
1160 {importSession ? (
1161 <BrowserImportWizard
1162 source={importSession.source}
1163 destinationEnvironmentName={importSession.environmentName}
1164 targetProfiles={listedProfiles.map((profile) => ({ id: profile.id, name: profile.name }))}
1165 canCreateProfile={settingsHydrated && !atProfileLimit}
1166 onImport={(input) =>
1167 runWizardImport(importSession.source, importSession.environmentId, input)
1168 }
1169 onRefreshSource={() => refreshImportSource(importSession.source.id)}
1170 onClose={() => setImportSession(null)}
1171 />
1172 ) : null}
836 </SettingsRow>1173 </SettingsRow>
837 );1174 );
838}1175}
8391176
840function BrowserDefaultProfileSetting({ disabled }: { readonly disabled: boolean }) {
841 const userProfiles = useClientSettings((settings) => settings.browserProfiles);
842 const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
843 const settingsHydrated = useClientSettingsHydrated();
844 const updateSettings = useUpdatePrimarySettings();
845 const profileWritesDisabled = disabled || !settingsHydrated;
846 // Incognito is deliberately absent: as a default it would open every tab
847 // into storage that is discarded on close.
848 const profiles = resolveBrowserProfiles(userProfiles).filter(
849 (profile) => profile.kind !== "incognito",
850 );
851 const selected = findBrowserProfile(profiles, defaultProfileId) ?? profiles[0];
852
853 return (
854 <SettingsRow
855 {...searchableSetting("browser-default-profile")}
856 description="Profile new browser tabs open under, including tabs an agent opens."
857 resetAction={
858 !profileWritesDisabled && defaultProfileId !== DEFAULT_BROWSER_PROFILE_ID ? (
859 <SettingResetButton
860 label="default browser profile"
861 onClick={() => {
862 if (settingsHydrated) {
863 updateSettings({ browserDefaultProfileId: DEFAULT_BROWSER_PROFILE_ID });
864 }
865 }}
866 />
867 ) : null
868 }
869 control={
870 <Select
871 disabled={profileWritesDisabled}
872 value={selected?.id ?? DEFAULT_BROWSER_PROFILE_ID}
873 onValueChange={(value) => {
874 if (settingsHydrated && value !== null) {
875 updateSettings({ browserDefaultProfileId: value });
876 }
877 }}
878 >
879 <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default browser profile">
880 <SelectValue>{selected?.name ?? "Default"}</SelectValue>
881 </SelectTrigger>
882 {/*
883 Capped and truncated like the tab menu's profile list: names are
884 user-supplied and run to 48 characters, which would otherwise
885 widen the popup to fit the longest one. The cap goes on the glass
886 shell (`popupClassName`), and the list fills that shell so it is
887 never narrower than the trigger it opens from — the same floor
888 every other settings select keeps. `ItemText` renders a block, so
889 the label must be a block too for `truncate` to apply.
890 */}
891 <SelectPopup
892 align="end"
893 alignItemWithTrigger={false}
894 popupClassName="max-w-64"
895 className="w-full"
896 >
897 {profiles.map((profile) => (
898 <SelectItem hideIndicator key={profile.id} value={profile.id}>
899 <span className="block min-w-0 truncate">{profile.name}</span>
900 </SelectItem>
901 ))}
902 </SelectPopup>
903 </Select>
904 }
905 />
906 );
907}
908
909export function IntegrationsSettingsPanel() {1177export function IntegrationsSettingsPanel() {
910 // Client-local preview defaults are editable only where the preview exists.1178 // Client-local preview defaults are editable only where the preview exists.
911 const previewDefaultsDisabled = !isElectron;1179 const previewDefaultsDisabled = !isElectron;
912 const previewDefaults = (1180 const previewDefaults = (
913 <>1181 <>
914 <BrowserProfilesSetting disabled={previewDefaultsDisabled} />1182 <BrowserProfilesSetting disabled={previewDefaultsDisabled} />
915 <BrowserDefaultProfileSetting disabled={previewDefaultsDisabled} />
916 <BrowserViewportSetting disabled={previewDefaultsDisabled} />1183 <BrowserViewportSetting disabled={previewDefaultsDisabled} />
917 <BrowserZoomSetting disabled={previewDefaultsDisabled} />1184 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
918 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />1185 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
apps/web/src/components/settings/browserImportWizard.logic.test.ts created+192
...@@ -0,0 +1,192 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { BrowserImportSource } from "@t3tools/contracts";
3
4import {
5 canCloseWizard,
6 initialWizardStep,
7 initialTargetSelection,
8 isRetryableReason,
9 formatSkippedDomains,
10 outcomeToStep,
11 refreshedSourceProfileDirectory,
12 refreshedSourceStep,
13 resolveWizardTarget,
14} from "./browserImportWizard.logic";
15
16describe("wizard target selection", () => {
17 it("treats an existing profile whose id is new as an existing target", () => {
18 const profiles = [{ id: "new", name: "Existing new" }];
19 const selection = initialTargetSelection(false, profiles);
20
21 expect(selection).toEqual({ kind: "existing", profileId: "new" });
22 expect(resolveWizardTarget(selection, "generated", profiles)).toEqual({
23 kind: "existing",
24 profileId: "new",
25 name: "Existing new",
26 });
27 });
28
29 it("rejects an existing target that is no longer listed", () => {
30 expect(
31 resolveWizardTarget({ kind: "existing", profileId: "removed" }, "generated", [
32 { id: "default", name: "Default" },
33 ]),
34 ).toBeUndefined();
35 });
36});
37
38const source = (over: Partial<BrowserImportSource> = {}): BrowserImportSource => ({
39 id: "helium",
40 name: "Helium",
41 profiles: [{ directory: "Default", name: "You" }],
42 ...over,
43});
44
45describe("initialWizardStep", () => {
46 it("opens on the quit screen when the browser is running", () => {
47 expect(initialWizardStep(source({ unavailable: "browserRunning" }))).toEqual({ step: "quit" });
48 });
49
50 it("opens on configure when the source is ready", () => {
51 expect(initialWizardStep(source())).toEqual({ step: "configure" });
52 });
53
54 it("blocks on a reason nothing local can fix", () => {
55 expect(initialWizardStep(source({ unavailable: "unsupportedPlatform" }))).toEqual({
56 step: "blocked",
57 reason: "unsupportedPlatform",
58 });
59 });
60
61 it("blocks a ready source that reports no importable profiles", () => {
62 expect(initialWizardStep(source({ profiles: [] }))).toEqual({
63 step: "blocked",
64 reason: "unknownSourceProfile",
65 });
66 });
67
68 it("handles source availability before checking its profiles", () => {
69 expect(
70 initialWizardStep(source({ profiles: [], unavailable: "needsKeychainApproval" })),
71 ).toEqual({ step: "blocked", reason: "needsKeychainApproval" });
72 });
73});
74
75describe("canCloseWizard", () => {
76 it("keeps the wizard open while an import writes its target profile", () => {
77 expect(canCloseWizard({ step: "importing" })).toBe(false);
78 expect(canCloseWizard({ step: "configure" })).toBe(true);
79 expect(canCloseWizard({ step: "blocked", reason: "readFailed" })).toBe(true);
80 });
81});
82
83describe("outcomeToStep", () => {
84 it("lands on done after a successful import", () => {
85 expect(
86 outcomeToStep({
87 kind: "imported",
88 imported: 12,
89 skipped: 3,
90 skippedDomains: ["example.com"],
91 targetName: "Work",
92 }),
93 ).toEqual({
94 step: "done",
95 imported: 12,
96 skipped: 3,
97 skippedDomains: ["example.com"],
98 targetName: "Work",
99 });
100 });
101
102 it("routes a reopened browser back to the quit screen", () => {
103 expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" });
104 });
105
106 it("surfaces every other failure on the blocked screen", () => {
107 expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({
108 step: "blocked",
109 reason: "readFailed",
110 });
111 });
112});
113
114describe("refreshedSourceStep", () => {
115 it("moves to configure once a quit browser frees its cookies", () => {
116 expect(refreshedSourceStep(source())).toEqual({ step: "configure" });
117 });
118
119 it("stays on quit while the browser is still running", () => {
120 expect(refreshedSourceStep(source({ unavailable: "browserRunning" }))).toEqual({
121 step: "quit",
122 });
123 });
124
125 it("blocks when the source vanished from the list", () => {
126 expect(refreshedSourceStep(undefined)).toEqual({ step: "blocked", reason: "unknownSource" });
127 });
128
129 it("blocks when the refreshed source has no profiles", () => {
130 expect(refreshedSourceStep(source({ profiles: [] }))).toEqual({
131 step: "blocked",
132 reason: "unknownSourceProfile",
133 });
134 });
135});
136
137describe("refreshedSourceProfileDirectory", () => {
138 const refreshed = source({
139 profiles: [
140 { directory: "Default", name: "Personal" },
141 { directory: "Profile 2", name: "Work" },
142 ],
143 });
144
145 it("preserves a selected non-first profile after the browser quits", () => {
146 expect(refreshedSourceProfileDirectory("Profile 2", refreshed)).toBe("Profile 2");
147 });
148
149 it("falls back only when the old profile vanished and clears an empty result", () => {
150 expect(refreshedSourceProfileDirectory("Removed", refreshed)).toBe("Default");
151 expect(refreshedSourceProfileDirectory("Removed", source({ profiles: [] }))).toBe("");
152 });
153});
154
155describe("isRetryableReason", () => {
156 it("offers a retry for failures a second attempt can clear", () => {
157 expect(isRetryableReason("needsKeychainApproval")).toBe(true);
158 expect(isRetryableReason("readFailed")).toBe(true);
159 });
160
161 it("does not offer a retry for a permanent failure", () => {
162 expect(isRetryableReason("unsupportedPlatform")).toBe(false);
163 expect(isRetryableReason("unknownSourceProfile")).toBe(false);
164 // Retrying the same new-profile import cannot lower the profile count.
165 expect(isRetryableReason("profileLimitReached")).toBe(false);
166 });
167
168 it("offers a retry once the user has signed in to create the missing key", () => {
169 // The blocked copy tells the user to sign in and retry, so the screen
170 // has to offer the retry it asks for.
171 expect(isRetryableReason("keychainItemMissing")).toBe(true);
172 });
173
174 it("offers a retry when the cookies landed but the new profile was not saved", () => {
175 expect(isRetryableReason("profileNotSaved")).toBe(true);
176 });
177});
178
179describe("formatSkippedDomains", () => {
180 it("joins a short list naturally", () => {
181 expect(formatSkippedDomains([])).toBe("");
182 expect(formatSkippedDomains(["a.com"])).toBe("a.com");
183 expect(formatSkippedDomains(["a.com", "b.com"])).toBe("a.com and b.com");
184 expect(formatSkippedDomains(["a.com", "b.com", "c.com"])).toBe("a.com, b.com and c.com");
185 });
186
187 it("summarizes a long list", () => {
188 expect(formatSkippedDomains(["a.com", "b.com", "c.com", "d.com", "e.com"])).toBe(
189 "a.com, b.com, c.com and 2 more",
190 );
191 });
192});
apps/web/src/components/settings/browserImportWizard.logic.ts created+154
...@@ -0,0 +1,154 @@
1import type { BrowserImportFailureReason, BrowserImportSource } from "@t3tools/contracts";
2
3export interface WizardTargetProfile {
4 readonly id: string;
5 readonly name: string;
6}
7
8export type WizardTarget =
9 | { readonly kind: "new"; readonly profileId: string }
10 | { readonly kind: "existing"; readonly profileId: string; readonly name: string };
11
12export type WizardTargetSelection =
13 | { readonly kind: "new" }
14 | { readonly kind: "existing"; readonly profileId: string };
15
16export function initialTargetSelection(
17 canCreateProfile: boolean,
18 targetProfiles: ReadonlyArray<WizardTargetProfile>,
19): WizardTargetSelection {
20 if (canCreateProfile) return { kind: "new" };
21 const first = targetProfiles[0];
22 return first ? { kind: "existing", profileId: first.id } : { kind: "new" };
23}
24
25export function resolveWizardTarget(
26 selection: WizardTargetSelection,
27 newProfileId: string,
28 targetProfiles: ReadonlyArray<WizardTargetProfile>,
29): WizardTarget | undefined {
30 if (selection.kind === "new") return { kind: "new", profileId: newProfileId };
31 const profile = targetProfiles.find((candidate) => candidate.id === selection.profileId);
32 if (profile === undefined) return undefined;
33 return {
34 kind: "existing",
35 profileId: selection.profileId,
36 name: profile.name,
37 };
38}
39
40/**
41 * What the import wizard produces once it has actually tried to import. The
42 * parent runs the import and classifies the result; the wizard only reacts to
43 * it, which keeps the step transitions pure and testable.
44 */
45export type ImportOutcome =
46 | {
47 readonly kind: "imported";
48 readonly imported: number;
49 readonly skipped: number;
50 readonly skippedDomains: ReadonlyArray<string>;
51 readonly targetName: string;
52 }
53 | { readonly kind: "blocked"; readonly reason: BrowserImportFailureReason };
54
55/**
56 * The wizard's screens. Every one is a place the user can act from — there are
57 * no dead ends. `blocked` covers the reasons no local step recovers.
58 */
59export type WizardStep =
60 | { readonly step: "quit" }
61 | { readonly step: "configure" }
62 | { readonly step: "checking" }
63 | { readonly step: "importing" }
64 | {
65 readonly step: "done";
66 readonly imported: number;
67 readonly skipped: number;
68 readonly skippedDomains: ReadonlyArray<string>;
69 readonly targetName: string;
70 }
71 | { readonly step: "blocked"; readonly reason: BrowserImportFailureReason };
72
73/** The import owns its target partition until the write finishes. */
74export function canCloseWizard(step: WizardStep): boolean {
75 return step.step !== "importing";
76}
77
78/**
79 * Where the wizard opens for a source. A running browser is the one thing we
80 * know up front, from the source listing; everything else is discovered by
81 * trying, so the wizard starts by letting the user choose what to import.
82 */
83export function initialWizardStep(source: BrowserImportSource): WizardStep {
84 if (source.unavailable === "browserRunning") return { step: "quit" };
85 if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable };
86 if (source.profiles.length === 0) return { step: "blocked", reason: "unknownSourceProfile" };
87 return { step: "configure" };
88}
89
90/** Where an attempted import lands the wizard, by how it turned out. */
91export function outcomeToStep(outcome: ImportOutcome): WizardStep {
92 if (outcome.kind === "imported") {
93 return {
94 step: "done",
95 imported: outcome.imported,
96 skipped: outcome.skipped,
97 skippedDomains: outcome.skippedDomains,
98 targetName: outcome.targetName,
99 };
100 }
101 // A browser that reopened mid-import routes back to the quit screen; every
102 // other failure surfaces on the blocked screen, which offers a retry when
103 // one could help.
104 if (outcome.reason === "browserRunning") return { step: "quit" };
105 return { step: "blocked", reason: outcome.reason };
106}
107
108/** Where a fresh availability check lands the wizard after the user quits. */
109export function refreshedSourceStep(source: BrowserImportSource | undefined): WizardStep {
110 if (source === undefined) return { step: "blocked", reason: "unknownSource" };
111 return initialWizardStep(source);
112}
113
114/** Preserve the chosen source profile when a post-quit refresh still lists it. */
115export function refreshedSourceProfileDirectory(
116 currentDirectory: string,
117 source: BrowserImportSource,
118): string {
119 if (source.profiles.some((profile) => profile.directory === currentDirectory)) {
120 return currentDirectory;
121 }
122 return source.profiles[0]?.directory ?? "";
123}
124
125/**
126 * Whether retrying could clear a failure. The keychain prompt can be approved
127 * on a second try, a missing key appears once the user signs in to the
128 * browser (which is what its copy asks for), and a read or session error may
129 * be transient; an unsupported browser will not change, so it gets no retry
130 * button.
131 */
132export function isRetryableReason(reason: BrowserImportFailureReason): boolean {
133 switch (reason) {
134 case "needsKeychainApproval":
135 case "keychainItemMissing":
136 case "readFailed":
137 case "sessionUnavailable":
138 case "profileNotSaved":
139 return true;
140 default:
141 return false;
142 }
143}
144
145/**
146 * Names the sites whose cookies were skipped: "example.com and google.com",
147 * or "a, b, c and 4 more" past a few, so the line stays short.
148 */
149export function formatSkippedDomains(domains: ReadonlyArray<string>): string {
150 if (domains.length === 0) return "";
151 if (domains.length === 1) return domains[0]!;
152 if (domains.length <= 3) return `${domains.slice(0, -1).join(", ")} and ${domains.at(-1)}`;
153 return `${domains.slice(0, 3).join(", ")} and ${domains.length - 3} more`;
154}
apps/web/src/components/settings/settingsSearch.test.ts+8
...@@ -217,4 +217,12 @@ describe("searchSettings", () => {...@@ -217,4 +217,12 @@ describe("searchSettings", () => {
217 });217 });
218 expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" });218 expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" });
219 });219 });
220
221 it("finds the default browser profile action in the profiles list", () => {
222 expect(searchSettings("default profile")[0]).toMatchObject({
223 id: "browser-default-profile",
224 to: "/settings/integrations",
225 targetId: "browser-profiles",
226 });
227 });
220});228});
apps/web/src/components/settings/settingsSearch.ts+1-1
...@@ -347,7 +347,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -347,7 +347,7 @@ export const SETTINGS_SEARCH_ITEMS = [
347 id: "browser-default-profile",347 id: "browser-default-profile",
348 title: "Default browser profile",348 title: "Default browser profile",
349 to: "/settings/integrations",349 to: "/settings/integrations",
350 targetId: "browser",350 targetId: "browser-profiles",
351 },351 },
352 {352 {
353 id: "browser-default-viewport",353 id: "browser-default-viewport",
apps/web/src/hooks/useSettings.test.ts+152-2
...@@ -4,9 +4,159 @@ import {...@@ -4,9 +4,159 @@ import {
4 ProviderInstanceId,4 ProviderInstanceId,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";6import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
7import { describe, expect, it } from "vite-plus/test";7import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
88
9import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings";9import {
10 __resetClientSettingsPersistenceForTests,
11 __setClientSettingsForTests,
12 getClientSettings,
13 mergeEnvironmentSettings,
14 persistClientSettingsPatch,
15 persistClientSettingsUpdate,
16 resolveEnvironmentIdentificationMode,
17} from "./useSettings";
18
19beforeEach(() => {
20 __resetClientSettingsPersistenceForTests();
21});
22
23describe("persistClientSettingsUpdate", () => {
24 it("publishes the update only after persistence succeeds", async () => {
25 let finishPersistence!: () => void;
26 const persistence = new Promise<void>((resolve) => {
27 finishPersistence = resolve;
28 });
29 const setClientSettings = vi.fn(() => persistence);
30 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
31
32 const pending = persistClientSettingsUpdate(
33 (current) => ({
34 ...current,
35 timestampFormat: "12-hour",
36 }),
37 setClientSettings,
38 );
39
40 expect(getClientSettings().timestampFormat).toBe(DEFAULT_CLIENT_SETTINGS.timestampFormat);
41 finishPersistence();
42 await expect(pending).resolves.toMatchObject({ timestampFormat: "12-hour" });
43 expect(getClientSettings().timestampFormat).toBe("12-hour");
44 });
45
46 it("keeps the current snapshot and propagates persistence failure", async () => {
47 const failure = new Error("disk full");
48 const setClientSettings = vi.fn().mockRejectedValue(failure);
49 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
50
51 await expect(
52 persistClientSettingsUpdate(
53 (current) => ({ ...current, timestampFormat: "12-hour" }),
54 setClientSettings,
55 ),
56 ).rejects.toBe(failure);
57 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
58 });
59
60 it("preserves an optimistic write made while an awaited update persists", async () => {
61 let finishFirstPersistence!: () => void;
62 let durableSettings = DEFAULT_CLIENT_SETTINGS;
63 const firstPersistence = new Promise<void>((resolve) => {
64 finishFirstPersistence = resolve;
65 });
66 const persist = vi
67 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
68 .mockImplementationOnce((settings) =>
69 firstPersistence.then(() => {
70 durableSettings = settings;
71 }),
72 )
73 .mockImplementation(async (settings) => {
74 durableSettings = settings;
75 });
76 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
77 const importedProfile = { id: "profile-import", name: "Imported", kind: "persistent" as const };
78
79 const pending = persistClientSettingsUpdate(
80 (current) => ({
81 ...current,
82 browserProfiles: [...current.browserProfiles, importedProfile],
83 }),
84 persist,
85 );
86 await Promise.resolve();
87 persistClientSettingsPatch({ wordWrap: false }, persist);
88 finishFirstPersistence();
89 await pending;
90 await Promise.resolve();
91
92 expect(persist).toHaveBeenCalledTimes(3);
93 expect(persist.mock.calls[1]?.[0]).toMatchObject({
94 wordWrap: false,
95 });
96 expect(persist.mock.calls[1]?.[0].browserProfiles).toContainEqual(importedProfile);
97 expect(durableSettings.wordWrap).toBe(false);
98 expect(durableSettings.browserProfiles).toContainEqual(importedProfile);
99 expect(getClientSettings().wordWrap).toBe(false);
100 expect(getClientSettings().browserProfiles).toContainEqual(importedProfile);
101 });
102
103 it("orders an awaited update after an older optimistic write", async () => {
104 let finishOldWrite!: () => void;
105 let durableSettings = DEFAULT_CLIENT_SETTINGS;
106 const oldWrite = new Promise<void>((resolve) => {
107 finishOldWrite = resolve;
108 });
109 const persist = vi
110 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
111 .mockImplementationOnce((settings) =>
112 oldWrite.then(() => {
113 durableSettings = settings;
114 }),
115 )
116 .mockImplementation(async (settings) => {
117 durableSettings = settings;
118 });
119 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
120
121 persistClientSettingsPatch({ wordWrap: false }, persist);
122 const importedProfile = { id: "profile-import", name: "Imported", kind: "persistent" as const };
123 const registration = persistClientSettingsUpdate(
124 (current) => ({
125 ...current,
126 browserProfiles: [...current.browserProfiles, importedProfile],
127 }),
128 persist,
129 );
130 await Promise.resolve();
131 expect(persist).toHaveBeenCalledTimes(1);
132
133 finishOldWrite();
134 await registration;
135
136 expect(persist).toHaveBeenCalledTimes(2);
137 expect(durableSettings.wordWrap).toBe(false);
138 expect(durableSettings.browserProfiles).toContainEqual(importedProfile);
139 });
140
141 it("continues the queue after a rejected write", async () => {
142 const failure = new Error("disk full");
143 const persist = vi
144 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
145 .mockRejectedValueOnce(failure)
146 .mockResolvedValue(undefined);
147 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
148
149 await expect(
150 persistClientSettingsUpdate(
151 (current) => ({ ...current, timestampFormat: "12-hour" }),
152 persist,
153 ),
154 ).rejects.toBe(failure);
155 await expect(
156 persistClientSettingsUpdate((current) => ({ ...current, wordWrap: false }), persist),
157 ).resolves.toMatchObject({ wordWrap: false });
158 });
159});
10160
11describe("resolveEnvironmentIdentificationMode", () => {161describe("resolveEnvironmentIdentificationMode", () => {
12 it("keeps identification hidden until client settings hydrate", () => {162 it("keeps identification hidden until client settings hydrate", () => {
apps/web/src/hooks/useSettings.ts+64-14
...@@ -57,6 +57,7 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;...@@ -57,6 +57,7 @@ let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
57let clientSettingsHydrated = false;57let clientSettingsHydrated = false;
58let clientSettingsHydrationPromise: Promise<void> | null = null;58let clientSettingsHydrationPromise: Promise<void> | null = null;
59let clientSettingsHydrationGeneration = 0;59let clientSettingsHydrationGeneration = 0;
60let clientSettingsPersistenceQueue: Promise<void> = Promise.resolve();
6061
61function emitClientSettingsChange() {62function emitClientSettingsChange() {
62 for (const listener of clientSettingsListeners) {63 for (const listener of clientSettingsListeners) {
...@@ -147,16 +148,54 @@ async function hydrateClientSettings(): Promise<void> {...@@ -147,16 +148,54 @@ async function hydrateClientSettings(): Promise<void> {
147 return clientSettingsHydrationPromise;148 return clientSettingsHydrationPromise;
148}149}
149150
150function persistClientSettings(settings: ClientSettings): void {151const defaultClientSettingsPersistence = (settings: ClientSettings): Promise<void> =>
151 replaceClientSettingsSnapshot(settings);152 ensureLocalApi().persistence.setClientSettings(settings);
152 void ensureLocalApi()153
153 .persistence.setClientSettings(settings)154function enqueueClientSettingsPersistence<A>(work: () => Promise<A>): Promise<A> {
154 .catch((error) => {155 const result = clientSettingsPersistenceQueue.then(work);
156 clientSettingsPersistenceQueue = result.then(
157 () => undefined,
158 () => undefined,
159 );
160 return result;
161}
162
163export function persistClientSettingsPatch(
164 patch: ClientSettingsPatch,
165 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
166): void {
167 replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch });
168 void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch(
169 (error) => {
155 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {170 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
156 operation: "persist",171 operation: "persist",
157 ...safeErrorLogAttributes(error),172 ...safeErrorLogAttributes(error),
158 });173 });
159 });174 },
175 );
176}
177
178/**
179 * Persists a client-settings update before publishing it to the in-memory
180 * snapshot. If another settings write lands while persistence is pending, the
181 * updater is reapplied to that newer snapshot and persisted again so neither
182 * change is lost.
183 */
184export async function persistClientSettingsUpdate(
185 update: (current: ClientSettings) => ClientSettings,
186 persist: (settings: ClientSettings) => Promise<void> = defaultClientSettingsPersistence,
187): Promise<ClientSettings> {
188 return enqueueClientSettingsPersistence(async () => {
189 for (;;) {
190 const current = getClientSettingsSnapshot();
191 const next = update(current);
192 await persist(next);
193 if (getClientSettingsSnapshot() === current) {
194 replaceClientSettingsSnapshot(next);
195 return next;
196 }
197 }
198 });
160}199}
161200
162// ── Key sets for routing patches ─────────────────────────────────────201// ── Key sets for routing patches ─────────────────────────────────────
...@@ -396,10 +435,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -396,10 +435,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
396 }435 }
397 }436 }
398 if (Object.keys(clientPatch).length > 0) {437 if (Object.keys(clientPatch).length > 0) {
399 persistClientSettings({438 persistClientSettingsPatch(clientPatch);
400 ...getClientSettingsSnapshot(),
401 ...clientPatch,
402 });
403 }439 }
404 },440 },
405 [environmentId, persistServerSettings, sharedSettingsSyncTargetIds],441 [environmentId, persistServerSettings, sharedSettingsSyncTargetIds],
...@@ -472,9 +508,23 @@ export function useUpdatePrimarySettings() {...@@ -472,9 +508,23 @@ export function useUpdatePrimarySettings() {
472508
473export function useUpdateClientSettings() {509export function useUpdateClientSettings() {
474 return useCallback((patch: ClientSettingsPatch) => {510 return useCallback((patch: ClientSettingsPatch) => {
475 persistClientSettings({511 persistClientSettingsPatch(patch);
476 ...getClientSettingsSnapshot(),
477 ...patch,
478 });
479 }, []);512 }, []);
480}513}
514
515export function __resetClientSettingsPersistenceForTests(): void {
516 clientSettingsHydrationGeneration += 1;
517 clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS;
518 clientSettingsHydrated = false;
519 clientSettingsHydrationPromise = null;
520 clientSettingsPersistenceQueue = Promise.resolve();
521 clientSettingsListeners.clear();
522 clientSettingsHydrationListeners.clear();
523}
524
525export function __setClientSettingsForTests(settings: ClientSettings): void {
526 clientSettingsHydrationGeneration += 1;
527 clientSettingsSnapshot = settings;
528 clientSettingsHydrated = true;
529 clientSettingsHydrationPromise = null;
530}
packages/contracts/src/browserImport.ts created+150
...@@ -0,0 +1,150 @@
1/**
2 * Browser import - pulling cookies from a browser already installed on the
3 * machine into a T3 Code browser profile.
4 *
5 * Only cookies are imported. They carry the logged-in sessions, which is what
6 * makes an imported profile useful; saved passwords are deliberately out of
7 * scope because Electron exposes no password store to put them in.
8 *
9 * Availability is per source and per platform, and the reasons are modelled
10 * explicitly: some are a permission the user can grant, one is a limitation
11 * no amount of consent works around. The UI needs to tell those apart.
12 *
13 * @module BrowserImport
14 */
15import * as Schema from "effect/Schema";
16import { TrimmedNonEmptyString } from "./baseSchemas.ts";
17import { BrowserProfileId } from "./browserProfile.ts";
18
19export const BROWSER_IMPORT_SOURCE_IDS = ["helium"] as const;
20
21export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS);
22export type BrowserImportSourceId = typeof BrowserImportSourceId.Type;
23
24/**
25 * Why a detected source cannot be imported right now.
26 *
27 * `needsKeychainApproval` and `browserRunning` are recoverable — the user
28 * grants access or quits the browser. `unsupportedPlatform` is not: it covers
29 * cases like Chrome on Windows, whose App-Bound Encryption is designed to stop
30 * exactly this, and which we will not work around.
31 */
32export const BrowserImportUnavailableReason = Schema.Literals([
33 "notInstalled",
34 "needsKeychainApproval",
35 "keychainItemMissing",
36 "browserRunning",
37 "unsupportedPlatform",
38]);
39export type BrowserImportUnavailableReason = typeof BrowserImportUnavailableReason.Type;
40
41/**
42 * Why an import that was actually attempted failed.
43 *
44 * A superset of the unavailable reasons: a source can pass the pre-flight
45 * check and still fail, most often because the user declined the keychain
46 * prompt the read triggers.
47 */
48export const BrowserImportFailureReason = Schema.Literals([
49 ...BrowserImportUnavailableReason.literals,
50 /** No source registered under the requested id. */
51 "unknownSource",
52 /** The requested profile directory is not one the source reported. */
53 "unknownSourceProfile",
54 /** The target profile's Electron session could not be opened. */
55 "sessionUnavailable",
56 /**
57 * The cookies were written, but the new profile could not be saved to
58 * settings, so its partition was cleared again rather than left orphaned.
59 */
60 "profileNotSaved",
61 /**
62 * The cookies were written, but the profile count reached its cap while the
63 * import ran, so the new profile was not saved and its partition was cleared.
64 */
65 "profileLimitReached",
66 /** Anything else: a corrupt database, a failed decrypt, a vanished file. */
67 "readFailed",
68]);
69export type BrowserImportFailureReason = typeof BrowserImportFailureReason.Type;
70
71/** A profile inside the source browser, e.g. Chromium's "Default" directory. */
72export const BrowserImportSourceProfile = Schema.Struct({
73 /** Directory name under the source's user-data dir. */
74 directory: TrimmedNonEmptyString,
75 /** The name the source browser shows for it. */
76 name: TrimmedNonEmptyString,
77 /**
78 * How many cookies the profile holds. Counted without decrypting, so it is
79 * cheap; absent when the store could not be read yet (Safari before Full
80 * Disk Access is granted).
81 */
82 cookieCount: Schema.optional(Schema.Int),
83});
84export type BrowserImportSourceProfile = typeof BrowserImportSourceProfile.Type;
85
86export const BrowserImportSource = Schema.Struct({
87 id: BrowserImportSourceId,
88 name: TrimmedNonEmptyString,
89 profiles: Schema.Array(BrowserImportSourceProfile),
90 /** Absent when the source is importable. */
91 unavailable: Schema.optional(BrowserImportUnavailableReason),
92});
93export type BrowserImportSource = typeof BrowserImportSource.Type;
94
95export const BrowserImportInput = Schema.Struct({
96 sourceId: BrowserImportSourceId,
97 sourceProfileDirectory: TrimmedNonEmptyString,
98 /** T3 Code profile the cookies are written into. */
99 targetProfileId: BrowserProfileId,
100});
101export type BrowserImportInput = typeof BrowserImportInput.Type;
102
103/** IPC payload: the import input plus the environment the partition belongs to. */
104export const DesktopPreviewImportCookiesInputSchema = Schema.Struct({
105 environmentId: TrimmedNonEmptyString,
106 sourceId: BrowserImportSourceId,
107 sourceProfileDirectory: TrimmedNonEmptyString,
108 targetProfileId: BrowserProfileId,
109});
110
111export const BrowserImportResult = Schema.Struct({
112 /** Cookies successfully written into the target partition. */
113 imported: Schema.Int,
114 /**
115 * Cookies read but not written — expired, rejected as malformed, or held
116 * under a key we could not use. Surfaced rather than hidden so a
117 * mostly-failed import doesn't look like a success.
118 */
119 skipped: Schema.Int,
120 /**
121 * The distinct hosts those skipped cookies belonged to, so the user can be
122 * told what didn't come over rather than just how many. Capped, since a
123 * broken key can skip thousands across many sites.
124 */
125 skippedDomains: Schema.Array(Schema.String),
126});
127export type BrowserImportResult = typeof BrowserImportResult.Type;
128
129export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly<
130 Record<BrowserImportUnavailableReason, string>
131> = {
132 notInstalled: "Not installed on this machine.",
133 needsKeychainApproval: "Needs Keychain access to read its cookies.",
134 keychainItemMissing:
135 "No encryption key in your Keychain — sign in to that browser once, then retry.",
136 browserRunning: "Quit the browser first so its cookie database can be read.",
137 unsupportedPlatform: "Importing from this browser isn't possible on this platform.",
138};
139
140/** What to tell the user when an attempted import fails. */
141export const BROWSER_IMPORT_FAILURE_COPY: Readonly<Record<BrowserImportFailureReason, string>> = {
142 ...BROWSER_IMPORT_UNAVAILABLE_COPY,
143 unknownSource: "That browser is no longer available to import from.",
144 unknownSourceProfile: "That browser profile no longer exists.",
145 sessionUnavailable: "The target profile could not be opened.",
146 profileNotSaved: "The cookies were imported, but the new profile couldn't be saved. Try again.",
147 profileLimitReached:
148 "You've reached the profile limit. Delete a profile or import into an existing one.",
149 readFailed: "The browser's cookie database could not be read.",
150};
packages/contracts/src/index.ts+1
...@@ -29,6 +29,7 @@ export * from "./project.ts";...@@ -29,6 +29,7 @@ export * from "./project.ts";
29export * from "./filesystem.ts";29export * from "./filesystem.ts";
30export * from "./assets.ts";30export * from "./assets.ts";
31export * from "./review.ts";31export * from "./review.ts";
32export * from "./browserImport.ts";
32export * from "./browserProfile.ts";33export * from "./browserProfile.ts";
33export * from "./preview.ts";34export * from "./preview.ts";
34export * from "./previewAutomation.ts";35export * from "./previewAutomation.ts";
packages/contracts/src/ipc.ts+13
...@@ -89,6 +89,11 @@ import type {...@@ -89,6 +89,11 @@ import type {
89} from "./orchestration.ts";89} from "./orchestration.ts";
90import { EnvironmentId } from "./baseSchemas.ts";90import { EnvironmentId } from "./baseSchemas.ts";
91import { BrowserProfileId } from "./browserProfile.ts";91import { BrowserProfileId } from "./browserProfile.ts";
92import type {
93 BrowserImportResult,
94 BrowserImportSource,
95 BrowserImportSourceId,
96} from "./browserImport.ts";
92import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";97import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts";
93import { AdvertisedEndpoint } from "./remoteAccess.ts";98import { AdvertisedEndpoint } from "./remoteAccess.ts";
94import { ExecutionEnvironmentDescriptor } from "./environment.ts";99import { ExecutionEnvironmentDescriptor } from "./environment.ts";
...@@ -1190,6 +1195,14 @@ export interface DesktopPreviewBridge {...@@ -1190,6 +1195,14 @@ export interface DesktopPreviewBridge {
1190 environmentId: EnvironmentId,1195 environmentId: EnvironmentId,
1191 profileId?: string,1196 profileId?: string,
1192 ) => Promise<DesktopPreviewWebviewConfig>;1197 ) => Promise<DesktopPreviewWebviewConfig>;
1198 /** Browsers on this machine whose cookies can be imported. */
1199 listBrowserImportSources: () => Promise<ReadonlyArray<BrowserImportSource>>;
1200 importBrowserCookies: (input: {
1201 readonly environmentId: EnvironmentId;
1202 readonly sourceId: BrowserImportSourceId;
1203 readonly sourceProfileDirectory: string;
1204 readonly targetProfileId: string;
1205 }) => Promise<BrowserImportResult>;
1193 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;1206 setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>;
1194 /**1207 /**
1195 * Activate the in-page element picker for the given tab. Resolves with1208 * Activate the in-page element picker for the given tab. Resolves with
pnpm-lock.yaml+135
...@@ -139,6 +139,9 @@ importers:...@@ -139,6 +139,9 @@ importers:
139 '@effect/platform-node':139 '@effect/platform-node':
140 specifier: 4.0.0-beta.103140 specifier: 4.0.0-beta.103
141 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)141 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)
142 '@napi-rs/keyring':
143 specifier: ^1.3.0
144 version: 1.3.0
142 '@t3tools/client-runtime':145 '@t3tools/client-runtime':
143 specifier: workspace:*146 specifier: workspace:*
144 version: link:../../packages/client-runtime147 version: link:../../packages/client-runtime
...@@ -3177,6 +3180,87 @@ packages:...@@ -3177,6 +3180,87 @@ packages:
3177 resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}3180 resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==}
3178 engines: {node: '>=18'}3181 engines: {node: '>=18'}
31793182
3183 '@napi-rs/keyring-darwin-arm64@1.3.0':
3184 resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==}
3185 engines: {node: '>= 10'}
3186 cpu: [arm64]
3187 os: [darwin]
3188
3189 '@napi-rs/keyring-darwin-x64@1.3.0':
3190 resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==}
3191 engines: {node: '>= 10'}
3192 cpu: [x64]
3193 os: [darwin]
3194
3195 '@napi-rs/keyring-freebsd-x64@1.3.0':
3196 resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==}
3197 engines: {node: '>= 10'}
3198 cpu: [x64]
3199 os: [freebsd]
3200
3201 '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0':
3202 resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==}
3203 engines: {node: '>= 10'}
3204 cpu: [arm]
3205 os: [linux]
3206
3207 '@napi-rs/keyring-linux-arm64-gnu@1.3.0':
3208 resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==}
3209 engines: {node: '>= 10'}
3210 cpu: [arm64]
3211 os: [linux]
3212 libc: [glibc]
3213
3214 '@napi-rs/keyring-linux-arm64-musl@1.3.0':
3215 resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==}
3216 engines: {node: '>= 10'}
3217 cpu: [arm64]
3218 os: [linux]
3219 libc: [musl]
3220
3221 '@napi-rs/keyring-linux-riscv64-gnu@1.3.0':
3222 resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==}
3223 engines: {node: '>= 10'}
3224 cpu: [riscv64]
3225 os: [linux]
3226 libc: [glibc]
3227
3228 '@napi-rs/keyring-linux-x64-gnu@1.3.0':
3229 resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==}
3230 engines: {node: '>= 10'}
3231 cpu: [x64]
3232 os: [linux]
3233 libc: [glibc]
3234
3235 '@napi-rs/keyring-linux-x64-musl@1.3.0':
3236 resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==}
3237 engines: {node: '>= 10'}
3238 cpu: [x64]
3239 os: [linux]
3240 libc: [musl]
3241
3242 '@napi-rs/keyring-win32-arm64-msvc@1.3.0':
3243 resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==}
3244 engines: {node: '>= 10'}
3245 cpu: [arm64]
3246 os: [win32]
3247
3248 '@napi-rs/keyring-win32-ia32-msvc@1.3.0':
3249 resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==}
3250 engines: {node: '>= 10'}
3251 cpu: [ia32]
3252 os: [win32]
3253
3254 '@napi-rs/keyring-win32-x64-msvc@1.3.0':
3255 resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==}
3256 engines: {node: '>= 10'}
3257 cpu: [x64]
3258 os: [win32]
3259
3260 '@napi-rs/keyring@1.3.0':
3261 resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==}
3262 engines: {node: '>= 10'}
3263
3180 '@napi-rs/wasm-runtime@1.1.6':3264 '@napi-rs/wasm-runtime@1.1.6':
3181 resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}3265 resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
3182 peerDependencies:3266 peerDependencies:
...@@ -13169,6 +13253,57 @@ snapshots:...@@ -13169,6 +13253,57 @@ snapshots:
13169 strict-event-emitter: 0.5.113253 strict-event-emitter: 0.5.1
13170 optional: true13254 optional: true
1317113255
13256 '@napi-rs/keyring-darwin-arm64@1.3.0':
13257 optional: true
13258
13259 '@napi-rs/keyring-darwin-x64@1.3.0':
13260 optional: true
13261
13262 '@napi-rs/keyring-freebsd-x64@1.3.0':
13263 optional: true
13264
13265 '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0':
13266 optional: true
13267
13268 '@napi-rs/keyring-linux-arm64-gnu@1.3.0':
13269 optional: true
13270
13271 '@napi-rs/keyring-linux-arm64-musl@1.3.0':
13272 optional: true
13273
13274 '@napi-rs/keyring-linux-riscv64-gnu@1.3.0':
13275 optional: true
13276
13277 '@napi-rs/keyring-linux-x64-gnu@1.3.0':
13278 optional: true
13279
13280 '@napi-rs/keyring-linux-x64-musl@1.3.0':
13281 optional: true
13282
13283 '@napi-rs/keyring-win32-arm64-msvc@1.3.0':
13284 optional: true
13285
13286 '@napi-rs/keyring-win32-ia32-msvc@1.3.0':
13287 optional: true
13288
13289 '@napi-rs/keyring-win32-x64-msvc@1.3.0':
13290 optional: true
13291
13292 '@napi-rs/keyring@1.3.0':
13293 optionalDependencies:
13294 '@napi-rs/keyring-darwin-arm64': 1.3.0
13295 '@napi-rs/keyring-darwin-x64': 1.3.0
13296 '@napi-rs/keyring-freebsd-x64': 1.3.0
13297 '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0
13298 '@napi-rs/keyring-linux-arm64-gnu': 1.3.0
13299 '@napi-rs/keyring-linux-arm64-musl': 1.3.0
13300 '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0
13301 '@napi-rs/keyring-linux-x64-gnu': 1.3.0
13302 '@napi-rs/keyring-linux-x64-musl': 1.3.0
13303 '@napi-rs/keyring-win32-arm64-msvc': 1.3.0
13304 '@napi-rs/keyring-win32-ia32-msvc': 1.3.0
13305 '@napi-rs/keyring-win32-x64-msvc': 1.3.0
13306
13172 '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':13307 '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
13173 dependencies:13308 dependencies:
13174 '@emnapi/core': 1.10.013309 '@emnapi/core': 1.10.0
scripts/build-desktop-artifact.ts+80
...@@ -200,6 +200,22 @@ export class MacPasskeySigningConfigurationResolutionError extends Schema.Tagged...@@ -200,6 +200,22 @@ export class MacPasskeySigningConfigurationResolutionError extends Schema.Tagged
200 }200 }
201}201}
202202
203export class KeyringNativePackageMissingError extends Schema.TaggedErrorClass<KeyringNativePackageMissingError>()(
204 "KeyringNativePackageMissingError",
205 {
206 packageName: Schema.String,
207 binaryFileName: Schema.String,
208 packageEntryPath: Schema.String,
209 platform: BuildPlatform,
210 arch: BuildArch,
211 cause: Schema.Defect(),
212 },
213) {
214 override get message(): string {
215 return `Keyring native package is missing: ${this.packageName}`;
216 }
217}
218
203export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass<ClerkPasskeyNativePackageMissingError>()(219export class ClerkPasskeyNativePackageMissingError extends Schema.TaggedErrorClass<ClerkPasskeyNativePackageMissingError>()(
204 "ClerkPasskeyNativePackageMissingError",220 "ClerkPasskeyNativePackageMissingError",
205 {221 {
...@@ -1343,6 +1359,69 @@ export function resolveClerkPasskeyNativeArtifacts(...@@ -1343,6 +1359,69 @@ export function resolveClerkPasskeyNativeArtifacts(
1343 return [];1359 return [];
1344}1360}
13451361
1362export function resolveKeyringNativeArtifacts(
1363 platform: typeof BuildPlatform.Type,
1364 arch: typeof BuildArch.Type,
1365): readonly ClerkPasskeyNativeArtifact[] {
1366 const architectures = arch === "universal" ? (["arm64", "x64"] as const) : [arch];
1367
1368 if (platform === "mac") {
1369 return architectures.map((architecture) => ({
1370 packageName: `@napi-rs/keyring-darwin-${architecture}`,
1371 binaryFileName: `keyring.darwin-${architecture}.node`,
1372 }));
1373 }
1374
1375 if (platform === "win") {
1376 return architectures.map((architecture) => ({
1377 packageName: `@napi-rs/keyring-win32-${architecture}-msvc`,
1378 binaryFileName: `keyring.win32-${architecture}-msvc.node`,
1379 }));
1380 }
1381
1382 return architectures.map((architecture) => ({
1383 packageName: `@napi-rs/keyring-linux-${architecture}-gnu`,
1384 binaryFileName: `keyring.linux-${architecture}-gnu.node`,
1385 }));
1386}
1387
1388/**
1389 * Same nesting problem as the Clerk passkey binaries: pnpm keeps the platform
1390 * package under `@napi-rs/keyring`, electron-builder only retains collected
1391 * top-level dependencies, and the generated loader checks for a sibling
1392 * `keyring.<platform>.node` before falling back to the package. Staging the
1393 * binary beside `index.js` lets that first branch win.
1394 */
1395const stageKeyringNativeBinaries = Effect.fn("stageKeyringNativeBinaries")(function* (
1396 stageAppDir: string,
1397 platform: typeof BuildPlatform.Type,
1398 arch: typeof BuildArch.Type,
1399) {
1400 const fs = yield* FileSystem.FileSystem;
1401 const path = yield* Path.Path;
1402 const packageEntryPath = yield* fs.realPath(
1403 path.join(stageAppDir, "node_modules", "@napi-rs", "keyring", "index.js"),
1404 );
1405 const packageDir = path.dirname(packageEntryPath);
1406 const packageRequire = NodeModule.createRequire(packageEntryPath);
1407
1408 for (const artifact of resolveKeyringNativeArtifacts(platform, arch)) {
1409 const sourcePath = yield* Effect.try({
1410 try: () => packageRequire.resolve(`${artifact.packageName}/${artifact.binaryFileName}`),
1411 catch: (cause) =>
1412 new KeyringNativePackageMissingError({
1413 packageName: artifact.packageName,
1414 binaryFileName: artifact.binaryFileName,
1415 packageEntryPath,
1416 platform,
1417 arch,
1418 cause,
1419 }),
1420 });
1421 yield* fs.copyFile(sourcePath, path.join(packageDir, artifact.binaryFileName));
1422 }
1423});
1424
1346// pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only1425// pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only
1347// retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage1426// retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage
1348// the binary there and let electron-builder's native-addon handling unpack it from the ASAR.1427// the binary there and let electron-builder's native-addon handling unpack it from the ASAR.
...@@ -3554,6 +3633,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3554,6 +3633,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3554 { label: "vp install --prod", verbose: options.verbose },3633 { label: "vp install --prod", verbose: options.verbose },
3555 );3634 );
3556 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);3635 yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch);
3636 yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch);
35573637
3558 // WSL is Windows-only, so only the Windows artifact carries the server3638 // WSL is Windows-only, so only the Windows artifact carries the server
3559 // sidecar (which embeds the Linux node-pty prebuild); other platforms3639 // sidecar (which embeds the Linux node-pty prebuild); other platforms