Skip to content

Commit e3f619b

Browse files
feat(desktop): import cookies from an installed browser into a profile
Adds per-profile cookie import, with Helium on macOS as the first source. Cookies carry the logged-in sessions, which is what makes an imported profile useful; saved passwords are out of scope because Electron exposes no password store to put them in. The key is read through the in-process Keychain API rather than by shelling out to `/usr/bin/security`, because macOS attributes both the consent prompt and the resulting ACL grant to the binary that asks. Via the CLI the prompt said "security" and "Always Allow" granted trust to a tool every process on the machine can invoke; in-process it names this app and the grant belongs to it. There is deliberately no fallback when consent is denied — the techniques that work around it exist to defeat exactly this. The read is untimed: macOS answers it with a modal, and a timeout racing the user means the prompt can be approved after nothing is left listening, which reads as "approving did nothing". Sources pin their own coordinates rather than deriving them, because Chromium forks disagree: Helium uses the keychain service "Helium Storage Key" / account "Helium" where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>". `expires_utc` counts microseconds since 1601 and exceeds JavaScript's safe integer range, which `node:sqlite` refuses to narrow, so the division happens in SQL and only seconds cross the boundary. The cookie database is snapshotted before reading, since Chromium keeps it open with WAL. Unavailable sources are reported with a reason rather than as one generic failure — a missing keychain item is not something approving a prompt can fix. `unsupportedPlatform` is the one that is not a permission at all: it covers cases like Chrome on Windows, whose App-Bound Encryption is designed to stop this and which we will not work around. The platform binary is staged beside the loader during packaging, mirroring the Clerk passkey handling: pnpm nests the arch package, electron-builder only retains top-level dependencies, and the generated loader checks for a sibling `.node` first. Verified end to end against a real Helium profile: 5,002 cookies imported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b7395b6 commit e3f619b

14 files changed

Lines changed: 881 additions & 0 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@clerk/electron": "catalog:",
1616
"@clerk/electron-passkeys": "catalog:",
1717
"@effect/platform-node": "catalog:",
18+
"@napi-rs/keyring": "^1.3.0",
1819
"@t3tools/client-runtime": "workspace:*",
1920
"@t3tools/contracts": "workspace:*",
2021
"@t3tools/shared": "workspace:*",

apps/desktop/src/ipc/DesktopIpcHandlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
9595
for (const previewMethod of PreviewIpc.methods) {
9696
yield* ipc.handle(previewMethod);
9797
}
98+
yield* ipc.handle(PreviewIpc.listBrowserImportSources);
99+
yield* ipc.handle(PreviewIpc.importBrowserCookies);
98100
});

apps/desktop/src/ipc/channels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
5858
export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
5959
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
6060
export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
61+
export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources";
62+
export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies";
6163
export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";
6264
export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
6365
export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
DesktopPreviewRegisterWebviewInputSchema,
1515
DesktopPreviewScreenshotArtifactSchema,
1616
DesktopPreviewSetColorSchemeInputSchema,
17+
BrowserImportResult,
18+
BrowserImportSource,
1719
DesktopPreviewClearDataInputSchema,
20+
DesktopPreviewImportCookiesInputSchema,
1821
DesktopPreviewCreateTabInputSchema,
1922
DesktopPreviewTabInputSchema,
2023
DesktopPreviewWebviewConfigSchema,
@@ -29,6 +32,7 @@ import * as Schema from "effect/Schema";
2932
import * as NodeURL from "node:url";
3033

3134
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
35+
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
3236
import * as PreviewManager from "../../preview/Manager.ts";
3337
import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
3438
import * as IpcChannels from "../channels.ts";
@@ -261,6 +265,37 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({
261265
}),
262266
});
263267

268+
/**
269+
* Registered separately from `methods`: these carry `BrowserImport` in their
270+
* context and their own failure type, so they do not unify with the
271+
* manager-backed handlers the shared loop iterates.
272+
*/
273+
export const listBrowserImportSources = DesktopIpc.makeIpcMethod({
274+
channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL,
275+
payload: Schema.Void,
276+
result: Schema.Array(BrowserImportSource),
277+
handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () {
278+
const browserImport = yield* BrowserImport.BrowserImport;
279+
return yield* browserImport.listSources;
280+
}),
281+
});
282+
283+
export const importBrowserCookies = DesktopIpc.makeIpcMethod({
284+
channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL,
285+
payload: DesktopPreviewImportCookiesInputSchema,
286+
result: BrowserImportResult,
287+
handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({
288+
environmentId,
289+
...importInput
290+
}) {
291+
const browserImport = yield* BrowserImport.BrowserImport;
292+
// Derived in main from the same helper the webview config uses, so cookies
293+
// land in exactly the partition the profile's tabs attach to.
294+
const { scope, persistent } = resolvePartitionScope(environmentId, importInput.targetProfileId);
295+
return yield* browserImport.importCookies({ input: importInput, scope, persistent });
296+
}),
297+
});
298+
264299
export const setAnnotationTheme = DesktopIpc.makeIpcMethod({
265300
channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL,
266301
payload: DesktopPreviewAnnotationThemeInputSchema,

apps/desktop/src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
5757
import * as DesktopState from "./app/DesktopState.ts";
5858
import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
5959
import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
60+
import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
6061
import * as BrowserSession from "./preview/BrowserSession.ts";
6162
import * as PreviewManager from "./preview/Manager.ts";
6263
import * as DesktopWindow from "./window/DesktopWindow.ts";
@@ -148,6 +149,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
148149
);
149150

150151
const desktopPreviewLayer = PreviewManager.layer.pipe(
152+
// Merged rather than provided so the IPC handlers can reach the import
153+
// service alongside the manager; both sit on the same BrowserSession.
154+
Layer.provideMerge(BrowserImport.layer),
151155
Layer.provideMerge(BrowserSession.layer),
152156
Layer.provideMerge(desktopFoundationLayer),
153157
);

apps/desktop/src/preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
185185
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
186186
openDevTools: (tabId) =>
187187
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
188+
listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL),
189+
importBrowserCookies: (input) =>
190+
ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input),
188191
clearCookies: (environmentId, profileId) =>
189192
ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
190193
clearCache: (environmentId, profileId) =>
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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+
*/
7+
import type {
8+
BrowserImportInput,
9+
BrowserImportResult,
10+
BrowserImportSource,
11+
BrowserImportUnavailableReason,
12+
} from "@t3tools/contracts";
13+
import * as Context from "effect/Context";
14+
import * as Effect from "effect/Effect";
15+
import * as Layer from "effect/Layer";
16+
import * as Schema from "effect/Schema";
17+
18+
import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
19+
20+
import * as BrowserSession from "../BrowserSession.ts";
21+
import {
22+
ChromiumCookieReadError,
23+
readChromiumCookies,
24+
type ChromiumCookie,
25+
} from "./ChromiumCookies.ts";
26+
import {
27+
BROWSER_IMPORT_SOURCES,
28+
cookieDatabasePath,
29+
isSourceInstalled,
30+
isSourceRunning,
31+
listSourceProfiles,
32+
type BrowserImportSourceDefinition,
33+
} from "./Sources.ts";
34+
35+
export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
36+
"BrowserImportFailedError",
37+
{
38+
sourceId: Schema.String,
39+
reason: Schema.String,
40+
},
41+
) {
42+
override get message(): string {
43+
return `Importing cookies from ${this.sourceId} failed: ${this.reason}.`;
44+
}
45+
}
46+
47+
export class BrowserImport extends Context.Service<
48+
BrowserImport,
49+
{
50+
readonly listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>>;
51+
readonly importCookies: (input: {
52+
readonly input: BrowserImportInput;
53+
/** Partition scope of the target profile, derived by the caller in main. */
54+
readonly scope: string;
55+
readonly persistent: boolean;
56+
}) => Effect.Effect<BrowserImportResult, BrowserImportFailedError>;
57+
}
58+
>()("@t3tools/desktop/preview/BrowserImport/BrowserImport") {}
59+
60+
const unavailableReason = async (
61+
definition: BrowserImportSourceDefinition,
62+
platform: NodeJS.Platform,
63+
): Promise<BrowserImportUnavailableReason | undefined> => {
64+
if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
65+
if (!(await isSourceInstalled(definition))) return "notInstalled";
66+
if (await isSourceRunning(definition)) return "browserRunning";
67+
return undefined;
68+
};
69+
70+
export const make = Effect.gen(function* BrowserImportMake() {
71+
const browserSession = yield* BrowserSession.BrowserSession;
72+
const platform = yield* HostProcessPlatform;
73+
const executablePath = yield* HostProcessExecutablePath;
74+
75+
const listSources = Effect.promise(async (): Promise<ReadonlyArray<BrowserImportSource>> => {
76+
const sources: BrowserImportSource[] = [];
77+
for (const definition of BROWSER_IMPORT_SOURCES) {
78+
const unavailable = await unavailableReason(definition, platform);
79+
sources.push({
80+
id: definition.id,
81+
name: definition.name,
82+
// Listing profiles touches the source's own files, so skip it when the
83+
// source is unusable anyway.
84+
profiles: unavailable === undefined ? await listSourceProfiles(definition) : [],
85+
...(unavailable === undefined ? {} : { unavailable }),
86+
});
87+
}
88+
return sources;
89+
});
90+
91+
const importCookies = Effect.fn("BrowserImport.importCookies")(function* (input: {
92+
readonly input: BrowserImportInput;
93+
readonly scope: string;
94+
readonly persistent: boolean;
95+
}) {
96+
const definition = BROWSER_IMPORT_SOURCES.find(
97+
(candidate) => candidate.id === input.input.sourceId,
98+
);
99+
if (!definition) {
100+
return yield* new BrowserImportFailedError({
101+
sourceId: input.input.sourceId,
102+
reason: "unknown source",
103+
});
104+
}
105+
106+
const blocked = yield* Effect.promise(() => unavailableReason(definition, platform));
107+
if (blocked !== undefined) {
108+
return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked });
109+
}
110+
111+
// macOS attributes the Keychain prompt and the resulting ACL grant to the
112+
// executable that asks, so record which one that was — in a packaged build
113+
// it is the signed app, in dev whatever binary hosts the main process.
114+
yield* Effect.logInfo("Reading browser cookie key from the keychain", {
115+
sourceId: definition.id,
116+
executablePath,
117+
});
118+
119+
const cookies: ReadonlyArray<ChromiumCookie> = yield* Effect.tryPromise({
120+
try: () =>
121+
readChromiumCookies({
122+
cookieDatabasePath: cookieDatabasePath(definition, input.input.sourceProfileDirectory),
123+
keychainService: definition.keychainService,
124+
keychainAccount: definition.keychainAccount,
125+
platform,
126+
}),
127+
catch: (cause) =>
128+
new BrowserImportFailedError({
129+
sourceId: definition.id,
130+
reason: cause instanceof ChromiumCookieReadError ? cause.failure.reason : "readFailed",
131+
}),
132+
});
133+
134+
const session = yield* browserSession
135+
.getSession(input.scope, input.persistent)
136+
.pipe(
137+
Effect.mapError(
138+
() => new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }),
139+
),
140+
);
141+
142+
// Written one at a time rather than in parallel: Chromium's cookie store
143+
// serialises writes anyway, and a rejected cookie should only cost itself.
144+
let imported = 0;
145+
let skipped = 0;
146+
for (const cookie of cookies) {
147+
const written = yield* Effect.tryPromise({
148+
try: () =>
149+
session.cookies.set({
150+
url: cookie.url,
151+
name: cookie.name,
152+
value: cookie.value,
153+
domain: cookie.domain,
154+
path: cookie.path,
155+
secure: cookie.secure,
156+
httpOnly: cookie.httpOnly,
157+
sameSite: cookie.sameSite,
158+
...(cookie.expirationDate === undefined
159+
? {}
160+
: { expirationDate: cookie.expirationDate }),
161+
}),
162+
catch: () => undefined,
163+
}).pipe(
164+
Effect.as(true),
165+
Effect.catchCause(() => Effect.succeed(false)),
166+
);
167+
if (written) imported += 1;
168+
else skipped += 1;
169+
}
170+
171+
return { imported, skipped };
172+
});
173+
174+
return BrowserImport.of({ listSources, importCookies });
175+
});
176+
177+
export const layer = Layer.effect(BrowserImport, make);

0 commit comments

Comments
 (0)