Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7eda4e9
feat(desktop): partition preview browsers by profile
juliusmarminge Aug 16, 2026
1131795
feat(web): manage browser profiles from Settings → Integrations
juliusmarminge Aug 16, 2026
54de817
feat(web): open browser tabs in a chosen profile
juliusmarminge Aug 16, 2026
d023a81
fix(web): repair the Browser surface card and collapse the profile me…
juliusmarminge Aug 16, 2026
56f42dd
fix(web): give menu submenu triggers the same cursor as menu items
juliusmarminge Aug 16, 2026
e8fafea
feat(web): show which profile a browser tab is running in
juliusmarminge Aug 16, 2026
c5c3f19
fix(web): clear the profile the menu names, and only that profile
juliusmarminge Aug 16, 2026
cd6bfa2
fix(web): keep the profile chrome from crowding its neighbours
juliusmarminge Aug 16, 2026
9244cfc
fix(web): give the profile badge a real ellipsis
juliusmarminge Aug 17, 2026
9ca7160
fix(web): dim the profile list with the rest of the desktop-only block
juliusmarminge Aug 17, 2026
aa72dd4
fix(web): dim only the row content that has no disabled state of its own
juliusmarminge Aug 17, 2026
3342c39
fix(web): keep profile names from stretching the menus
juliusmarminge Aug 17, 2026
c909639
fix(web): make each profile its own row, and truncate the menu heading
juliusmarminge Aug 17, 2026
c827d0f
fix(web): clear the partition a legacy tab actually runs in
juliusmarminge Aug 17, 2026
6f78727
fix(desktop): avoid browser partition scope collisions
juliusmarminge Aug 29, 2026
e554830
fix(web): theme profile badge tooltip
juliusmarminge Aug 29, 2026
c9ab6e4
style(web): format hosted browser webview
juliusmarminge Aug 29, 2026
1500150
fix(web): hydrate preview open defaults
juliusmarminge Aug 29, 2026
2a09d96
fix(web): show browser panel shortcut
juliusmarminge Aug 29, 2026
a083076
fix(web): open browser profiles on touch
juliusmarminge Aug 29, 2026
7a584d3
fix(web): keep profiles when cleanup fails
juliusmarminge Aug 29, 2026
b5a107c
fix(web): clear profiles from every environment
juliusmarminge Aug 29, 2026
9ecaa61
fix(web): update browser profiles from current settings
juliusmarminge Aug 29, 2026
eab495f
fix(web): identify tabs from removed profiles
juliusmarminge Aug 29, 2026
d8b9808
fix(desktop): isolate browser profile partitions
juliusmarminge Aug 29, 2026
4de923d
fix(web): group browser submenu affordances
juliusmarminge Aug 29, 2026
c4e69cf
fix(web): disable cancel during profile removal
juliusmarminge Aug 29, 2026
53aacc4
fix(web): disable unavailable profile removal
juliusmarminge Aug 29, 2026
45ecc3d
fix(desktop): encode browser profile scopes safely
juliusmarminge Aug 29, 2026
a1f7de4
fix(web): expose unavailable profile removal reason
juliusmarminge Aug 29, 2026
f666480
fix(web): wait for settings before profile writes
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
44 changes: 43 additions & 1 deletion apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { it as effectIt } from "@effect/vitest";
import { PreviewAutomationStatus } from "@t3tools/contracts";
import {
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
PreviewAutomationStatus,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
Expand Down Expand Up @@ -38,6 +42,44 @@ describe("preview IPC methods", () => {
expect(fromPartition).not.toHaveBeenCalled();
});

it("derives distinct partition scopes when identifiers contain the delimiter", () => {
const first = PreviewIpc.resolvePartitionScope("a", "b::c");
const second = PreviewIpc.resolvePartitionScope("a::b", "c");

expect(first).toEqual({ scope: '["a","b::c"]', persistent: true, namespace: "profile" });
expect(second).toEqual({ scope: '["a::b","c"]', persistent: true, namespace: "profile" });
expect(first.scope).not.toBe(second.scope);
});

it("preserves lone surrogates without collapsing them to replacement characters", () => {
const highSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\ud800");
const lowSurrogate = PreviewIpc.resolvePartitionScope("environment", "profile-\udc00");
const replacement = PreviewIpc.resolvePartitionScope("environment", "profile-�");

expect(highSurrogate.scope).toBe('["environment","profile-\\ud800"]');
expect(lowSurrogate.scope).toBe('["environment","profile-\\udc00"]');
expect(highSurrogate.scope).not.toBe(lowSurrogate.scope);
expect(highSurrogate.scope).not.toBe(replacement.scope);
expect(lowSurrogate.scope).not.toBe(replacement.scope);
});

it("keeps the legacy default partition scope and incognito persistence", () => {
expect(PreviewIpc.resolvePartitionScope("environment::legacy", undefined)).toEqual({
scope: "environment::legacy",
persistent: true,
});
expect(
PreviewIpc.resolvePartitionScope("environment::legacy", DEFAULT_BROWSER_PROFILE_ID),
).toEqual({ scope: "environment::legacy", persistent: true });
expect(
PreviewIpc.resolvePartitionScope("environment::legacy", INCOGNITO_BROWSER_PROFILE_ID),
).toEqual({
scope: '["environment::legacy","incognito"]',
persistent: false,
namespace: "profile",
});
});

effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
Effect.map(
PreviewIpc.registerWebview
Expand Down
73 changes: 64 additions & 9 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import {
DesktopPreviewScreenshotArtifactSchema,
DesktopPreviewSetAudioMutedInputSchema,
DesktopPreviewSetColorSchemeInputSchema,
DesktopPreviewClearDataInputSchema,
DesktopPreviewCreateTabInputSchema,
DesktopPreviewTabInputSchema,
DesktopPreviewWebviewConfigSchema,
PreviewAnnotationSubmissionResultSchema,
PreviewAutomationSnapshot,
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -196,33 +199,85 @@ export const closePictureInPicture = tabMethod(

export const clearCookies = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,
payload: Schema.Void,
payload: DesktopPreviewClearDataInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () {
handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* ({ environmentId, profileId }) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.clearCookies();
yield* manager.clearCookies(yield* resolveClearPartitions(manager, environmentId, profileId));
}),
});

export const clearCache = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
payload: Schema.Void,
payload: DesktopPreviewClearDataInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () {
handler: Effect.fn("desktop.ipc.preview.clearCache")(function* ({ environmentId, profileId }) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.clearCache();
yield* manager.clearCache(yield* resolveClearPartitions(manager, environmentId, profileId));
}),
});

/**
* Partition scope for an (environment, profile) pair.
*
* The default profile keeps the bare environment id it used before profiles
* existed, so upgrading does not strand anyone's existing logins in an
* orphaned partition. Incognito derives a non-persistent partition.
*/
export function resolvePartitionScope(
environmentId: string,
profileId: string | undefined,
): {
readonly scope: string;
readonly persistent: boolean;
readonly namespace?: "profile";
} {
if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) {
return { scope: environmentId, persistent: true };
}
// JSON's tuple framing is injective for strings, including lone UTF-16
// surrogates (which it escapes). URI encoding throws on those supported ids,
// while replacing them with U+FFFD would collapse distinct identities.
return {
scope: JSON.stringify([environmentId, profileId]),
persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID,
namespace: "profile" as const,
};
}

/**
* Clearing without a profile keeps the historical "everything" behaviour for
* an explicit all-profiles action; naming a profile confines it to that
* profile's partition so one profile's sign-out cannot reach the others.
*/
const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* (
manager: PreviewManager.PreviewManager["Service"],
environmentId: string,
profileId: string | undefined,
) {
if (profileId === undefined) return undefined;
const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
// Loading the session is what puts the partition in the map the clear walks.
// Deriving the partition string alone leaves nothing to match, so clearing a
// profile with no tab open this run — after a restart, or when deleting a
// profile — would report success and delete nothing.
yield* manager.getBrowserSession(scope, persistent, namespace);
return [yield* manager.getBrowserPartition(scope, persistent, namespace)];
});

export const getPreviewConfig = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL,
payload: DesktopPreviewConfigInputSchema,
result: DesktopPreviewWebviewConfigSchema,
handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) {
handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.getBrowserSession(environmentId);
const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId);
// Creating the session first is what installs the UA rewrite and permission
// handlers; a guest that attached to an untouched partition would run with
// Electron's default UA and Chromium's default permission behaviour.
yield* manager.getBrowserSession(scope, persistent, namespace);
return {
partition: yield* manager.getBrowserPartition(environmentId),
partition: yield* manager.getBrowserPartition(scope, persistent, namespace),
webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
preloadUrl: NodeURL.pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
};
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
openDevTools: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
clearCache: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL),
getPreviewConfig: (environmentId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId }),
clearCookies: (environmentId, profileId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, { environmentId, profileId }),
clearCache: (environmentId, profileId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }),
getPreviewConfig: (environmentId, profileId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }),
setAnnotationTheme: (theme) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }),
pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }),
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/src/preview/BrowserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ describe("BrowserSession", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;

// These share the same scope string: default environment `a::b`, and
// environment `a` with nondefault profile `b`.
const legacyDefault = yield* browserSessions.getPartition("a::b");
const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile");

assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e");
assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e");
assert.notStrictEqual(nondefaultProfile, legacyDefault);
assert.isTrue(browserSessions.isPartition(legacyDefault));
assert.isTrue(browserSessions.isPartition(nondefaultProfile));
}).pipe(Effect.provide(layer)),
);

it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
Expand Down Expand Up @@ -192,6 +209,28 @@ describe("BrowserSession", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("clears a partition whose session has not been opened yet", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
const partition = yield* browserSessions.getPartition("scope-untouched");

// Deriving the partition string does not create the session, and the
// clear only walks sessions it already holds. Without loading it first
// this reports success and deletes nothing — which is what a user
// clearing a profile after a restart would get.
assert.isUndefined(sessions.get(partition));
yield* browserSessions.clearCookies([partition]);
assert.isUndefined(sessions.get(partition));

yield* browserSessions.getSession("scope-untouched");
yield* browserSessions.clearCookies([partition]);

const created = sessions.get(partition);
assert.isDefined(created);
assert.strictEqual(created.clearStorageData.mock.calls.length, 1);
}).pipe(Effect.provide(layer)),
);

it.effect("correlates clear failures while still attempting every session", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
Expand Down
72 changes: 60 additions & 12 deletions apps/desktop/src/preview/BrowserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import * as Schema from "effect/Schema";
import * as SynchronizedRef from "effect/SynchronizedRef";

const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
/**
* Incognito partitions deliberately omit the `persist:` prefix, which is what
* makes Chromium keep them in memory and discard them with the process. They
* still carry the product prefix so `isPartition` can admit them — the
* `will-attach-webview` gate rejects anything it does not recognise.
*/
const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-";
const PROFILE_PARTITION_MARKER = "profile-";

export type BrowserSessionPartitionNamespace = "profile";

// Permissions granted to preview web content. `clipboard-sanitized-write` is the
// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT
Expand Down Expand Up @@ -99,19 +109,47 @@ export class BrowserSession extends Context.Service<
{
readonly getPartition: (
scope?: string,
persistent?: boolean,
namespace?: BrowserSessionPartitionNamespace,
) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;
readonly isPartition: (partition: string) => boolean;
readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionGetSessionError>;
readonly clearCookies: () => Effect.Effect<void, BrowserSessionStorageClearError>;
readonly clearCache: () => Effect.Effect<void, BrowserSessionCacheClearError>;
readonly getSession: (
scope?: string,
persistent?: boolean,
namespace?: BrowserSessionPartitionNamespace,
) => Effect.Effect<Session, BrowserSessionGetSessionError>;
/** Omit `partitions` to clear every known partition. */
readonly clearCookies: (
partitions?: ReadonlyArray<string>,
) => Effect.Effect<void, BrowserSessionStorageClearError>;
readonly clearCache: (
partitions?: ReadonlyArray<string>,
) => Effect.Effect<void, BrowserSessionCacheClearError>;
}
>()("@t3tools/desktop/preview/BrowserSession") {}

/**
* Restricts a clear to the given partitions. Omitting them keeps the historical
* "every partition" behaviour, which callers now only use for an explicit
* "all profiles" action — a per-profile clear must never reach across profiles.
*/
const selectSessions = (
sessions: ReadonlyMap<string, Session>,
partitions: ReadonlyArray<string> | undefined,
): ReadonlyArray<readonly [string, Session]> =>
[...sessions.entries()].filter(
([partition]) => partitions === undefined || partitions.includes(partition),
);

export const make = Effect.gen(function* BrowserSessionMake() {
const crypto = yield* Crypto.Crypto;
const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());

const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") {
const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
scope = "shared",
persistent = true,
namespace?: BrowserSessionPartitionNamespace,
) {
const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe(
Effect.mapError(
(cause) =>
Expand All @@ -121,11 +159,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {
}),
),
);
return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`;
const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX;
// Legacy/default partitions are prefix + hex digest. The non-hex profile
// marker creates a disjoint namespace while leaving every legacy default
// partition byte-for-byte unchanged.
return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Encoding.encodeHex(digest).slice(0, 20)}`;
});

const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {
const partition = yield* getPartition(scope);
const getSession = Effect.fn("BrowserSession.getSession")(function* (
scope = "shared",
persistent = true,
namespace?: BrowserSessionPartitionNamespace,
) {
const partition = yield* getPartition(scope, persistent, namespace);
return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => {
const existing = sessions.get(partition);
if (existing) return Effect.succeed([existing, sessions] as const);
Expand Down Expand Up @@ -159,12 +205,14 @@ export const make = Effect.gen(function* BrowserSessionMake() {

return BrowserSession.of({
getPartition,
isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),
isPartition: (partition) =>
partition.startsWith(PREVIEW_PARTITION_PREFIX) ||
partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX),
getSession,
clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) {
const sessions = yield* SynchronizedRef.get(sessionsRef);
yield* Effect.all(
[...sessions.entries()].map(([partition, browserSession]) =>
selectSessions(sessions, partitions).map(([partition, browserSession]) =>
Effect.tryPromise({
try: () =>
browserSession.clearStorageData({
Expand All @@ -180,10 +228,10 @@ export const make = Effect.gen(function* BrowserSessionMake() {
{ concurrency: "unbounded", discard: true },
);
}),
clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) {
const sessions = yield* SynchronizedRef.get(sessionsRef);
yield* Effect.all(
[...sessions.entries()].map(([partition, browserSession]) =>
selectSessions(sessions, partitions).map(([partition, browserSession]) =>
Effect.tryPromise({
try: () => browserSession.clearCache(),
catch: (cause) =>
Expand Down
Loading
Loading