Skip to content

Commit d9f7c0c

Browse files
fix(desktop): browser tab fixes for fullscreen, shortcuts, links, reload and hidden tabs (#16961)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5754121 commit d9f7c0c

35 files changed

Lines changed: 1069 additions & 152 deletions

‎apps/desktop/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@t3tools/tailscale": "workspace:*",
2525
"dbus-next": "0.10.2",
2626
"effect": "catalog:",
27-
"electron": "44.4.2",
27+
"electron": "44.4.5",
2828
"electron-store": "^8.2.0",
2929
"electron-updater": "^6.8.9",
3030
"ffi-rs": "1.3.2",

‎apps/desktop/src/app/DesktopLifecycle.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ function layerDesktopWindow(
9696
dispatchMenuAction: () => Effect.void,
9797
dispatchSnapShotEvent: () => Effect.void,
9898
zoomMain: () => Effect.void,
99+
runMainContentsCommand: () => Effect.void,
99100
syncAppearance: Effect.void,
100101
});
101102
}

‎apps/desktop/src/backend/DesktopBackendPool.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
100100
dispatchMenuAction: () => Effect.die("unexpected menu action"),
101101
dispatchSnapShotEvent: () => Effect.void,
102102
zoomMain: () => Effect.die("unexpected zoom"),
103+
runMainContentsCommand: () => Effect.die("unexpected main contents command"),
103104
syncAppearance: Effect.void,
104105
} satisfies DesktopWindow.DesktopWindow["Service"]),
105106
),

‎apps/desktop/src/electron/ElectronDialog.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service<
9595
readonly pickFiles: (
9696
input: ElectronDialogPickFilesInput,
9797
) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
98+
/** Pass `owner` to attach the box to that window so it cannot fall behind it. */
9899
readonly showMessageBox: (
99100
options: Electron.MessageBoxOptions,
101+
owner?: Electron.BrowserWindow,
100102
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
101103
readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
102104
}
@@ -164,9 +166,12 @@ export const make = ElectronDialog.of({
164166
});
165167
return result.canceled ? [] : result.filePaths;
166168
}),
167-
showMessageBox: (options) =>
169+
showMessageBox: (options, owner) =>
168170
Effect.tryPromise({
169-
try: () => Electron.dialog.showMessageBox(options),
171+
try: () =>
172+
owner === undefined
173+
? Electron.dialog.showMessageBox(options)
174+
: Electron.dialog.showMessageBox(owner, options),
170175
catch: (cause) =>
171176
new ElectronDialogShowMessageBoxError({
172177
type: options.type ?? null,

‎apps/desktop/src/ipc/channels.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
109109
export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
110110
export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
111111
export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
112+
export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link";
112113

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

‎apps/desktop/src/ipc/methods/preview.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export const installPreviewEventForwarding = Effect.fn(
5454
yield* manager.subscribePointerEvents((event) =>
5555
electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
5656
);
57+
yield* manager.subscribeOpenLinks((event) =>
58+
electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event),
59+
);
5760
});
5861

5962
export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({

‎apps/desktop/src/preload.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
DesktopBridge,
3+
DesktopPreviewOpenLinkEvent,
34
DesktopPreviewPointerEvent,
45
DesktopPreviewRecordingInputEvent,
56
DesktopPreviewRecordingFrame,
@@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
416417
return () =>
417418
ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);
418419
},
420+
onOpenLink: (listener) => {
421+
const wrappedListener = (_event: Electron.IpcRendererEvent, linkEvent: unknown) => {
422+
if (typeof linkEvent !== "object" || linkEvent === null) return;
423+
listener(linkEvent as DesktopPreviewOpenLinkEvent);
424+
};
425+
ipcRenderer.on(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
426+
return () =>
427+
ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
428+
},
419429
},
420430
} satisfies DesktopBridge);

‎apps/desktop/src/preview/BrowserSession.test.ts‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
2121
>(),
2222
}));
2323

24+
const { fromWebContents } = vi.hoisted(() => ({
25+
fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
26+
}));
27+
2428
vi.mock("electron", () => ({
29+
BrowserWindow: { fromWebContents },
2530
session: {
2631
fromPartition,
2732
},
2833
}));
2934

35+
import * as ElectronDialog from "../electron/ElectronDialog.ts";
3036
import * as BrowserSession from "./BrowserSession.ts";
3137

32-
const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer));
38+
const { showMessageBox } = vi.hoisted(() => ({
39+
showMessageBox: vi.fn<
40+
(options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number
41+
>(() => 1),
42+
}));
43+
const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
44+
pickFolder: () => Effect.die("unused"),
45+
pickFiles: () => Effect.die("unused"),
46+
showMessageBox: (options, owner) =>
47+
Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })),
48+
showErrorBox: () => Effect.void,
49+
});
50+
const layer = BrowserSession.layer.pipe(
51+
Layer.provide(Layer.merge(NodeServices.layer, layerDialog)),
52+
);
3353

3454
describe("BrowserSession", () => {
3555
beforeEach(() => {
@@ -168,6 +188,7 @@ describe("BrowserSession", () => {
168188
"clipboard-sanitized-write",
169189
"notifications",
170190
"geolocation",
191+
"fullscreen",
171192
]) {
172193
assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
173194
assert.isTrue(
@@ -189,6 +210,73 @@ describe("BrowserSession", () => {
189210
}).pipe(Effect.provide(layer)),
190211
);
191212

213+
it.effect("opens custom-scheme links externally only after the user confirms", () =>
214+
Effect.gen(function* () {
215+
const browserSessions = yield* BrowserSession.BrowserSession;
216+
const partition = yield* browserSessions.getPartition("scope-a");
217+
yield* browserSessions.getSession("scope-a");
218+
const requestHandler =
219+
sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
220+
assert.isFunction(requestHandler);
221+
222+
const request = (externalURL: string) =>
223+
Effect.callback<boolean>((resume) => {
224+
requestHandler(
225+
null,
226+
"openExternal",
227+
(granted: boolean) => resume(Effect.succeed(granted)),
228+
{ externalURL },
229+
);
230+
});
231+
232+
showMessageBox.mockReset();
233+
showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1);
234+
assert.isTrue(yield* request("slack://open?team=T1"));
235+
assert.isFalse(yield* request("zoommtg://zoom.us/join"));
236+
assert.strictEqual(showMessageBox.mock.calls.length, 2);
237+
238+
for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) {
239+
assert.isFalse(yield* request(url), `${url} must never open externally`);
240+
}
241+
assert.strictEqual(showMessageBox.mock.calls.length, 2);
242+
}).pipe(Effect.provide(layer)),
243+
);
244+
245+
// A prompt without an owner can open behind the app window, leaving the one
246+
// prompt slot taken and every later link silently denied.
247+
it.effect("attaches the open-externally prompt to the window hosting the preview", () =>
248+
Effect.gen(function* () {
249+
const browserSessions = yield* BrowserSession.BrowserSession;
250+
const partition = yield* browserSessions.getPartition("scope-a");
251+
yield* browserSessions.getSession("scope-a");
252+
const requestHandler =
253+
sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
254+
assert.isFunction(requestHandler);
255+
256+
const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow;
257+
const host = { isDestroyed: () => false };
258+
const guest = { isDestroyed: () => false, hostWebContents: host };
259+
fromWebContents.mockImplementation((webContents) =>
260+
webContents === host ? hostWindow : null,
261+
);
262+
showMessageBox.mockReset();
263+
showMessageBox.mockReturnValueOnce(1);
264+
265+
yield* Effect.callback<boolean>((resume) => {
266+
requestHandler(
267+
guest,
268+
"openExternal",
269+
(granted: boolean) => resume(Effect.succeed(granted)),
270+
{
271+
externalURL: "slack://open?team=T1",
272+
},
273+
);
274+
});
275+
276+
assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow);
277+
}).pipe(Effect.provide(layer)),
278+
);
279+
192280
it.effect("preserves partition scope and the platform failure chain", () => {
193281
const nativeCause = new Error("native digest failed");
194282
const platformCause = PlatformError.systemError({
@@ -218,7 +306,11 @@ describe("BrowserSession", () => {
218306
"Failed to derive a desktop preview browser partition for scope environment-a.",
219307
);
220308
assert.notInclude(error.message, nativeCause.message);
221-
}).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(layerFailingCrypto))));
309+
}).pipe(
310+
Effect.provide(
311+
BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))),
312+
),
313+
);
222314
});
223315

224316
it.effect("preserves session scope, partition, and the Electron failure", () =>

‎apps/desktop/src/preview/BrowserSession.ts‎

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Session } from "electron";
2-
import { session } from "electron";
1+
import type { Session, WebContents } from "electron";
2+
import { BrowserWindow, session } from "electron";
33
import * as Context from "effect/Context";
44
import * as Crypto from "effect/Crypto";
55
import * as Effect from "effect/Effect";
@@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError";
99
import * as Schema from "effect/Schema";
1010
import * as SynchronizedRef from "effect/SynchronizedRef";
1111

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

48+
/**
49+
* Schemes a preview page may never hand to the OS, even with the user's
50+
* consent: they read local files, run script, or are web pages that belong in
51+
* the preview itself.
52+
*/
53+
const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set([
54+
"about:",
55+
"blob:",
56+
"chrome:",
57+
"data:",
58+
"devtools:",
59+
"file:",
60+
"filesystem:",
61+
"http:",
62+
"https:",
63+
"javascript:",
64+
"view-source:",
65+
]);
66+
67+
/**
68+
* The URL to offer the user when a preview page navigates to a custom scheme
69+
* such as `slack://` or `zoom://`, or `null` when it must stay denied.
70+
*/
71+
const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => {
72+
if (!rawUrl) return null;
73+
try {
74+
const url = new URL(rawUrl);
75+
return NEVER_EXTERNAL_PROTOCOLS.has(url.protocol) ? null : url.href;
76+
} catch {
77+
return null;
78+
}
79+
};
80+
81+
const MAX_PROMPT_URL_LENGTH = 300;
82+
4283
export class BrowserSessionPartitionDerivationError extends Schema.TaggedError<BrowserSessionPartitionDerivationError>()(
4384
"BrowserSessionPartitionDerivationError",
4485
{
@@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array =>
159200
),
160201
);
161202

203+
/**
204+
* The window showing a preview guest. A `<webview>` guest has no window of its
205+
* own, so look it up through the page that embeds it.
206+
*/
207+
const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => {
208+
if (guest === null || guest.isDestroyed()) return undefined;
209+
const host = guest.hostWebContents ?? guest;
210+
if (host.isDestroyed()) return undefined;
211+
return BrowserWindow.fromWebContents(host) ?? undefined;
212+
};
213+
162214
/** @public Service construction is part of the canonical Effect module API. */
163215
export const make = Effect.gen(function* BrowserSessionMake() {
164216
const crypto = yield* Crypto.Crypto;
217+
const electronDialog = yield* ElectronDialog.ElectronDialog;
218+
const runFork = Effect.runForkWith(yield* Effect.context<never>());
219+
// One prompt at a time, so a page cannot stack dialogs by looping a deep link.
220+
let externalPromptOpen = false;
221+
/**
222+
* Electron grants an external-protocol navigation through the `openExternal`
223+
* permission and then launches the OS handler itself. Ask first: previews run
224+
* untrusted pages, and a silent grant would let any of them launch apps.
225+
*/
226+
const confirmOpenExternal = (
227+
requester: WebContents | null,
228+
rawUrl: string | undefined,
229+
callback: (granted: boolean) => void,
230+
): void => {
231+
const url = externalProtocolPromptUrl(rawUrl);
232+
if (url === null || externalPromptOpen) {
233+
callback(false);
234+
return;
235+
}
236+
externalPromptOpen = true;
237+
const settle = (granted: boolean) =>
238+
Effect.sync(() => {
239+
externalPromptOpen = false;
240+
callback(granted);
241+
});
242+
const shownUrl =
243+
url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url;
244+
runFork(
245+
electronDialog
246+
.showMessageBox(
247+
{
248+
type: "question",
249+
buttons: ["Open", "Cancel"],
250+
defaultId: 0,
251+
cancelId: 1,
252+
message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`,
253+
detail: `A page in the browser wants to open another application:\n\n${shownUrl}`,
254+
},
255+
previewHostWindow(requester),
256+
)
257+
.pipe(
258+
Effect.map(({ response }) => response === 0),
259+
Effect.orElseSucceed(() => false),
260+
Effect.flatMap(settle),
261+
Effect.onInterrupt(() => settle(false)),
262+
),
263+
);
264+
};
165265
const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
166266

167267
const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
@@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {
203303
// the challenge every few seconds, so logins behind it never complete
204304
// (#5002). Re-setting the unchanged native string is harmless, so it
205305
// is the rewritten string itself that trips the check.
206-
browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
207-
callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
208-
});
306+
browserSession.setPermissionRequestHandler(
307+
(webContents, permission, callback, details) => {
308+
if (permission === "openExternal") {
309+
confirmOpenExternal(
310+
webContents,
311+
"externalURL" in details ? details.externalURL : undefined,
312+
callback,
313+
);
314+
return;
315+
}
316+
callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
317+
},
318+
);
209319
browserSession.setPermissionCheckHandler((_webContents, permission) =>
210320
ALLOWED_PREVIEW_PERMISSIONS.has(permission),
211321
);

0 commit comments

Comments
 (0)