Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
83a7c7c
feat(desktop): import cookies from an installed browser into a profile
juliusmarminge Aug 16, 2026
45059de
feat(web): rebuild the profile list as a table with import targets
juliusmarminge Aug 16, 2026
c3df576
fix(desktop): reject unlisted source profiles and detect running brow…
juliusmarminge Aug 16, 2026
7410796
refactor(desktop): put browser cookie import on Effect services
juliusmarminge Aug 16, 2026
75f5d34
fix(web): confirm profile removal and keep the table honest
juliusmarminge Aug 16, 2026
37fcb6f
refactor(desktop): name the database a cookie read failed on
juliusmarminge Aug 16, 2026
c14c338
docs(web): fold the stale profile-setting comments into one
juliusmarminge Aug 16, 2026
173e933
fix(desktop): stop listing browsers that are not installed
juliusmarminge Aug 16, 2026
0564181
fix(web): don't leave an empty profile behind when import can't start
juliusmarminge Aug 17, 2026
68179bd
fix(web): say why clearing a profile did nothing
juliusmarminge Aug 17, 2026
569e743
fix(web): dim the Default badge with the rest of its row
juliusmarminge Aug 17, 2026
891d88d
fix(desktop): reject crafted profile directories from Local State
juliusmarminge Aug 17, 2026
1afbd69
fix(desktop): keep host-only cookies host-only on import
juliusmarminge Aug 17, 2026
b7a7727
fix(desktop): find profiles when Local State cannot be read
juliusmarminge Aug 17, 2026
dc5bf70
fix(desktop): count cookies the import could not decrypt
juliusmarminge Aug 17, 2026
8473665
feat(web): import browser cookies through a guided wizard
juliusmarminge Aug 17, 2026
da626ea
feat(web): show cookie counts and redesign the import step as From → …
juliusmarminge Aug 17, 2026
4a69f90
feat(desktop): name the sites whose cookies were skipped
juliusmarminge Aug 17, 2026
6d282fb
fix(web): plainer, more direct copy for profiles and import
juliusmarminge Aug 28, 2026
612df67
fix(desktop): import plaintext Chromium cookies
juliusmarminge Aug 29, 2026
ab495cf
fix(desktop): snapshot browser cookies consistently
juliusmarminge Aug 29, 2026
f7ae00f
fix(desktop): preserve cookie write interruption
juliusmarminge Aug 29, 2026
5911e32
test(desktop): type browser import session mock
juliusmarminge Aug 29, 2026
193cbde
fix(web): label browser import progress
juliusmarminge Aug 29, 2026
8b66e88
refactor(web): clean up import configure step
juliusmarminge Aug 29, 2026
12f1b09
fix(web): expose import tile selection
juliusmarminge Aug 29, 2026
673e77e
fix(web): clarify browser import results
juliusmarminge Aug 29, 2026
3f378a6
fix(web): tag browser import targets
juliusmarminge Aug 29, 2026
1b9f7c6
fix(web): find default browser profile
juliusmarminge Aug 29, 2026
86b03b3
fix(desktop): preserve defects in browser probes
juliusmarminge Aug 29, 2026
ee91894
fix(web): lock profiles during browser import
juliusmarminge Aug 29, 2026
8608cbe
fix(web): identify tabs from removed profiles
juliusmarminge Aug 29, 2026
1eb3d69
fix(desktop): validate Chromium domain-bound cookies
juliusmarminge Aug 29, 2026
7a186a0
fix(desktop): read legacy macOS Chromium cookie values
juliusmarminge Aug 29, 2026
0101934
fix(desktop): skip partitioned Chromium cookies
juliusmarminge Aug 29, 2026
80408de
fix(desktop): target imports at profile partition
juliusmarminge Aug 29, 2026
68ca812
fix(desktop): follow tagged error catch convention
juliusmarminge Aug 29, 2026
230965e
fix(web): keep browser profile menus available
juliusmarminge Aug 29, 2026
e875bae
fix(web): preserve browser import source selection
juliusmarminge Aug 29, 2026
bae1fe7
fix(web): pin browser import environment
juliusmarminge Aug 29, 2026
0be2a8a
fix(web): clarify browser import destination copy
juliusmarminge Aug 29, 2026
b521dea
fix(desktop): ignore stale Chromium locks
juliusmarminge Aug 29, 2026
3c6c0ba
fix(desktop): guard Chromium liveness errors
juliusmarminge Aug 29, 2026
5509b12
fix(desktop): decode Chromium text schema versions
juliusmarminge Aug 29, 2026
cfded12
fix(web): hide browser import close while writing
juliusmarminge Aug 29, 2026
2f85fe2
fix(web): explain unavailable profile cleanup
juliusmarminge Aug 29, 2026
ab306cf
fix(web): block imports without source profiles
juliusmarminge Aug 29, 2026
d95c81c
fix(web): hydrate settings before profile imports
juliusmarminge Aug 29, 2026
6e6a250
fix(web): explain unavailable browser imports
juliusmarminge Aug 29, 2026
9796604
fix(desktop): normalize IPv6 cookie URLs
juliusmarminge Aug 29, 2026
8fbc011
fix(web): await imported profile persistence
juliusmarminge Aug 29, 2026
8a39228
fix(web): revalidate browser import targets
juliusmarminge Aug 29, 2026
3c41bf6
fix(web): explain removed import targets
juliusmarminge Aug 29, 2026
a07d815
fix(web): serialize client settings persistence
juliusmarminge Aug 29, 2026
7107588
fix(web): show pointer on import choices
juliusmarminge Aug 29, 2026
4f64d22
fix(desktop): read browser lock hostname from service
juliusmarminge Aug 29, 2026
4e49304
fix(web): clarify empty browser imports
juliusmarminge Aug 29, 2026
683ca1b
fix(web): normalize import destination labels
juliusmarminge Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@clerk/electron": "catalog:",
"@clerk/electron-passkeys": "catalog:",
"@effect/platform-node": "catalog:",
"@napi-rs/keyring": "^1.3.0",
"@t3tools/client-runtime": "workspace:*",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
for (const previewMethod of PreviewIpc.methods) {
yield* ipc.handle(previewMethod);
}
yield* ipc.handle(PreviewIpc.listBrowserImportSources);
yield* ipc.handle(PreviewIpc.importBrowserCookies);
});
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources";
export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies";
export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";
export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as Schema from "effect/Schema";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import * as PreviewManager from "../../preview/Manager.ts";
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
import * as PreviewIpc from "./preview.ts";

const { fromPartition } = vi.hoisted(() => ({
Expand Down Expand Up @@ -80,6 +81,38 @@ describe("preview IPC methods", () => {
});
});

effectIt.effect("targets imports at the same partition tuple as the renderer", () => {
const received: Array<Parameters<BrowserImport.BrowserImport["Service"]["importCookies"]>[0]> =
[];
const browserImport = BrowserImport.BrowserImport.of({
listSources: Effect.succeed([]),
importCookies: (input) =>
Effect.sync(() => {
received.push(input);
return { imported: 0, skipped: 0, skippedDomains: [] };
}),
});
const request = (environmentId: string, targetProfileId: string) =>
PreviewIpc.importBrowserCookies.handler({
environmentId,
sourceId: "helium",
sourceProfileDirectory: "Default",
targetProfileId,
});

return Effect.gen(function* () {
yield* request("a", "b");
yield* request("a::b", DEFAULT_BROWSER_PROFILE_ID);

expect(received[0]).toMatchObject(PreviewIpc.resolvePartitionScope("a", "b"));
expect(received[1]).toMatchObject(
PreviewIpc.resolvePartitionScope("a::b", DEFAULT_BROWSER_PROFILE_ID),
);
expect(received[0]?.namespace).toBe("profile");
expect(received[1]?.namespace).toBeUndefined();
}).pipe(Effect.provideService(BrowserImport.BrowserImport, browserImport));
});

effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
Effect.map(
PreviewIpc.registerWebview
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
DesktopPreviewScreenshotArtifactSchema,
DesktopPreviewSetAudioMutedInputSchema,
DesktopPreviewSetColorSchemeInputSchema,
BrowserImportResult,
BrowserImportSource,
DesktopPreviewClearDataInputSchema,
DesktopPreviewImportCookiesInputSchema,
DesktopPreviewCreateTabInputSchema,
DesktopPreviewTabInputSchema,
DesktopPreviewWebviewConfigSchema,
Expand All @@ -30,6 +33,7 @@ import * as Schema from "effect/Schema";
import * as NodeURL from "node:url";

import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
import * as PreviewManager from "../../preview/Manager.ts";
import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
import * as IpcChannels from "../channels.ts";
Expand Down Expand Up @@ -284,6 +288,45 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({
}),
});

/**
* Registered separately from `methods`: these carry `BrowserImport` in their
* context and their own failure type, so they do not unify with the
* manager-backed handlers the shared loop iterates.
*/
export const listBrowserImportSources = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL,
payload: Schema.Void,
result: Schema.Array(BrowserImportSource),
handler: Effect.fn("desktop.ipc.preview.listBrowserImportSources")(function* () {
const browserImport = yield* BrowserImport.BrowserImport;
return yield* browserImport.listSources;
}),
});

export const importBrowserCookies = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL,
payload: DesktopPreviewImportCookiesInputSchema,
result: BrowserImportResult,
handler: Effect.fn("desktop.ipc.preview.importBrowserCookies")(function* ({
environmentId,
...importInput
}) {
const browserImport = yield* BrowserImport.BrowserImport;
// Derived in main from the same helper the webview config uses, so cookies
// land in exactly the partition the profile's tabs attach to.
const { scope, persistent, namespace } = resolvePartitionScope(
environmentId,
importInput.targetProfileId,
);
return yield* browserImport.importCookies({
input: importInput,
scope,
persistent,
...(namespace === undefined ? {} : { namespace }),
});
}),
});

export const setAnnotationTheme = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL,
payload: DesktopPreviewAnnotationThemeInputSchema,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
import * as DesktopState from "./app/DesktopState.ts";
import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
import * as BrowserSession from "./preview/BrowserSession.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
Expand Down Expand Up @@ -148,6 +149,9 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
);

const desktopPreviewLayer = PreviewManager.layer.pipe(
// Merged rather than provided so the IPC handlers can reach the import
// service alongside the manager; both sit on the same BrowserSession.
Layer.provideMerge(BrowserImport.layer),
Layer.provideMerge(BrowserSession.layer),
Layer.provideMerge(desktopFoundationLayer),
);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
openDevTools: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL),
importBrowserCookies: (input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input),
clearCookies: (environmentId, profileId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
clearCache: (environmentId, profileId) =>
Expand Down
177 changes: 177 additions & 0 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import {
HostProcessEnvironment,
HostProcessExecutablePath,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";

import * as BrowserSession from "../BrowserSession.ts";
import * as BrowserImport from "./BrowserImport.ts";
import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";

const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;

const cookie = {
url: "https://rejected.example/path",
name: "session",
value: "value",
domain: undefined,
path: "/",
secure: true,
httpOnly: true,
expirationDate: undefined,
sameSite: "lax" as const,
};

/**
* Dies if the import reaches session work: every case here covers a request
* that must be rejected before a cookie is read or written.
*/
const rejectedBeforeSession = Layer.succeed(
BrowserSession.BrowserSession,
BrowserSession.BrowserSession.of({
getPartition: () => Effect.die("getPartition must not be reached"),
isPartition: () => false,
getSession: () => Effect.die("getSession must not be reached"),
clearCookies: () => Effect.die("clearCookies must not be reached"),
clearCache: () => Effect.die("clearCache must not be reached"),
}),
);

/**
* Builds the service against a scratch home containing an installed, closed
* copy of the source browser.
*/
const withImporter = Effect.fnUntraced(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
const paths = yield* sourcePaths.pipe(
Effect.provideService(HostProcessEnvironment, { HOME: home }),
);
yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {
recursive: true,
});
// The cookie database is what marks a source as installed, so a fixture
// without one is reported as absent before any other check runs.
yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db");

const importer = yield* BrowserImport.BrowserImport.pipe(
Effect.provide(
BrowserImport.layer.pipe(
Layer.provide(rejectedBeforeSession),
Layer.provide(environment),
Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")),
Layer.provide(Layer.succeed(HostProcessExecutablePath, "/Applications/T3 Code.app")),
Layer.provide(NodeServices.layer),
),
),
);
return { importer, home, paths };
});

describe("BrowserImport.importCookies", () => {
it.effect("rejects a source profile the browser never reported", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const { importer, home } = yield* withImporter();

// A cookie database reachable on disk but outside the browser's
// user-data directory — the payoff a traversal would be after.
yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true });
yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db");

const error = yield* importer
.importCookies({
input: {
sourceId: "helium",
sourceProfileDirectory: "../../../../secrets",
targetProfileId: "default",
},
scope: "persist:t3code-preview-test",
persistent: true,
})
.pipe(Effect.flip);

assert.instanceOf(error, BrowserImport.BrowserImportFailedError);
assert.equal(error.reason, "unknownSourceProfile");
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);

it.effect("refuses to import while the source browser holds its profile", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const { importer, paths } = yield* withImporter();
// The lock Chromium leaves while it is running, dangling target and
// all. This must stop the import before it ever asks the keychain.
yield* fileSystem.symlink(
"host-that-does-not-exist-1234",
`${helium.userDataDirectory(paths)}/SingletonLock`,
);

const error = yield* importer
.importCookies({
input: {
sourceId: "helium",
sourceProfileDirectory: "Default",
targetProfileId: "default",
},
scope: "persist:t3code-preview-test",
persistent: true,
})
.pipe(Effect.flip);

assert.equal(error.reason, "browserRunning");
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);
});

describe("BrowserImport.writeCookies", () => {
it.effect("counts a rejected cookie and its domain as skipped", () =>
Effect.gen(function* () {
const result = yield* BrowserImport.writeCookies(
{
cookies: {
set: () => Promise.reject(new Error("fixture rejection")),
},
},
{ cookies: [cookie], undecryptable: 0, undecryptableHosts: [] },
);

assert.deepEqual(result, {
imported: 0,
skipped: 1,
skippedDomains: ["rejected.example"],
});
}),
);

it.effect("propagates interruption while writing a cookie", () =>
Effect.gen(function* () {
const write = BrowserImport.writeCookies(
{
cookies: {
set: () => new Promise<void>(() => {}),
},
},
{ cookies: [cookie], undecryptable: 0, undecryptableHosts: [] },
);

const interrupted = yield* Ref.make(false);
const fiber = yield* write.pipe(
Effect.onInterrupt(() => Ref.set(interrupted, true)),
Effect.forkChild,
);
yield* Effect.yieldNow;
yield* Fiber.interrupt(fiber);

assert.isTrue(yield* Ref.get(interrupted));
}),
);
});
Loading
Loading