Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3503088
feat(desktop): partition preview browsers by profile
juliusmarminge Aug 16, 2026
bd3f910
feat(web): manage browser profiles from Settings → Integrations
juliusmarminge Aug 16, 2026
b5435d2
feat(web): open browser tabs in a chosen profile
juliusmarminge Aug 16, 2026
bd58fab
fix(web): repair the Browser surface card and collapse the profile me…
juliusmarminge Aug 16, 2026
2fbfa0e
fix(web): give menu submenu triggers the same cursor as menu items
juliusmarminge Aug 16, 2026
0a9e51f
feat(web): show which profile a browser tab is running in
juliusmarminge Aug 16, 2026
fa3e6b5
fix(web): clear the profile the menu names, and only that profile
juliusmarminge Aug 16, 2026
040e929
fix(web): keep the profile chrome from crowding its neighbours
juliusmarminge Aug 16, 2026
a1568c0
fix(web): give the profile badge a real ellipsis
juliusmarminge Aug 17, 2026
5cd1e0c
fix(web): dim the profile list with the rest of the desktop-only block
juliusmarminge Aug 17, 2026
56b0482
fix(web): dim only the row content that has no disabled state of its own
juliusmarminge Aug 17, 2026
904f7bd
fix(web): keep profile names from stretching the menus
juliusmarminge Aug 17, 2026
1baec7e
fix(web): make each profile its own row, and truncate the menu heading
juliusmarminge Aug 17, 2026
bccf188
fix(web): clear the partition a legacy tab actually runs in
juliusmarminge Aug 17, 2026
e190ae5
fix(desktop): avoid browser partition scope collisions
juliusmarminge Aug 29, 2026
52548a2
fix(web): theme profile badge tooltip
juliusmarminge Aug 29, 2026
59cc0ff
style(web): format hosted browser webview
juliusmarminge Aug 29, 2026
ccff475
fix(web): hydrate preview open defaults
juliusmarminge Aug 29, 2026
ac3c431
fix(web): show browser panel shortcut
juliusmarminge Aug 29, 2026
944d63d
fix(web): open browser profiles on touch
juliusmarminge Aug 29, 2026
efd9522
fix(web): keep profiles when cleanup fails
juliusmarminge Aug 29, 2026
2a74df7
fix(web): clear profiles from every environment
juliusmarminge Aug 29, 2026
3239ee8
fix(web): update browser profiles from current settings
juliusmarminge Aug 29, 2026
d642f4b
fix(web): identify tabs from removed profiles
juliusmarminge Aug 29, 2026
3cd048f
fix(desktop): isolate browser profile partitions
juliusmarminge Aug 29, 2026
cf5f060
fix(web): group browser submenu affordances
juliusmarminge Aug 29, 2026
3c8ad20
fix(web): disable cancel during profile removal
juliusmarminge Aug 29, 2026
989e2ed
fix(web): disable unavailable profile removal
juliusmarminge Aug 29, 2026
69b6cfb
fix(desktop): encode browser profile scopes safely
juliusmarminge Aug 29, 2026
a6c5b56
fix(web): expose unavailable profile removal reason
juliusmarminge Aug 29, 2026
b1c6b4a
fix(web): wait for settings before profile writes
juliusmarminge Aug 29, 2026
81742f8
fix(web): keep long profile names from stretching the default-profile…
juliusmarminge Sep 2, 2026
32e789f
fix(web): actually truncate long names in the default-profile picker
juliusmarminge Sep 2, 2026
fe5a26f
fix(web): keep the default-profile popup at least as wide as its trigger
juliusmarminge Sep 2, 2026
1a5aa85
fix(desktop): keep lone-surrogate scopes in distinct browser partitions
juliusmarminge Sep 2, 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
61 changes: 61 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,45 @@ describe("BrowserSession", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;

// TextEncoder folds a lone surrogate to U+FFFD, so without escaping these
// two supported ids would hash to one partition and share every cookie.
const loneSurrogate = yield* browserSessions.getPartition("p\ud800");
const replacementChar = yield* browserSessions.getPartition("p\ufffd");
assert.notStrictEqual(loneSurrogate, replacementChar);

// The escape can't be forged with a literal backslash either.
const literal = yield* browserSessions.getPartition("p\\ud800");
assert.notStrictEqual(literal, loneSurrogate);

// And a well-formed scope still lands on its historical partition.
assert.strictEqual(
yield* browserSessions.getPartition("scope-a"),
"persist:t3code-preview-f051bb2c68cb7b2fe969",
);
}).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 +231,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
Loading
Loading