Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f7e8aec
fix(desktop): bump Electron to 44.4.5 so preview tabs stop logging sa…
juliusmarminge Oct 7, 2026
98afe51
fix(desktop): pages in browser tabs can go fullscreen inside the panel
juliusmarminge Oct 7, 2026
1e4815a
fix(web): sidebar and panel shortcuts work while a browser page has f…
juliusmarminge Oct 7, 2026
51caa3d
fix(desktop): custom-scheme links in browser tabs open their app afte…
juliusmarminge Oct 7, 2026
8191965
fix(desktop): View > Reload reloads the app, not the embedded browser…
juliusmarminge Oct 7, 2026
0212489
fix(desktop): window.open("about:blank") no longer replaces the brows…
juliusmarminge Oct 7, 2026
f2fb041
fix(desktop): target=_blank links in a browser tab open a new tab
juliusmarminge Oct 7, 2026
6aba282
fix(desktop): browser loading bar clears after a late cross-origin if…
juliusmarminge Oct 7, 2026
f01ec60
fix(web): desktop browser opens local servers on localhost, not the W…
juliusmarminge Oct 7, 2026
ba86747
fix(web): custom viewport size fields show their full focus ring
juliusmarminge Oct 7, 2026
07f9293
fix(desktop): element picker keeps its screenshot when the capture is…
juliusmarminge Oct 7, 2026
414cf76
fix(web): mini player page follows the player when the sidebar toggles
juliusmarminge Oct 7, 2026
6cd516a
fix(web): hidden browser tabs on macOS stop painting
juliusmarminge Oct 7, 2026
f0e6738
chore(desktop): keep the external-link prompt helper private
juliusmarminge Oct 7, 2026
6227672
fix(desktop): View reload and DevTools target the main window, not a …
juliusmarminge Oct 7, 2026
0a1caa3
fix(web): background link opens keep a tab picked while they were loa…
juliusmarminge Oct 7, 2026
83c8b01
fix(desktop): open-link prompt attaches to the window showing the page
juliusmarminge Oct 7, 2026
e486ac1
fix(web): a background open keeps the new tab when the user picks it
juliusmarminge Oct 7, 2026
c38211e
Merge remote-tracking branch 'origin/main' into HEAD
juliusmarminge Oct 8, 2026
3af35d2
fix(web): a picked element's popover shows its screenshot
juliusmarminge Oct 8, 2026
549a39c
fix(desktop): a download you click in a browser tab lands in Downloads
juliusmarminge Oct 8, 2026
f3bbe7f
Revert "fix(web): hidden browser tabs on macOS stop painting"
juliusmarminge Oct 8, 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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@t3tools/tailscale": "workspace:*",
"dbus-next": "0.10.2",
"effect": "catalog:",
"electron": "44.4.2",
"electron": "44.4.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.9",
"ffi-rs": "1.3.2",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function layerDesktopWindow(
dispatchMenuAction: () => Effect.void,
dispatchSnapShotEvent: () => Effect.void,
zoomMain: () => Effect.void,
runMainContentsCommand: () => Effect.void,
syncAppearance: Effect.void,
});
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
dispatchMenuAction: () => Effect.die("unexpected menu action"),
dispatchSnapShotEvent: () => Effect.void,
zoomMain: () => Effect.die("unexpected zoom"),
runMainContentsCommand: () => Effect.die("unexpected main contents command"),
syncAppearance: Effect.void,
} satisfies DesktopWindow.DesktopWindow["Service"]),
),
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src/electron/ElectronDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service<
readonly pickFiles: (
input: ElectronDialogPickFilesInput,
) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
/** Pass `owner` to attach the box to that window so it cannot fall behind it. */
readonly showMessageBox: (
options: Electron.MessageBoxOptions,
owner?: Electron.BrowserWindow,
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
}
Expand Down Expand Up @@ -164,9 +166,12 @@ export const make = ElectronDialog.of({
});
return result.canceled ? [] : result.filePaths;
}),
showMessageBox: (options) =>
showMessageBox: (options, owner) =>
Effect.tryPromise({
try: () => Electron.dialog.showMessageBox(options),
try: () =>
owner === undefined
? Electron.dialog.showMessageBox(options)
: Electron.dialog.showMessageBox(owner, options),
catch: (cause) =>
new ElectronDialogShowMessageBoxError({
type: options.type ?? null,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link";

export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper";

Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export const installPreviewEventForwarding = Effect.fn(
yield* manager.subscribePointerEvents((event) =>
electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
);
yield* manager.subscribeOpenLinks((event) =>
electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event),
);
});

export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
DesktopBridge,
DesktopPreviewOpenLinkEvent,
DesktopPreviewPointerEvent,
DesktopPreviewRecordingInputEvent,
DesktopPreviewRecordingFrame,
Expand Down Expand Up @@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return () =>
ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);
},
onOpenLink: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, linkEvent: unknown) => {
if (typeof linkEvent !== "object" || linkEvent === null) return;
listener(linkEvent as DesktopPreviewOpenLinkEvent);
};
ipcRenderer.on(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
return () =>
ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
},
},
} satisfies DesktopBridge);
96 changes: 94 additions & 2 deletions apps/desktop/src/preview/BrowserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
>(),
}));

const { fromWebContents } = vi.hoisted(() => ({
fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
}));

vi.mock("electron", () => ({
BrowserWindow: { fromWebContents },
session: {
fromPartition,
},
}));

import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as BrowserSession from "./BrowserSession.ts";

const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer));
const { showMessageBox } = vi.hoisted(() => ({
showMessageBox: vi.fn<
(options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number
>(() => 1),
}));
const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.die("unused"),
pickFiles: () => Effect.die("unused"),
showMessageBox: (options, owner) =>
Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })),
showErrorBox: () => Effect.void,
});
const layer = BrowserSession.layer.pipe(
Layer.provide(Layer.merge(NodeServices.layer, layerDialog)),
);

describe("BrowserSession", () => {
beforeEach(() => {
Expand Down Expand Up @@ -168,6 +188,7 @@ describe("BrowserSession", () => {
"clipboard-sanitized-write",
"notifications",
"geolocation",
"fullscreen",
]) {
assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
assert.isTrue(
Expand All @@ -189,6 +210,73 @@ describe("BrowserSession", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("opens custom-scheme links externally only after the user confirms", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
const partition = yield* browserSessions.getPartition("scope-a");
yield* browserSessions.getSession("scope-a");
const requestHandler =
sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
assert.isFunction(requestHandler);

const request = (externalURL: string) =>
Effect.callback<boolean>((resume) => {
requestHandler(
null,
"openExternal",
(granted: boolean) => resume(Effect.succeed(granted)),
{ externalURL },
);
});

showMessageBox.mockReset();
showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1);
assert.isTrue(yield* request("slack://open?team=T1"));
assert.isFalse(yield* request("zoommtg://zoom.us/join"));
assert.strictEqual(showMessageBox.mock.calls.length, 2);

for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) {
assert.isFalse(yield* request(url), `${url} must never open externally`);
}
assert.strictEqual(showMessageBox.mock.calls.length, 2);
}).pipe(Effect.provide(layer)),
);

// A prompt without an owner can open behind the app window, leaving the one
// prompt slot taken and every later link silently denied.
it.effect("attaches the open-externally prompt to the window hosting the preview", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
const partition = yield* browserSessions.getPartition("scope-a");
yield* browserSessions.getSession("scope-a");
const requestHandler =
sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
assert.isFunction(requestHandler);

const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow;
const host = { isDestroyed: () => false };
const guest = { isDestroyed: () => false, hostWebContents: host };
fromWebContents.mockImplementation((webContents) =>
webContents === host ? hostWindow : null,
);
showMessageBox.mockReset();
showMessageBox.mockReturnValueOnce(1);

yield* Effect.callback<boolean>((resume) => {
requestHandler(
guest,
"openExternal",
(granted: boolean) => resume(Effect.succeed(granted)),
{
externalURL: "slack://open?team=T1",
},
);
});

assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow);
}).pipe(Effect.provide(layer)),
);

it.effect("preserves partition scope and the platform failure chain", () => {
const nativeCause = new Error("native digest failed");
const platformCause = PlatformError.systemError({
Expand Down Expand Up @@ -218,7 +306,11 @@ describe("BrowserSession", () => {
"Failed to derive a desktop preview browser partition for scope environment-a.",
);
assert.notInclude(error.message, nativeCause.message);
}).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(layerFailingCrypto))));
}).pipe(
Effect.provide(
BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))),
),
);
});

it.effect("preserves session scope, partition, and the Electron failure", () =>
Expand Down
120 changes: 115 additions & 5 deletions apps/desktop/src/preview/BrowserSession.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Session } from "electron";
import { session } from "electron";
import type { Session, WebContents } from "electron";
import { BrowserWindow, session } from "electron";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
Expand All @@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as SynchronizedRef from "effect/SynchronizedRef";

import * as ElectronDialog from "../electron/ElectronDialog.ts";

const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
/**
* Incognito partitions deliberately omit the `persist:` prefix, which is what
Expand All @@ -33,12 +35,51 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
"clipboard-sanitized-write",
"notifications",
"geolocation",
// The Fullscreen API. `DesktopWindow` disables HTML fullscreen window resizing
// for the app window and every preview guest, so a page that goes fullscreen
// fills its own webview instead of taking over the whole T3 window.
"fullscreen",
// Deliberately NOT local-fonts: preview sessions run untrusted web content,
// and silently granting it would hand every page the user's installed-font
// fingerprint (and font file bytes via FontData.blob()). The app's own font
// picker runs in the main window session, which is unaffected by this list.
]);

/**
* Schemes a preview page may never hand to the OS, even with the user's
* consent: they read local files, run script, or are web pages that belong in
* the preview itself.
*/
const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set([
"about:",
"blob:",
"chrome:",
"data:",
"devtools:",
"file:",
"filesystem:",
"http:",
"https:",
"javascript:",
"view-source:",
]);

/**
* The URL to offer the user when a preview page navigates to a custom scheme
* such as `slack://` or `zoom://`, or `null` when it must stay denied.
*/
const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => {
if (!rawUrl) return null;
try {
const url = new URL(rawUrl);
return NEVER_EXTERNAL_PROTOCOLS.has(url.protocol) ? null : url.href;
} catch {
return null;
}
};

const MAX_PROMPT_URL_LENGTH = 300;

export class BrowserSessionPartitionDerivationError extends Schema.TaggedError<BrowserSessionPartitionDerivationError>()(
"BrowserSessionPartitionDerivationError",
{
Expand Down Expand Up @@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array =>
),
);

/**
* The window showing a preview guest. A `<webview>` guest has no window of its
* own, so look it up through the page that embeds it.
*/
const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => {
if (guest === null || guest.isDestroyed()) return undefined;
const host = guest.hostWebContents ?? guest;
if (host.isDestroyed()) return undefined;
return BrowserWindow.fromWebContents(host) ?? undefined;
};

/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* BrowserSessionMake() {
const crypto = yield* Crypto.Crypto;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const runFork = Effect.runForkWith(yield* Effect.context<never>());
// One prompt at a time, so a page cannot stack dialogs by looping a deep link.
let externalPromptOpen = false;
/**
* Electron grants an external-protocol navigation through the `openExternal`
* permission and then launches the OS handler itself. Ask first: previews run
* untrusted pages, and a silent grant would let any of them launch apps.
*/
const confirmOpenExternal = (
requester: WebContents | null,
rawUrl: string | undefined,
callback: (granted: boolean) => void,
): void => {
const url = externalProtocolPromptUrl(rawUrl);
if (url === null || externalPromptOpen) {
callback(false);
return;
}
externalPromptOpen = true;
const settle = (granted: boolean) =>
Effect.sync(() => {
externalPromptOpen = false;
callback(granted);
});
const shownUrl =
url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url;
runFork(
electronDialog
.showMessageBox(
{
type: "question",
buttons: ["Open", "Cancel"],
defaultId: 0,
cancelId: 1,
message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`,
detail: `A page in the browser wants to open another application:\n\n${shownUrl}`,
},
previewHostWindow(requester),
)
.pipe(
Effect.map(({ response }) => response === 0),
Effect.orElseSucceed(() => false),
Effect.flatMap(settle),
Effect.onInterrupt(() => settle(false)),
),
);
};
Comment thread
juliusmarminge marked this conversation as resolved.
const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());

const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
Expand Down Expand Up @@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {
// the challenge every few seconds, so logins behind it never complete
// (#5002). Re-setting the unchanged native string is harmless, so it
// is the rewritten string itself that trips the check.
browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
});
browserSession.setPermissionRequestHandler(
(webContents, permission, callback, details) => {
if (permission === "openExternal") {
confirmOpenExternal(
webContents,
"externalURL" in details ? details.externalURL : undefined,
callback,
);
return;
}
callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
},
);
browserSession.setPermissionCheckHandler((_webContents, permission) =>
ALLOWED_PREVIEW_PERMISSIONS.has(permission),
);
Expand Down
Loading
Loading