Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fd0f971
feat(desktop): import cookies from an installed browser into a profile
juliusmarminge Aug 16, 2026
b8f0eb0
feat(web): rebuild the profile list as a table with import targets
juliusmarminge Aug 16, 2026
467933a
fix(desktop): reject unlisted source profiles and detect running brow…
juliusmarminge Aug 16, 2026
a5bd478
refactor(desktop): put browser cookie import on Effect services
juliusmarminge Aug 16, 2026
0636798
fix(web): confirm profile removal and keep the table honest
juliusmarminge Aug 16, 2026
784a6f7
refactor(desktop): name the database a cookie read failed on
juliusmarminge Aug 16, 2026
3ecd5d8
docs(web): fold the stale profile-setting comments into one
juliusmarminge Aug 16, 2026
82a184b
fix(desktop): stop listing browsers that are not installed
juliusmarminge Aug 16, 2026
28cb236
fix(web): don't leave an empty profile behind when import can't start
juliusmarminge Aug 17, 2026
6fc4291
fix(web): say why clearing a profile did nothing
juliusmarminge Aug 17, 2026
302d4dc
fix(web): dim the Default badge with the rest of its row
juliusmarminge Aug 17, 2026
ab84f86
fix(desktop): reject crafted profile directories from Local State
juliusmarminge Aug 17, 2026
7005ec2
fix(desktop): keep host-only cookies host-only on import
juliusmarminge Aug 17, 2026
83077b1
fix(desktop): find profiles when Local State cannot be read
juliusmarminge Aug 17, 2026
7b8e294
fix(desktop): count cookies the import could not decrypt
juliusmarminge Aug 17, 2026
05fe2c1
feat(web): import browser cookies through a guided wizard
juliusmarminge Aug 17, 2026
463df5e
feat(web): show cookie counts and redesign the import step as From → …
juliusmarminge Aug 17, 2026
2d762e5
feat(desktop): name the sites whose cookies were skipped
juliusmarminge Aug 17, 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 @@ -95,4 +95,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
for (const previewMethod of PreviewIpc.methods) {
yield* ipc.handle(previewMethod);
}
yield* ipc.handle(PreviewIpc.listBrowserImportSources);
yield* ipc.handle(PreviewIpc.importBrowserCookies);
});
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config";
export const PREVIEW_IMPORT_SOURCES_CHANNEL = "desktop:preview-import-sources";
export const PREVIEW_IMPORT_COOKIES_CHANNEL = "desktop:preview-import-cookies";
export const PREVIEW_SET_ANNOTATION_THEME_CHANNEL = "desktop:preview-set-annotation-theme";
export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element";
export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element";
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
DesktopPreviewRegisterWebviewInputSchema,
DesktopPreviewScreenshotArtifactSchema,
DesktopPreviewSetColorSchemeInputSchema,
BrowserImportResult,
BrowserImportSource,
DesktopPreviewClearDataInputSchema,
DesktopPreviewImportCookiesInputSchema,
DesktopPreviewCreateTabInputSchema,
DesktopPreviewTabInputSchema,
DesktopPreviewWebviewConfigSchema,
Expand All @@ -29,6 +32,7 @@ import * as Schema from "effect/Schema";
import * as NodeURL from "node:url";

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

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

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

export const setAnnotationTheme = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL,
payload: DesktopPreviewAnnotationThemeInputSchema,
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 @@ -185,6 +185,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
openDevTools: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL),
importBrowserCookies: (input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_COOKIES_CHANNEL, input),
clearCookies: (environmentId, profileId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
clearCache: (environmentId, profileId) =>
Expand Down
115 changes: 115 additions & 0 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import {
HostProcessEnvironment,
HostProcessExecutablePath,
HostProcessPlatform,
} from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";

import * as 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")!;

/**
* Dies if the import reaches session work: every case here covers a request
* that must be rejected before a cookie is read or written.
*/
const rejectedBeforeSession = Layer.succeed(BrowserSession.BrowserSession, {
derivePartition: () => Effect.die("derivePartition must not be reached"),
getSession: () => Effect.die("getSession must not be reached"),
clearStorage: () => Effect.die("clearStorage must not be reached"),
clearCache: () => Effect.die("clearCache must not be reached"),
} as unknown as BrowserSession.BrowserSession["Service"]);

/**
* 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),
);
});
Loading
Loading