authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:38:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:38:01-07:00
logd9f7c0caed5cc0fe84cec97f2b6f002084542a4e
tree16639b265d73355aded011e18eb787be5b5da42a
parent5754121c23f73201d47a96feb97eca7b96334a2a
signature Signed by PGP key B5690EEEBB952194

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>

35 files changed, 1069 insertions(+), 152 deletions(-)

apps/desktop/package.json+1-1
...@@ -24,7 +24,7 @@...@@ -24,7 +24,7 @@
24 "@t3tools/tailscale": "workspace:*",24 "@t3tools/tailscale": "workspace:*",
25 "dbus-next": "0.10.2",25 "dbus-next": "0.10.2",
26 "effect": "catalog:",26 "effect": "catalog:",
27 "electron": "44.4.2",27 "electron": "44.4.5",
28 "electron-store": "^8.2.0",28 "electron-store": "^8.2.0",
29 "electron-updater": "^6.8.9",29 "electron-updater": "^6.8.9",
30 "ffi-rs": "1.3.2",30 "ffi-rs": "1.3.2",
apps/desktop/src/app/DesktopLifecycle.test.ts+1
...@@ -96,6 +96,7 @@ function layerDesktopWindow(...@@ -96,6 +96,7 @@ function layerDesktopWindow(
96 dispatchMenuAction: () => Effect.void,96 dispatchMenuAction: () => Effect.void,
97 dispatchSnapShotEvent: () => Effect.void,97 dispatchSnapShotEvent: () => Effect.void,
98 zoomMain: () => Effect.void,98 zoomMain: () => Effect.void,
99 runMainContentsCommand: () => Effect.void,
99 syncAppearance: Effect.void,100 syncAppearance: Effect.void,
100 });101 });
101}102}
apps/desktop/src/backend/DesktopBackendPool.test.ts+1
...@@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De...@@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
100 dispatchMenuAction: () => Effect.die("unexpected menu action"),100 dispatchMenuAction: () => Effect.die("unexpected menu action"),
101 dispatchSnapShotEvent: () => Effect.void,101 dispatchSnapShotEvent: () => Effect.void,
102 zoomMain: () => Effect.die("unexpected zoom"),102 zoomMain: () => Effect.die("unexpected zoom"),
103 runMainContentsCommand: () => Effect.die("unexpected main contents command"),
103 syncAppearance: Effect.void,104 syncAppearance: Effect.void,
104 } satisfies DesktopWindow.DesktopWindow["Service"]),105 } satisfies DesktopWindow.DesktopWindow["Service"]),
105 ),106 ),
apps/desktop/src/electron/ElectronDialog.ts+7-2
...@@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service<...@@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service<
95 readonly pickFiles: (95 readonly pickFiles: (
96 input: ElectronDialogPickFilesInput,96 input: ElectronDialogPickFilesInput,
97 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;97 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
98 /** Pass `owner` to attach the box to that window so it cannot fall behind it. */
98 readonly showMessageBox: (99 readonly showMessageBox: (
99 options: Electron.MessageBoxOptions,100 options: Electron.MessageBoxOptions,
101 owner?: Electron.BrowserWindow,
100 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;102 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
101 readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;103 readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
102 }104 }
...@@ -164,9 +166,12 @@ export const make = ElectronDialog.of({...@@ -164,9 +166,12 @@ export const make = ElectronDialog.of({
164 });166 });
165 return result.canceled ? [] : result.filePaths;167 return result.canceled ? [] : result.filePaths;
166 }),168 }),
167 showMessageBox: (options) =>169 showMessageBox: (options, owner) =>
168 Effect.tryPromise({170 Effect.tryPromise({
169 try: () => Electron.dialog.showMessageBox(options),171 try: () =>
172 owner === undefined
173 ? Electron.dialog.showMessageBox(options)
174 : Electron.dialog.showMessageBox(owner, options),
170 catch: (cause) =>175 catch: (cause) =>
171 new ElectronDialogShowMessageBoxError({176 new ElectronDialogShowMessageBoxError({
172 type: options.type ?? null,177 type: options.type ?? null,
apps/desktop/src/ipc/channels.ts+1
...@@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";...@@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
109export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";109export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
110export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";110export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
111export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";111export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
112export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link";
112113
113export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper";114export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper";
114115
apps/desktop/src/ipc/methods/preview.ts+3
...@@ -54,6 +54,9 @@ export const installPreviewEventForwarding = Effect.fn(...@@ -54,6 +54,9 @@ export const installPreviewEventForwarding = Effect.fn(
54 yield* manager.subscribePointerEvents((event) =>54 yield* manager.subscribePointerEvents((event) =>
55 electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),55 electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
56 );56 );
57 yield* manager.subscribeOpenLinks((event) =>
58 electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event),
59 );
57});60});
5861
59export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({62export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({
apps/desktop/src/preload.ts+10
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type {1import type {
2 DesktopBridge,2 DesktopBridge,
3 DesktopPreviewOpenLinkEvent,
3 DesktopPreviewPointerEvent,4 DesktopPreviewPointerEvent,
4 DesktopPreviewRecordingInputEvent,5 DesktopPreviewRecordingInputEvent,
5 DesktopPreviewRecordingFrame,6 DesktopPreviewRecordingFrame,
...@@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
416 return () =>417 return () =>
417 ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);418 ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);
418 },419 },
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 },
419 },429 },
420} satisfies DesktopBridge);430} satisfies DesktopBridge);
apps/desktop/src/preview/BrowserSession.test.ts+94-2
...@@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({...@@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
21 >(),21 >(),
22}));22}));
2323
24const { fromWebContents } = vi.hoisted(() => ({
25 fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
26}));
27
24vi.mock("electron", () => ({28vi.mock("electron", () => ({
29 BrowserWindow: { fromWebContents },
25 session: {30 session: {
26 fromPartition,31 fromPartition,
27 },32 },
28}));33}));
2934
35import * as ElectronDialog from "../electron/ElectronDialog.ts";
30import * as BrowserSession from "./BrowserSession.ts";36import * as BrowserSession from "./BrowserSession.ts";
3137
32const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer));38const { showMessageBox } = vi.hoisted(() => ({
39 showMessageBox: vi.fn<
40 (options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number
41 >(() => 1),
42}));
43const 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});
50const layer = BrowserSession.layer.pipe(
51 Layer.provide(Layer.merge(NodeServices.layer, layerDialog)),
52);
3353
34describe("BrowserSession", () => {54describe("BrowserSession", () => {
35 beforeEach(() => {55 beforeEach(() => {
...@@ -168,6 +188,7 @@ describe("BrowserSession", () => {...@@ -168,6 +188,7 @@ describe("BrowserSession", () => {
168 "clipboard-sanitized-write",188 "clipboard-sanitized-write",
169 "notifications",189 "notifications",
170 "geolocation",190 "geolocation",
191 "fullscreen",
171 ]) {192 ]) {
172 assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);193 assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
173 assert.isTrue(194 assert.isTrue(
...@@ -189,6 +210,73 @@ describe("BrowserSession", () => {...@@ -189,6 +210,73 @@ describe("BrowserSession", () => {
189 }).pipe(Effect.provide(layer)),210 }).pipe(Effect.provide(layer)),
190 );211 );
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
192 it.effect("preserves partition scope and the platform failure chain", () => {280 it.effect("preserves partition scope and the platform failure chain", () => {
193 const nativeCause = new Error("native digest failed");281 const nativeCause = new Error("native digest failed");
194 const platformCause = PlatformError.systemError({282 const platformCause = PlatformError.systemError({
...@@ -218,7 +306,11 @@ describe("BrowserSession", () => {...@@ -218,7 +306,11 @@ describe("BrowserSession", () => {
218 "Failed to derive a desktop preview browser partition for scope environment-a.",306 "Failed to derive a desktop preview browser partition for scope environment-a.",
219 );307 );
220 assert.notInclude(error.message, nativeCause.message);308 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 );
222 });314 });
223315
224 it.effect("preserves session scope, partition, and the Electron failure", () =>316 it.effect("preserves session scope, partition, and the Electron failure", () =>
apps/desktop/src/preview/BrowserSession.ts+115-5
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { Session } from "electron";1import type { Session, WebContents } from "electron";
2import { session } from "electron";2import { BrowserWindow, session } from "electron";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";4import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
...@@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError";...@@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
10import * as SynchronizedRef from "effect/SynchronizedRef";10import * as SynchronizedRef from "effect/SynchronizedRef";
1111
12import * as ElectronDialog from "../electron/ElectronDialog.ts";
13
12const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";14const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
13/**15/**
14 * Incognito partitions deliberately omit the `persist:` prefix, which is what16 * Incognito partitions deliberately omit the `persist:` prefix, which is what
...@@ -33,12 +35,51 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([...@@ -33,12 +35,51 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
33 "clipboard-sanitized-write",35 "clipboard-sanitized-write",
34 "notifications",36 "notifications",
35 "geolocation",37 "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",
36 // Deliberately NOT local-fonts: preview sessions run untrusted web content,42 // Deliberately NOT local-fonts: preview sessions run untrusted web content,
37 // and silently granting it would hand every page the user's installed-font43 // and silently granting it would hand every page the user's installed-font
38 // fingerprint (and font file bytes via FontData.blob()). The app's own font44 // fingerprint (and font file bytes via FontData.blob()). The app's own font
39 // picker runs in the main window session, which is unaffected by this list.45 // picker runs in the main window session, which is unaffected by this list.
40]);46]);
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 */
53const 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 */
71const 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
81const MAX_PROMPT_URL_LENGTH = 300;
82
42export class BrowserSessionPartitionDerivationError extends Schema.TaggedError<BrowserSessionPartitionDerivationError>()(83export class BrowserSessionPartitionDerivationError extends Schema.TaggedError<BrowserSessionPartitionDerivationError>()(
43 "BrowserSessionPartitionDerivationError",84 "BrowserSessionPartitionDerivationError",
44 {85 {
...@@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array =>...@@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array =>
159 ),200 ),
160 );201 );
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 */
207const 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
162/** @public Service construction is part of the canonical Effect module API. */214/** @public Service construction is part of the canonical Effect module API. */
163export const make = Effect.gen(function* BrowserSessionMake() {215export const make = Effect.gen(function* BrowserSessionMake() {
164 const crypto = yield* Crypto.Crypto;216 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 };
165 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());265 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
166266
167 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (267 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
...@@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {...@@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {
203 // the challenge every few seconds, so logins behind it never complete303 // the challenge every few seconds, so logins behind it never complete
204 // (#5002). Re-setting the unchanged native string is harmless, so it304 // (#5002). Re-setting the unchanged native string is harmless, so it
205 // is the rewritten string itself that trips the check.305 // is the rewritten string itself that trips the check.
206 browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {306 browserSession.setPermissionRequestHandler(
207 callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));307 (webContents, permission, callback, details) => {
208 });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 );
209 browserSession.setPermissionCheckHandler((_webContents, permission) =>319 browserSession.setPermissionCheckHandler((_webContents, permission) =>
210 ALLOWED_PREVIEW_PERMISSIONS.has(permission),320 ALLOWED_PREVIEW_PERMISSIONS.has(permission),
211 );321 );
apps/desktop/src/preview/DesktopBrowserHost.test.ts+29
...@@ -119,4 +119,33 @@ describe("DesktopBrowserHost", () => {...@@ -119,4 +119,33 @@ describe("DesktopBrowserHost", () => {
119 expect(paths).toEqual(["/srv/downloads/guid-1"]);119 expect(paths).toEqual(["/srv/downloads/guid-1"]);
120 }),120 }),
121 );121 );
122
123 it.effect("tells a download the person clicked from one the agent's input started", () =>
124 Effect.gen(function* () {
125 const host = yield* DesktopBrowserHost.make;
126 const debuggee = makeDebuggee();
127 host.attach(key, debuggee.tab);
128 // Reading the page is not acting on it.
129 yield* host.handleCommandLine(
130 encodeJson({
131 type: "cdp",
132 ...key,
133 message: encodeJson({ id: 1, method: "DOM.getDocument", params: {} }),
134 }),
135 );
136 expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(true);
137 yield* host.handleCommandLine(
138 encodeJson({
139 type: "cdp",
140 ...key,
141 message: encodeJson({
142 id: 2,
143 method: "Input.dispatchMouseEvent",
144 params: { type: "mousePressed", x: 1, y: 1 },
145 }),
146 }),
147 );
148 expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false);
149 }),
150 );
122});151});
apps/desktop/src/preview/DesktopBrowserHost.ts+20
...@@ -28,6 +28,11 @@ import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts...@@ -28,6 +28,11 @@ import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts
28const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));28const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
29const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));29const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));
30const lineEncoder = new TextEncoder();30const lineEncoder = new TextEncoder();
31/** CDP commands through which an agent clicks, types, or navigates the page. */
32const AGENT_INPUT_COMMAND =
33 /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/;
34/** How long after an agent's input a download still counts as the agent's. */
35const AGENT_DOWNLOAD_WINDOW_MS = 5_000;
3136
32export interface DesktopBrowserTabKey {37export interface DesktopBrowserTabKey {
33 readonly threadId: string;38 readonly threadId: string;
...@@ -50,6 +55,8 @@ interface AttachedTab {...@@ -50,6 +55,8 @@ interface AttachedTab {
50 downloadDirectory: string | null;55 downloadDirectory: string | null;
51 /** The guid CDP gave the download that is about to start. */56 /** The guid CDP gave the download that is about to start. */
52 pendingDownloadGuid: string | null;57 pendingDownloadGuid: string | null;
58 /** When the server last sent this page input, which only an agent does. */
59 agentInputAt: number;
53 readonly onMessage: (60 readonly onMessage: (
54 event: Electron.Event,61 event: Electron.Event,
55 method: string,62 method: string,
...@@ -72,6 +79,11 @@ export class DesktopBrowserHost extends Context.Service<...@@ -72,6 +79,11 @@ export class DesktopBrowserHost extends Context.Service<
72 readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;79 readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
73 /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */80 /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */
74 readonly detach: (key: DesktopBrowserTabKey) => void;81 readonly detach: (key: DesktopBrowserTabKey) => void;
82 /**
83 * Whether a server tab's download came from the person at this desktop:
84 * the server sent the page no input just before it started.
85 */
86 readonly humanStartedDownload: (source: Electron.WebContents) => boolean;
75 /** Points a server tab's download at the server; false for any other download. */87 /** Points a server tab's download at the server; false for any other download. */
76 readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;88 readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
77 /** The agent's cursor positions for attached tabs, keyed by their server tab. */89 /** The agent's cursor positions for attached tabs, keyed by their server tab. */
...@@ -161,6 +173,7 @@ export const make = Effect.gen(function* () {...@@ -161,6 +173,7 @@ export const make = Effect.gen(function* () {
161 relay: null,173 relay: null,
162 downloadDirectory: null,174 downloadDirectory: null,
163 pendingDownloadGuid: null,175 pendingDownloadGuid: null,
176 agentInputAt: Number.NEGATIVE_INFINITY,
164 onMessage: (_event, method, params, sessionId) => {177 onMessage: (_event, method, params, sessionId) => {
165 if (method === "Browser.downloadWillBegin") {178 if (method === "Browser.downloadWillBegin") {
166 const guid = (params as { guid?: unknown } | undefined)?.guid;179 const guid = (params as { guid?: unknown } | undefined)?.guid;
...@@ -190,9 +203,15 @@ export const make = Effect.gen(function* () {...@@ -190,9 +203,15 @@ export const make = Effect.gen(function* () {
190 tab.relay = null;203 tab.relay = null;
191 return;204 return;
192 }205 }
206 if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now();
193 relayFor(tab).receive(command.value.message);207 relayFor(tab).receive(command.value.message);
194 });208 });
195209
210 const humanStartedDownload = (source: Electron.WebContents) => {
211 const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source);
212 return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS;
213 };
214
196 // Read when a backend starts, not when the host is built.215 // Read when a backend starts, not when the host is built.
197 const announceAll = Effect.suspend(() =>216 const announceAll = Effect.suspend(() =>
198 Effect.forEach(217 Effect.forEach(
...@@ -219,6 +238,7 @@ export const make = Effect.gen(function* () {...@@ -219,6 +238,7 @@ export const make = Effect.gen(function* () {
219 attach,238 attach,
220 detach,239 detach,
221 placeDownload,240 placeDownload,
241 humanStartedDownload,
222 });242 });
223});243});
224244
apps/desktop/src/preview/Manager.test.ts+162-23
...@@ -158,21 +158,31 @@ describe("previewWindowOpenAction", () => {...@@ -158,21 +158,31 @@ describe("previewWindowOpenAction", () => {
158 ).toBe("popup");158 ).toBe("popup");
159 });159 });
160160
161 it("keeps target=_blank links in the preview tab", () => {161 it("opens target=_blank links as a new tab", () => {
162 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe(162 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe(
163 "navigate",163 "new-tab",
164 );164 );
165 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe(165 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe(
166 "navigate",166 "new-tab",
167 );167 );
168 });168 });
169169
170 it("keeps a form POST with a body on the in-place path", () => {
171 // A new tab can only reopen the URL as a GET, which would drop the body.
172 const postBody = { data: [], contentType: "application/x-www-form-urlencoded" };
173 expect(
174 PreviewManager.previewWindowOpenAction({
175 ...details({ disposition: "foreground-tab" }),
176 postBody,
177 }),
178 ).toBe("navigate");
179 });
180
170 it("does not hand a window to schemes that cannot be hardened", () => {181 it("does not hand a window to schemes that cannot be hardened", () => {
171 // A popup skips the `will-attach-webview` hardening, so it only gets a window182 // A popup skips the `will-attach-webview` hardening, so it only gets a window
172 // when its preferences can be overridden. Chromium copies the guest's183 // when its preferences can be overridden. Chromium copies the guest's
173 // preferences for `about:blank` and forbids overriding them.184 // preferences for `about:blank` and forbids overriding them.
174 for (const url of [185 for (const url of [
175 "about:blank",
176 "javascript:alert(1)",186 "javascript:alert(1)",
177 "file:///etc/passwd",187 "file:///etc/passwd",
178 "vscode://vscode-remote/ssh-remote+box/tmp",188 "vscode://vscode-remote/ssh-remote+box/tmp",
...@@ -181,6 +191,16 @@ describe("previewWindowOpenAction", () => {...@@ -181,6 +191,16 @@ describe("previewWindowOpenAction", () => {
181 expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate");191 expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate");
182 }192 }
183 });193 });
194
195 it("denies a blank window instead of blanking the opener", () => {
196 // MSAL opens `about:blank` first and falls back to a redirect on `null`;
197 // loading the blank page into the tab would destroy that fallback.
198 for (const url of ["about:blank", ""]) {
199 for (const disposition of ["new-window", "foreground-tab"] as const) {
200 expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny");
201 }
202 }
203 });
184});204});
185205
186const {206const {
...@@ -355,7 +375,7 @@ const makeTestPreviewWebContents = (...@@ -355,7 +375,7 @@ const makeTestPreviewWebContents = (
355 getType: () => "webview",375 getType: () => "webview",
356 getURL: () => "https://example.com",376 getURL: () => "https://example.com",
357 getTitle: () => "Example",377 getTitle: () => "Example",
358 isLoading: () => false,378 isLoadingMainFrame: () => false,
359 getZoomFactor: () => 1,379 getZoomFactor: () => 1,
360 setZoomFactor: vi.fn(),380 setZoomFactor: vi.fn(),
361 setAudioMuted: vi.fn(),381 setAudioMuted: vi.fn(),
...@@ -460,7 +480,7 @@ const makeFaviconWebContents = (options?: {...@@ -460,7 +480,7 @@ const makeFaviconWebContents = (options?: {
460 getType: () => "webview",480 getType: () => "webview",
461 getURL: () => currentUrl,481 getURL: () => currentUrl,
462 getTitle: () => "Preview",482 getTitle: () => "Preview",
463 isLoading: () => loading,483 isLoadingMainFrame: () => loading,
464 isDevToolsOpened: () => false,484 isDevToolsOpened: () => false,
465 getZoomFactor: () => 1,485 getZoomFactor: () => 1,
466 setZoomFactor: vi.fn(),486 setZoomFactor: vi.fn(),
...@@ -572,6 +592,40 @@ describe("PreviewManager", () => {...@@ -572,6 +592,40 @@ describe("PreviewManager", () => {
572 webviewSend.mockClear();592 webviewSend.mockClear();
573 });593 });
574594
595 effectIt.effect("opens a target=_blank link as a new tab without navigating the opener", () =>
596 withManager((manager) =>
597 Effect.gen(function* () {
598 const preview = makeFaviconWebContents();
599 fromId.mockReturnValue(preview.webContents);
600 const opened: Array<{ tabId: string; url: string; background: boolean }> = [];
601 yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event)));
602 yield* manager.createTab("tab_links");
603 yield* manager.registerWebview("tab_links", 42);
604 const openHandler = (preview.webContents as Electron.WebContents).setWindowOpenHandler as
605 | ReturnType<typeof vi.fn>
606 | undefined;
607 const handler = openHandler?.mock.lastCall?.[0] as (
608 details: Partial<Electron.HandlerDetails>,
609 ) => { action: string };
610
611 expect(
612 handler({ url: "https://github.com/issues/1", disposition: "foreground-tab" }),
613 ).toEqual({ action: "deny" });
614 expect(handler({ url: "about:blank", disposition: "new-window" })).toEqual({
615 action: "deny",
616 });
617 handler({ url: "https://github.com/issues/2", disposition: "background-tab" });
618 yield* settle(() => opened.length === 2);
619
620 expect(preview.loadURL).not.toHaveBeenCalled();
621 expect(opened).toEqual([
622 { tabId: "tab_links", url: "https://github.com/issues/1", background: false },
623 { tabId: "tab_links", url: "https://github.com/issues/2", background: true },
624 ]);
625 }),
626 ),
627 );
628
575 effectIt.effect("keeps preview shortcuts out of the host window", () =>629 effectIt.effect("keeps preview shortcuts out of the host window", () =>
576 withManager((manager) =>630 withManager((manager) =>
577 Effect.gen(function* () {631 Effect.gen(function* () {
...@@ -885,7 +939,7 @@ describe("PreviewManager", () => {...@@ -885,7 +939,7 @@ describe("PreviewManager", () => {
885 getType: () => "webview",939 getType: () => "webview",
886 getURL: () => "about:blank",940 getURL: () => "about:blank",
887 getTitle: () => "",941 getTitle: () => "",
888 isLoading: () => false,942 isLoadingMainFrame: () => false,
889 getZoomFactor: () => 1,943 getZoomFactor: () => 1,
890 setZoomFactor: vi.fn(),944 setZoomFactor: vi.fn(),
891 setAudioMuted: vi.fn(),945 setAudioMuted: vi.fn(),
...@@ -978,7 +1032,7 @@ describe("PreviewManager", () => {...@@ -978,7 +1032,7 @@ describe("PreviewManager", () => {
978 getType: () => "webview",1032 getType: () => "webview",
979 getURL: () => "http://localhost:3200/",1033 getURL: () => "http://localhost:3200/",
980 getTitle: () => "Preview",1034 getTitle: () => "Preview",
981 isLoading: () => false,1035 isLoadingMainFrame: () => false,
982 isDevToolsOpened: () => false,1036 isDevToolsOpened: () => false,
983 getZoomFactor: () => 1,1037 getZoomFactor: () => 1,
984 setZoomFactor: vi.fn(),1038 setZoomFactor: vi.fn(),
...@@ -1481,7 +1535,7 @@ describe("PreviewManager", () => {...@@ -1481,7 +1535,7 @@ describe("PreviewManager", () => {
1481 getType: () => "webview",1535 getType: () => "webview",
1482 getURL: () => url,1536 getURL: () => url,
1483 getTitle: () => "Example",1537 getTitle: () => "Example",
1484 isLoading: () => false,1538 isLoadingMainFrame: () => false,
1485 getZoomFactor: () => {1539 getZoomFactor: () => {
1486 if (!zoomReadable) throw new Error("zoom unavailable");1540 if (!zoomReadable) throw new Error("zoom unavailable");
1487 return effectiveZoom;1541 return effectiveZoom;
...@@ -1551,7 +1605,7 @@ describe("PreviewManager", () => {...@@ -1551,7 +1605,7 @@ describe("PreviewManager", () => {
1551 getType: () => "webview",1605 getType: () => "webview",
1552 getURL: () => url,1606 getURL: () => url,
1553 getTitle: () => "Example",1607 getTitle: () => "Example",
1554 isLoading: () => false,1608 isLoadingMainFrame: () => false,
1555 getZoomFactor: () => 1,1609 getZoomFactor: () => 1,
1556 setZoomFactor: replacementSetZoomFactor,1610 setZoomFactor: replacementSetZoomFactor,
1557 setAudioMuted: vi.fn(),1611 setAudioMuted: vi.fn(),
...@@ -1592,7 +1646,7 @@ describe("PreviewManager", () => {...@@ -1592,7 +1646,7 @@ describe("PreviewManager", () => {
1592 getType: () => "webview",1646 getType: () => "webview",
1593 getURL: () => "https://example.com",1647 getURL: () => "https://example.com",
1594 getTitle: () => "Example",1648 getTitle: () => "Example",
1595 isLoading: () => false,1649 isLoadingMainFrame: () => false,
1596 getZoomFactor: () => 1,1650 getZoomFactor: () => 1,
1597 setZoomFactor,1651 setZoomFactor,
1598 setAudioMuted: vi.fn(),1652 setAudioMuted: vi.fn(),
...@@ -1639,7 +1693,7 @@ describe("PreviewManager", () => {...@@ -1639,7 +1693,7 @@ describe("PreviewManager", () => {
1639 getType: () => "webview",1693 getType: () => "webview",
1640 getURL: () => "https://example.com",1694 getURL: () => "https://example.com",
1641 getTitle: () => "Example",1695 getTitle: () => "Example",
1642 isLoading: () => false,1696 isLoadingMainFrame: () => false,
1643 getZoomFactor: () => 1,1697 getZoomFactor: () => 1,
1644 setZoomFactor,1698 setZoomFactor,
1645 setAudioMuted: vi.fn(),1699 setAudioMuted: vi.fn(),
...@@ -1695,7 +1749,7 @@ describe("PreviewManager", () => {...@@ -1695,7 +1749,7 @@ describe("PreviewManager", () => {
1695 getType: () => "webview",1749 getType: () => "webview",
1696 getURL: () => "https://example.com",1750 getURL: () => "https://example.com",
1697 getTitle: () => "Example",1751 getTitle: () => "Example",
1698 isLoading: () => false,1752 isLoadingMainFrame: () => false,
1699 getZoomFactor: () => 1,1753 getZoomFactor: () => 1,
1700 setZoomFactor: vi.fn(),1754 setZoomFactor: vi.fn(),
1701 setAudioMuted: vi.fn(),1755 setAudioMuted: vi.fn(),
...@@ -1790,7 +1844,7 @@ describe("PreviewManager", () => {...@@ -1790,7 +1844,7 @@ describe("PreviewManager", () => {
1790 getType: () => "webview",1844 getType: () => "webview",
1791 getURL: () => "http://localhost:5173/README.md",1845 getURL: () => "http://localhost:5173/README.md",
1792 getTitle: () => "README.md",1846 getTitle: () => "README.md",
1793 isLoading: () => true,1847 isLoadingMainFrame: () => true,
1794 getZoomFactor: () => 1,1848 getZoomFactor: () => 1,
1795 setZoomFactor: vi.fn(),1849 setZoomFactor: vi.fn(),
1796 setAudioMuted: vi.fn(),1850 setAudioMuted: vi.fn(),
...@@ -1915,7 +1969,7 @@ describe("PreviewManager", () => {...@@ -1915,7 +1969,7 @@ describe("PreviewManager", () => {
1915 getType: () => "webview",1969 getType: () => "webview",
1916 getURL: () => "https://example.com",1970 getURL: () => "https://example.com",
1917 getTitle: () => "Example",1971 getTitle: () => "Example",
1918 isLoading: () => false,1972 isLoadingMainFrame: () => false,
1919 getZoomFactor: () => 1,1973 getZoomFactor: () => 1,
1920 setZoomFactor: vi.fn(),1974 setZoomFactor: vi.fn(),
1921 setAudioMuted,1975 setAudioMuted,
...@@ -2258,7 +2312,7 @@ describe("PreviewManager", () => {...@@ -2258,7 +2312,7 @@ describe("PreviewManager", () => {
2258 getType: () => "webview",2312 getType: () => "webview",
2259 getURL: () => url,2313 getURL: () => url,
2260 getTitle: () => "localhost:5733",2314 getTitle: () => "localhost:5733",
2261 isLoading: () => loading,2315 isLoadingMainFrame: () => loading,
2262 getZoomFactor: () => 1,2316 getZoomFactor: () => 1,
2263 setZoomFactor: vi.fn(),2317 setZoomFactor: vi.fn(),
2264 setAudioMuted: vi.fn(),2318 setAudioMuted: vi.fn(),
...@@ -2339,6 +2393,28 @@ describe("PreviewManager", () => {...@@ -2339,6 +2393,28 @@ describe("PreviewManager", () => {
2339 ),2393 ),
2340 );2394 );
23412395
2396 effectIt.effect("ignores a late cross-origin iframe load once the page has loaded", () =>
2397 withManager((manager) =>
2398 Effect.gen(function* () {
2399 const preview = makeFaviconWebContents();
2400 // The frame tree is still loading the iframe; the main frame is done.
2401 Object.assign(preview.webContents, { isLoading: () => true });
2402 fromId.mockReturnValue(preview.webContents);
2403 const states: PreviewManager.PreviewTabState[] = [];
2404 yield* manager.subscribeStateChanges((_tabId, state) =>
2405 Effect.sync(() => void states.push(state)),
2406 );
2407 yield* manager.createTab("tab_iframe");
2408 yield* manager.registerWebview("tab_iframe", 42);
2409
2410 preview.listeners.get("did-start-loading")?.();
2411 preview.listeners.get("did-stop-loading")?.();
2412 yield* settle(() => states.length > 2);
2413 expect(states.at(-1)?.navStatus.kind).toBe("Success");
2414 }),
2415 ),
2416 );
2417
2342 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>2418 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
2343 withManager((manager) =>2419 withManager((manager) =>
2344 Effect.gen(function* () {2420 Effect.gen(function* () {
...@@ -2351,7 +2427,7 @@ describe("PreviewManager", () => {...@@ -2351,7 +2427,7 @@ describe("PreviewManager", () => {
2351 getType: () => "webview",2427 getType: () => "webview",
2352 getURL: () => "https://example.com:8443/path?query=value",2428 getURL: () => "https://example.com:8443/path?query=value",
2353 getTitle: () => "Example",2429 getTitle: () => "Example",
2354 isLoading: () => false,2430 isLoadingMainFrame: () => false,
2355 getZoomFactor: () => 1,2431 getZoomFactor: () => 1,
2356 setZoomFactor: vi.fn(),2432 setZoomFactor: vi.fn(),
2357 setAudioMuted: vi.fn(),2433 setAudioMuted: vi.fn(),
...@@ -2919,7 +2995,7 @@ describe("PreviewManager", () => {...@@ -2919,7 +2995,7 @@ describe("PreviewManager", () => {
2919 getType: () => "webview",2995 getType: () => "webview",
2920 getURL: () => `https://example.com/${id}`,2996 getURL: () => `https://example.com/${id}`,
2921 getTitle: () => `Example ${id}`,2997 getTitle: () => `Example ${id}`,
2922 isLoading: () => false,2998 isLoadingMainFrame: () => false,
2923 getZoomFactor: () => 1,2999 getZoomFactor: () => 1,
2924 setZoomFactor: vi.fn(),3000 setZoomFactor: vi.fn(),
2925 setAudioMuted: vi.fn(),3001 setAudioMuted: vi.fn(),
...@@ -3381,7 +3457,7 @@ describe("PreviewManager", () => {...@@ -3381,7 +3457,7 @@ describe("PreviewManager", () => {
3381 getType: () => "webview",3457 getType: () => "webview",
3382 getURL: () => "https://example.com",3458 getURL: () => "https://example.com",
3383 getTitle: () => "Example",3459 getTitle: () => "Example",
3384 isLoading: () => false,3460 isLoadingMainFrame: () => false,
3385 getZoomFactor: () => 1,3461 getZoomFactor: () => 1,
3386 setZoomFactor: vi.fn(),3462 setZoomFactor: vi.fn(),
3387 setAudioMuted: vi.fn(),3463 setAudioMuted: vi.fn(),
...@@ -3880,7 +3956,7 @@ describe("PreviewManager", () => {...@@ -3880,7 +3956,7 @@ describe("PreviewManager", () => {
3880 getType: () => "webview",3956 getType: () => "webview",
3881 getURL: () => "https://example.com",3957 getURL: () => "https://example.com",
3882 getTitle: () => "Example",3958 getTitle: () => "Example",
3883 isLoading: () => false,3959 isLoadingMainFrame: () => false,
3884 isFocused: () => true,3960 isFocused: () => true,
3885 getZoomFactor: () => 1,3961 getZoomFactor: () => 1,
3886 setZoomFactor: vi.fn(),3962 setZoomFactor: vi.fn(),
...@@ -4026,7 +4102,7 @@ describe("PreviewManager", () => {...@@ -4026,7 +4102,7 @@ describe("PreviewManager", () => {
4026 getType: () => "webview",4102 getType: () => "webview",
4027 getURL: () => "https://example.com",4103 getURL: () => "https://example.com",
4028 getTitle: () => "Example",4104 getTitle: () => "Example",
4029 isLoading: () => false,4105 isLoadingMainFrame: () => false,
4030 isFocused: () => true,4106 isFocused: () => true,
4031 getZoomFactor: () => 1,4107 getZoomFactor: () => 1,
4032 setZoomFactor: vi.fn(),4108 setZoomFactor: vi.fn(),
...@@ -4095,6 +4171,69 @@ describe("PreviewManager", () => {...@@ -4095,6 +4171,69 @@ describe("PreviewManager", () => {
4095 ),4171 ),
4096 );4172 );
40974173
4174 effectIt.effect("crops the picked area out of a full-page capture at device scale", () =>
4175 withManager((manager) =>
4176 Effect.gen(function* () {
4177 let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
4178 const crop = vi.fn((rect: Electron.Rectangle) => ({
4179 getSize: () => ({ width: rect.width, height: rect.height }),
4180 toDataURL: () => "data:image/png;base64,cropped",
4181 }));
4182 const capturePage = vi.fn(async () => ({
4183 getSize: () => ({ width: 2000, height: 1200 }),
4184 crop,
4185 }));
4186 fromId.mockReturnValue(
4187 Object.assign(makeTestPreviewWebContents(capturePage as never), {
4188 isFocused: () => true,
4189 once: vi.fn(),
4190 ipc: {
4191 on: vi.fn((channel: string, listener: typeof onPicked) => {
4192 if (channel === "preview:element-picked") onPicked = listener;
4193 }),
4194 off: vi.fn(),
4195 removeListener: vi.fn(),
4196 },
4197 }),
4198 );
4199
4200 yield* manager.createTab("tab_1");
4201 yield* manager.registerWebview("tab_1", 42);
4202 const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
4203 yield* Effect.yieldNow;
4204 onPicked?.(
4205 {},
4206 {
4207 id: "annotation_1",
4208 pageUrl: "https://example.com",
4209 pageTitle: "Example",
4210 comment: "Tighten this spacing",
4211 elements: [],
4212 regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
4213 strokes: [],
4214 styleChanges: [],
4215 screenshot: null,
4216 createdAt: "2026-06-11T00:00:00.000Z",
4217 },
4218 { x: 5, y: 6, width: 20, height: 30 },
4219 "attach",
4220 2,
4221 );
4222
4223 const result = yield* Fiber.join(pick);
4224 expect(capturePage).toHaveBeenCalledWith();
4225 expect(crop).toHaveBeenCalledWith({ x: 10, y: 12, width: 40, height: 60 });
4226 expect(result?.screenshotFailed).toBeUndefined();
4227 expect(result?.annotation.screenshot).toEqual({
4228 dataUrl: "data:image/png;base64,cropped",
4229 width: 40,
4230 height: 60,
4231 cropRect: { x: 5, y: 6, width: 20, height: 30 },
4232 });
4233 }),
4234 ),
4235 );
4236
4098 effectIt.effect("a stale capture from a replaced pick never touches the next pick", () =>4237 effectIt.effect("a stale capture from a replaced pick never touches the next pick", () =>
4099 withManager((manager) =>4238 withManager((manager) =>
4100 Effect.gen(function* () {4239 Effect.gen(function* () {
...@@ -4105,7 +4244,7 @@ describe("PreviewManager", () => {...@@ -4105,7 +4244,7 @@ describe("PreviewManager", () => {
4105 getType: () => "webview",4244 getType: () => "webview",
4106 getURL: () => "https://example.com",4245 getURL: () => "https://example.com",
4107 getTitle: () => "Example",4246 getTitle: () => "Example",
4108 isLoading: () => false,4247 isLoadingMainFrame: () => false,
4109 isFocused: () => true,4248 isFocused: () => true,
4110 getZoomFactor: () => 1,4249 getZoomFactor: () => 1,
4111 setZoomFactor: vi.fn(),4250 setZoomFactor: vi.fn(),
...@@ -4190,7 +4329,7 @@ describe("PreviewManager", () => {...@@ -4190,7 +4329,7 @@ describe("PreviewManager", () => {
4190 getType: () => "webview",4329 getType: () => "webview",
4191 getURL: () => "https://example.com",4330 getURL: () => "https://example.com",
4192 getTitle: () => "Example",4331 getTitle: () => "Example",
4193 isLoading: () => false,4332 isLoadingMainFrame: () => false,
4194 getZoomFactor: () => 1,4333 getZoomFactor: () => 1,
4195 setZoomFactor: vi.fn(),4334 setZoomFactor: vi.fn(),
4196 setAudioMuted: vi.fn(),4335 setAudioMuted: vi.fn(),
apps/desktop/src/preview/Manager.ts+142-61
...@@ -13,6 +13,7 @@ import type {...@@ -13,6 +13,7 @@ import type {
13 DesktopPreviewAnnotationTheme,13 DesktopPreviewAnnotationTheme,
14 DesktopPreviewColorScheme,14 DesktopPreviewColorScheme,
15 DesktopPreviewFavicon,15 DesktopPreviewFavicon,
16 DesktopPreviewOpenLinkEvent,
16 DesktopPreviewPointerEvent,17 DesktopPreviewPointerEvent,
17 PreviewAnnotationPayload,18 PreviewAnnotationPayload,
18 PreviewAnnotationRect,19 PreviewAnnotationRect,
...@@ -30,6 +31,7 @@ import { normalizePreviewUrl } from "@t3tools/shared/preview";...@@ -30,6 +31,7 @@ import { normalizePreviewUrl } from "@t3tools/shared/preview";
30import {31import {
31 BrowserWindow,32 BrowserWindow,
32 ClipboardItem,33 ClipboardItem,
34 app,
33 type Session,35 type Session,
34 clipboard,36 clipboard,
35 nativeImage,37 nativeImage,
...@@ -284,61 +286,36 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {...@@ -284,61 +286,36 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
284 };286 };
285};287};
286288
287/** `capturePage` never settles when the guest's compositor is wedged. */
288const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds";
289
290/**289/**
291 * Crops the guest for a picked annotation. A stalled `capturePage` resolves to290 * Crops a full-page capture to a picked annotation. `cropRect` is in CSS px;
292 * `null` after the timeout: the annotation is still sendable without its291 * the capture's pixels are CSS px × the guest's `devicePixelRatio`.
293 * screenshot, and the pick session must settle either way.
294 */292 */
295const captureAnnotationScreenshot = (293const cropAnnotationScreenshot = (
296 tabId: string,294 image: Electron.NativeImage,
297 wc: Electron.WebContents,
298 cropRect: PreviewAnnotationRect | null,295 cropRect: PreviewAnnotationRect | null,
299): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>296 devicePixelRatio: number,
300 Effect.tryPromise({297): PreviewAnnotationPayload["screenshot"] => {
301 // The unused abort signal is what makes this interruptible, and therefore298 const full = image.getSize();
302 // what lets the timeout below fire. Drop the parameter and a stalled299 if (!cropRect) {
303 // capture strands the pick session again.300 return {
304 try: (_signal) =>301 dataUrl: image.toDataURL(),
305 wc.capturePage(302 width: full.width,
306 cropRect303 height: full.height,
307 ? {304 cropRect: { x: 0, y: 0, width: full.width, height: full.height },
308 x: cropRect.x,305 };
309 y: cropRect.y,306 }
310 width: cropRect.width,307 const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
311 height: cropRect.height,308 const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale)));
312 }309 const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale)));
313 : undefined,310 const cropped = image.crop({
314 ),311 x,
315 catch: (cause) =>312 y,
316 new PreviewOperationError({313 width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))),
317 operation: "captureAnnotationScreenshot",314 height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))),
318 tabId,315 });
319 webContentsId: wc.id,316 const size = cropped.getSize();
320 cause,317 return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect };
321 }),318};
322 }).pipe(
323 Effect.map((image): PreviewAnnotationPayload["screenshot"] => {
324 const size = image.getSize();
325 return {
326 dataUrl: image.toDataURL(),
327 width: size.width,
328 height: size.height,
329 cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height },
330 };
331 }),
332 Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT),
333 Effect.flatMap((screenshot) =>
334 Option.isSome(screenshot)
335 ? Effect.succeed(screenshot.value)
336 : Effect.logWarning("preview annotation screenshot timed out").pipe(
337 Effect.annotateLogs({ tabId, webContentsId: wc.id }),
338 Effect.as(null),
339 ),
340 ),
341 );
342319
343const findZoomStep = (current: number): number => {320const findZoomStep = (current: number): number => {
344 const index = ZOOM_LEVELS.findIndex(321 const index = ZOOM_LEVELS.findIndex(
...@@ -437,6 +414,8 @@ type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effe...@@ -437,6 +414,8 @@ type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effe
437414
438type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;415type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;
439416
417type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect<void>;
418
440/**419/**
441 * Protocols a preview page may open in a real popup window.420 * Protocols a preview page may open in a real popup window.
442 *421 *
...@@ -483,14 +462,29 @@ const POPUP_WINDOW_OPTIONS = {...@@ -483,14 +462,29 @@ const POPUP_WINDOW_OPTIONS = {
483 * navigating the preview tab instead destroys the opener the popup has to462 * navigating the preview tab instead destroys the opener the popup has to
484 * `postMessage` its result back to.463 * `postMessage` its result back to.
485 *464 *
486 * `target="_blank"` links arrive as a tab disposition and keep loading in the465 * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web
487 * preview tab, which is what people expect from a link inside a preview.466 * app, which opens it as another preview tab so the page that held the link
467 * stays put. Schemes a popup cannot be hardened for keep loading in place, and
468 * so does a form POST with a body, which a new tab could only reopen as a GET.
469 *
470 * `"deny"` is for a blank window (`""` or `about:blank`). It cannot get a real
471 * window (see `POPUP_PROTOCOLS`), and loading it into the preview tab would
472 * replace the opener with an empty page. Denying makes `window.open()` return
473 * `null`, which SDKs such as MSAL treat as a blocked popup and fall back from.
488 */474 */
489export const previewWindowOpenAction = (details: {475export const previewWindowOpenAction = (details: {
490 readonly url: string;476 readonly url: string;
491 readonly disposition: Electron.HandlerDetails["disposition"];477 readonly disposition: Electron.HandlerDetails["disposition"];
492}): "popup" | "navigate" =>478 readonly postBody?: Electron.PostBody;
493 details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate";479}): "popup" | "new-tab" | "navigate" | "deny" => {
480 if (details.url === "" || details.url === "about:blank") return "deny";
481 if (!isPopupUrl(details.url)) return "navigate";
482 if (details.disposition === "new-window") return "popup";
483 return !details.postBody &&
484 (details.disposition === "foreground-tab" || details.disposition === "background-tab")
485 ? "new-tab"
486 : "navigate";
487};
494488
495export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>489export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
496 input.type === "keyDown" &&490 input.type === "keyDown" &&
...@@ -566,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -566,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
566 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());560 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());
567 const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set());561 const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set());
568 const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set());562 const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set());
563 const openLinkListenersRef = yield* Ref.make<ReadonlySet<OpenLinkListener>>(new Set());
569 const recordingInputListenersRef = yield* Ref.make<ReadonlySet<RecordingInputListener>>(564 const recordingInputListenersRef = yield* Ref.make<ReadonlySet<RecordingInputListener>>(
570 new Set(),565 new Set(),
571 );566 );
...@@ -836,7 +831,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -836,7 +831,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
836 });831 });
837832
838 const deliverEvent = (833 const deliverEvent = (
839 eventKind: "state-change" | "recording-frame" | "recording-input" | "pointer-event",834 eventKind:
835 | "state-change"
836 | "recording-frame"
837 | "recording-input"
838 | "pointer-event"
839 | "open-link",
840 tabId: string,840 tabId: string,
841 delivery: () => Effect.Effect<void>,841 delivery: () => Effect.Effect<void>,
842 ) =>842 ) =>
...@@ -1241,7 +1241,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1241,7 +1241,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1241 const url = wc.getURL();1241 const url = wc.getURL();
1242 const title = wc.getTitle();1242 const title = wc.getTitle();
1243 if (url === "" || url === "about:blank") return { kind: "Idle" };1243 if (url === "" || url === "about:blank") return { kind: "Idle" };
1244 if (wc.isLoading()) return { kind: "Loading", url, title };1244 // Main frame only. `isLoading()` covers the whole frame tree, so a
1245 // cross-origin iframe that loads after the page can leave it true with no
1246 // later event to clear it, and the tab's loading bar never finishes.
1247 if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title };
1245 return { kind: "Success", url, title };1248 return { kind: "Success", url, title };
1246 };1249 };
12471250
...@@ -1607,9 +1610,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1607,9 +1610,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1607 wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);1610 wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);
1608 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);1611 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1609 wc.setWindowOpenHandler((details) => {1612 wc.setWindowOpenHandler((details) => {
1610 if (previewWindowOpenAction(details) === "popup") {1613 const action = previewWindowOpenAction(details);
1614 if (action === "popup") {
1611 return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };1615 return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };
1612 }1616 }
1617 if (action === "deny") return { action: "deny" };
1618 if (action === "new-tab") {
1619 runFork(
1620 emitOpenLink({
1621 tabId,
1622 url: details.url,
1623 background: details.disposition === "background-tab",
1624 }),
1625 );
1626 return { action: "deny" };
1627 }
1613 runFork(1628 runFork(
1614 attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>1629 attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
1615 wc.loadURL(details.url),1630 wc.loadURL(details.url),
...@@ -2234,8 +2249,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2234,8 +2249,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2234 const cropRect = normalizeCaptureRect(args[1]);2249 const cropRect = normalizeCaptureRect(args[1]);
2235 const submission =2250 const submission =
2236 args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach";2251 args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach";
2252 const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1;
2237 runFork(2253 runFork(
2238 captureAnnotationScreenshot(tabId, wc, cropRect).pipe(2254 // The full-page capture with retries is what the screenshot button
2255 // uses. A single cropped `capturePage(rect)` with a fixed timeout
2256 // dropped the crop on pages where that capture was merely slow.
2257 capturePageWithRetry(
2258 { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id },
2259 tabId,
2260 wc,
2261 ).pipe(
2262 Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio)),
2263 Effect.tapError((error) =>
2264 Effect.logWarning("preview annotation screenshot failed").pipe(
2265 Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }),
2266 ),
2267 ),
2268 Effect.withSpan("PreviewManager.captureAnnotationScreenshot"),
2239 // The renderer cannot tell a dropped crop from a comment-only2269 // The renderer cannot tell a dropped crop from a comment-only
2240 // pick by the null alone, so a failed or timed-out capture is2270 // pick by the null alone, so a failed or timed-out capture is
2241 // flagged on the result.2271 // flagged on the result.
...@@ -3320,6 +3350,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3320,6 +3350,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3320 );3350 );
3321 });3351 });
33223352
3353 const emitOpenLink = Effect.fn("PreviewManager.emitOpenLink")(function* (
3354 event: DesktopPreviewOpenLinkEvent,
3355 ) {
3356 const listeners = yield* Ref.get(openLinkListenersRef);
3357 yield* Effect.forEach(
3358 listeners,
3359 (listener) => deliverEvent("open-link", event.tabId, () => listener(event)),
3360 { discard: true },
3361 );
3362 });
3323 const subscribe = <A>(3363 const subscribe = <A>(
3324 ref: Ref.Ref<ReadonlySet<A>>,3364 ref: Ref.Ref<ReadonlySet<A>>,
3325 listener: A,3365 listener: A,
...@@ -3341,6 +3381,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3341,6 +3381,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3341 [3381 [
3342 Ref.set(listenersRef, new Set()),3382 Ref.set(listenersRef, new Set()),
3343 Ref.set(pointerEventListenersRef, new Set()),3383 Ref.set(pointerEventListenersRef, new Set()),
3384 Ref.set(openLinkListenersRef, new Set()),
3344 Ref.set(recordingFrameListenersRef, new Set()),3385 Ref.set(recordingFrameListenersRef, new Set()),
3345 Ref.set(recordingInputListenersRef, new Set()),3386 Ref.set(recordingInputListenersRef, new Set()),
3346 ],3387 ],
...@@ -3386,6 +3427,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3386,6 +3427,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3386 stopRecording,3427 stopRecording,
3387 subscribePointerEvents: (listener: PointerEventListener) =>3428 subscribePointerEvents: (listener: PointerEventListener) =>
3388 subscribe(pointerEventListenersRef, listener),3429 subscribe(pointerEventListenersRef, listener),
3430 subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener),
3389 subscribeRecordingInputs: (listener: RecordingInputListener) =>3431 subscribeRecordingInputs: (listener: RecordingInputListener) =>
3390 subscribe(recordingInputListenersRef, listener),3432 subscribe(recordingInputListenersRef, listener),
3391 subscribeRecordingFrames: (listener: RecordingFrameListener) =>3433 subscribeRecordingFrames: (listener: RecordingFrameListener) =>
...@@ -3627,6 +3669,9 @@ export class PreviewManager extends Context.Service<...@@ -3627,6 +3669,9 @@ export class PreviewManager extends Context.Service<
3627 readonly subscribePointerEvents: (3669 readonly subscribePointerEvents: (
3628 listener: PointerEventListener,3670 listener: PointerEventListener,
3629 ) => Effect.Effect<void, never, Scope.Scope>;3671 ) => Effect.Effect<void, never, Scope.Scope>;
3672 readonly subscribeOpenLinks: (
3673 listener: OpenLinkListener,
3674 ) => Effect.Effect<void, never, Scope.Scope>;
3630 readonly subscribeRecordingInputs: (3675 readonly subscribeRecordingInputs: (
3631 listener: RecordingInputListener,3676 listener: RecordingInputListener,
3632 ) => Effect.Effect<void, never, Scope.Scope>;3677 ) => Effect.Effect<void, never, Scope.Scope>;
...@@ -3642,13 +3687,48 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3642,13 +3687,48 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3642 const browserSession = yield* BrowserSession.BrowserSession;3687 const browserSession = yield* BrowserSession.BrowserSession;
3643 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;3688 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
3644 const downloadSessions = new WeakSet<Electron.Session>();3689 const downloadSessions = new WeakSet<Electron.Session>();
3690 const fileSystem = yield* FileSystem.FileSystem;
3691 const path = yield* Path.Path;
3692 const runForkDownload = Effect.runForkWith(yield* Effect.context<never>());
3693 /** Puts a copy in Downloads under a name that never replaces a file already there. */
3694 const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* (
3695 source: string,
3696 fileName: string,
3697 ) {
3698 const directory = app.getPath("downloads");
3699 const extension = path.extname(fileName);
3700 const stem = path.basename(fileName, extension) || "download";
3701 for (let attempt = 0; attempt < 100; attempt += 1) {
3702 const target = path.join(
3703 directory,
3704 attempt === 0 ? `${stem}${extension}` : `${stem} (${attempt})${extension}`,
3705 );
3706 if (yield* fileSystem.exists(target)) continue;
3707 yield* fileSystem.copyFile(source, target);
3708 shell.showItemInFolder(target);
3709 return;
3710 }
3711 });
3645 // Server tabs save downloads where the server's engine reads them. Downloads3712 // Server tabs save downloads where the server's engine reads them. Downloads
3646 // the person starts in a tab the server is not driving keep Electron's dialog.3713 // the person starts in a tab the server is not driving keep Electron's dialog.
3647 const placeServerDownloads = (session: Electron.Session) => {3714 const placeServerDownloads = (session: Electron.Session) => {
3648 if (downloadSessions.has(session)) return;3715 if (downloadSessions.has(session)) return;
3649 downloadSessions.add(session);3716 downloadSessions.add(session);
3650 session.on("will-download", (_event, item, source) => {3717 session.on("will-download", (_event, item, source) => {
3651 browserHost.placeDownload(source, item);3718 // Decided before the server's path is set: placing it consumes the guid.
3719 const human = browserHost.humanStartedDownload(source);
3720 if (!browserHost.placeDownload(source, item) || !human) return;
3721 // The server keeps its copy for agents; the person who clicked gets theirs.
3722 item.once("done", (_doneEvent, state) => {
3723 if (state !== "completed") return;
3724 runForkDownload(
3725 copyToDownloads(item.getSavePath(), item.getFilename()).pipe(
3726 Effect.catch((error) =>
3727 Effect.logWarning("preview download copy failed", { cause: error }),
3728 ),
3729 ),
3730 );
3731 });
3652 });3732 });
3653 };3733 };
36543734
...@@ -3732,6 +3812,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3732,6 +3812,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3732 saveRecording: operations.saveRecording,3812 saveRecording: operations.saveRecording,
3733 subscribeStateChanges: operations.subscribeStateChanges,3813 subscribeStateChanges: operations.subscribeStateChanges,
3734 subscribePointerEvents: operations.subscribePointerEvents,3814 subscribePointerEvents: operations.subscribePointerEvents,
3815 subscribeOpenLinks: operations.subscribeOpenLinks,
3735 subscribeRecordingFrames: operations.subscribeRecordingFrames,3816 subscribeRecordingFrames: operations.subscribeRecordingFrames,
3736 subscribeRecordingInputs: operations.subscribeRecordingInputs,3817 subscribeRecordingInputs: operations.subscribeRecordingInputs,
3737 });3818 });
apps/desktop/src/preview/PickPreload.ts+2
...@@ -1410,6 +1410,8 @@ function startAnnotation(sendEnabled: boolean): void {...@@ -1410,6 +1410,8 @@ function startAnnotation(sendEnabled: boolean): void {
1410 annotation,1410 annotation,
1411 screenshotRect,1411 screenshotRect,
1412 submission === "send" && !sendEnabled ? "attach" : submission,1412 submission === "send" && !sendEnabled ? "attach" : submission,
1413 // Main crops a full-page capture, whose pixels are CSS px × this.
1414 window.devicePixelRatio,
1413 );1415 );
1414 })1416 })
1415 .catch(() => {1417 .catch(() => {
apps/desktop/src/window/DesktopApplicationMenu.test.ts+29-1
...@@ -87,6 +87,8 @@ const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>...@@ -87,6 +87,8 @@ const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>
87 dispatchSnapShotEvent: () => Effect.void,87 dispatchSnapShotEvent: () => Effect.void,
88 zoomMain: (direction) =>88 zoomMain: (direction) =>
89 Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),89 Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
90 runMainContentsCommand: (command) =>
91 Deferred.succeed(selectedAction, `main-${command}`).pipe(Effect.asVoid),
90 syncAppearance: Effect.void,92 syncAppearance: Effect.void,
91 } satisfies DesktopWindow.DesktopWindow["Service"]);93 } satisfies DesktopWindow.DesktopWindow["Service"]);
9294
...@@ -265,7 +267,9 @@ describe("DesktopApplicationMenu", () => {...@@ -265,7 +267,9 @@ describe("DesktopApplicationMenu", () => {
265 }267 }
266268
267 assert.isUndefined(269 assert.isUndefined(
268 viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")),270 viewMenu.submenu.find((item) =>
271 ["zoom", "reload", "devtools"].some((role) => item.role?.toLowerCase().includes(role)),
272 ),
269 );273 );
270274
271 const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In");275 const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In");
...@@ -279,4 +283,28 @@ describe("DesktopApplicationMenu", () => {...@@ -279,4 +283,28 @@ describe("DesktopApplicationMenu", () => {
279 assert.equal(yield* Deferred.await(selectedAction), "zoom-in");283 assert.equal(yield* Deferred.await(selectedAction), "zoom-in");
280 }),284 }),
281 );285 );
286
287 it.effect("reloads the main window even while a browser page has focus", () =>
288 Effect.gen(function* () {
289 const selectedAction = yield* Deferred.make<string>();
290 const applicationMenuTemplate =
291 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
292
293 yield* configureMenu(selectedAction, applicationMenuTemplate);
294
295 const template = yield* Deferred.await(applicationMenuTemplate);
296 const viewMenu = template.find((item) => item.label === "View");
297 if (!Array.isArray(viewMenu?.submenu)) {
298 throw new Error("Expected View menu submenu to be an array.");
299 }
300 const reload = viewMenu.submenu.find((item) => item.label === "Reload");
301 assert.equal(reload?.accelerator, "CmdOrCtrl+R");
302 if (typeof reload?.click !== "function") {
303 throw new Error("Expected Reload menu item to have a click handler.");
304 }
305
306 reload.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
307 assert.equal(yield* Deferred.await(selectedAction), "main-reload");
308 }),
309 );
282});310});
apps/desktop/src/window/DesktopApplicationMenu.ts+26-8
...@@ -57,6 +57,13 @@ const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (...@@ -57,6 +57,13 @@ const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
57 yield* desktopWindow.zoomMain(direction);57 yield* desktopWindow.zoomMain(direction);
58});58});
5959
60const runMainContentsCommand = Effect.fn("desktop.menu.runMainContentsCommand")(function* (
61 command: DesktopWindow.MainWindowContentsCommand,
62): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
63 const desktopWindow = yield* DesktopWindow.DesktopWindow;
64 yield* desktopWindow.runMainContentsCommand(command);
65});
66
60const checkForUpdatesFromMenu = Effect.gen(function* () {67const checkForUpdatesFromMenu = Effect.gen(function* () {
61 const updates = yield* DesktopUpdates.DesktopUpdates;68 const updates = yield* DesktopUpdates.DesktopUpdates;
62 const electronDialog = yield* ElectronDialog.ElectronDialog;69 const electronDialog = yield* ElectronDialog.ElectronDialog;
...@@ -150,6 +157,9 @@ export const make = Effect.gen(function* () {...@@ -150,6 +157,9 @@ export const make = Effect.gen(function* () {
150 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {157 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
151 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));158 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
152 };159 };
160 const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => {
161 runMenuEffect(command, runMainContentsCommand(command));
162 };
153 const template: Electron.MenuItemConstructorOptions[] = [];163 const template: Electron.MenuItemConstructorOptions[] = [];
154164
155 if (environment.platform === "darwin") {165 if (environment.platform === "darwin") {
...@@ -227,16 +237,24 @@ export const make = Effect.gen(function* () {...@@ -227,16 +237,24 @@ export const make = Effect.gen(function* () {
227 {237 {
228 label: "View",238 label: "View",
229 submenu: [239 submenu: [
230 { role: "reload" },
231 { role: "forceReload" },
232 { role: "toggleDevTools" },
233 { type: "separator" },
234 /*240 /*
235 Not the zoom roles: those act on the focused webContents, so with241 Not the reload, DevTools or zoom roles: those act on the focused
236 an embedded preview WebContentsView focused they zoom the guest242 webContents, so with a browser page focused they reload or zoom
237 page and the app UI appears stuck. These always zoom the main243 the guest page and the app UI appears stuck. These always target
238 window (see DesktopWindow.zoomMain).244 the main window (see DesktopWindow.zoomMain).
239 */245 */
246 { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") },
247 {
248 label: "Force Reload",
249 accelerator: "Shift+CmdOrCtrl+R",
250 click: mainContentsClick("forceReload"),
251 },
252 {
253 label: "Toggle Developer Tools",
254 accelerator: environment.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I",
255 click: mainContentsClick("toggleDevTools"),
256 },
257 { type: "separator" },
240 { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },258 { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
241 { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },259 { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
242 {260 {
apps/desktop/src/window/DesktopWindow.test.ts+40-1
...@@ -90,10 +90,12 @@ function makeFakeBrowserWindow() {...@@ -90,10 +90,12 @@ function makeFakeBrowserWindow() {
90 once: vi.fn<(eventName: string, listener: (...args: readonly unknown[]) => void) => void>(),90 once: vi.fn<(eventName: string, listener: (...args: readonly unknown[]) => void) => void>(),
91 openDevTools: vi.fn(),91 openDevTools: vi.fn(),
92 reload: vi.fn(),92 reload: vi.fn(),
93 reloadIgnoringCache: vi.fn(),
93 replaceMisspelling: vi.fn(),94 replaceMisspelling: vi.fn(),
94 send: vi.fn(),95 send: vi.fn(),
95 setBackgroundThrottling: vi.fn(),96 setBackgroundThrottling: vi.fn(),
96 setWindowOpenHandler: vi.fn(),97 setWindowOpenHandler: vi.fn(),
98 toggleDevTools: vi.fn(),
97 };99 };
98100
99 const window = {101 const window = {
...@@ -138,6 +140,8 @@ function makeFakeBrowserWindow() {...@@ -138,6 +140,8 @@ function makeFakeBrowserWindow() {
138 maximize: window.maximize,140 maximize: window.maximize,
139 openDevTools: webContents.openDevTools,141 openDevTools: webContents.openDevTools,
140 reload: webContents.reload,142 reload: webContents.reload,
143 reloadIgnoringCache: webContents.reloadIgnoringCache,
144 toggleDevTools: webContents.toggleDevTools,
141 send: webContents.send,145 send: webContents.send,
142 setZoomLevel: webContents.setZoomLevel,146 setZoomLevel: webContents.setZoomLevel,
143 setWindowButtonPosition: window.setWindowButtonPosition,147 setWindowButtonPosition: window.setWindowButtonPosition,
...@@ -227,6 +231,7 @@ function layerTest(input: {...@@ -227,6 +231,7 @@ function layerTest(input: {
227 readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect<void>;231 readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect<void>;
228 readonly previewZoomReapplies?: number[];232 readonly previewZoomReapplies?: number[];
229 readonly onReveal?: (window: Electron.BrowserWindow) => void;233 readonly onReveal?: (window: Electron.BrowserWindow) => void;
234 readonly focusedWindow?: Electron.BrowserWindow;
230}) {235}) {
231 let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;236 let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
232 const layerDesktopAppSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {237 const layerDesktopAppSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
...@@ -273,7 +278,10 @@ function layerTest(input: {...@@ -273,7 +278,10 @@ function layerTest(input: {
273 ),278 ),
274 main: Ref.get(input.mainWindow),279 main: Ref.get(input.mainWindow),
275 currentMainOrFirst: Ref.get(input.mainWindow),280 currentMainOrFirst: Ref.get(input.mainWindow),
276 focusedMainOrFirst: Ref.get(input.mainWindow),281 focusedMainOrFirst:
282 input.focusedWindow === undefined
283 ? Ref.get(input.mainWindow)
284 : Effect.succeedSome(input.focusedWindow),
277 setMain: (window) => Ref.set(input.mainWindow, Option.some(window)),285 setMain: (window) => Ref.set(input.mainWindow, Option.some(window)),
278 clearMain: () => Ref.set(input.mainWindow, Option.none()),286 clearMain: () => Ref.set(input.mainWindow, Option.none()),
279 prepareReveal: () => Effect.succeed(false),287 prepareReveal: () => Effect.succeed(false),
...@@ -782,6 +790,37 @@ describe("DesktopWindow", () => {...@@ -782,6 +790,37 @@ describe("DesktopWindow", () => {
782 }),790 }),
783 );791 );
784792
793 it.effect("reloads and inspects the main window even when a popup has focus", () =>
794 Effect.gen(function* () {
795 const fakeWindow = makeFakeBrowserWindow();
796 const popup = makeFakeBrowserWindow();
797 const createCount = yield* Ref.make(0);
798 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
799 const layer = layerTest({
800 window: fakeWindow.window,
801 createCount,
802 mainWindow,
803 focusedWindow: popup.window,
804 });
805
806 yield* Effect.gen(function* () {
807 const desktopWindow = yield* DesktopWindow.DesktopWindow;
808 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
809
810 yield* desktopWindow.runMainContentsCommand("reload");
811 yield* desktopWindow.runMainContentsCommand("forceReload");
812 yield* desktopWindow.runMainContentsCommand("toggleDevTools");
813
814 assert.equal(fakeWindow.reload.mock.calls.length, 1);
815 assert.equal(fakeWindow.reloadIgnoringCache.mock.calls.length, 1);
816 assert.equal(fakeWindow.toggleDevTools.mock.calls.length, 1);
817 assert.equal(popup.reload.mock.calls.length, 0);
818 assert.equal(popup.reloadIgnoringCache.mock.calls.length, 0);
819 assert.equal(popup.toggleDevTools.mock.calls.length, 0);
820 }).pipe(Effect.provide(layer));
821 }),
822 );
823
785 it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () =>824 it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () =>
786 Effect.gen(function* () {825 Effect.gen(function* () {
787 const fakeWindow = makeFakeBrowserWindow();826 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+21
...@@ -93,6 +93,7 @@ export type DesktopWindowError =...@@ -93,6 +93,7 @@ export type DesktopWindowError =
93 | PreviewManager.PreviewManagerError;93 | PreviewManager.PreviewManagerError;
9494
95export type MainWindowZoomDirection = "in" | "out" | "reset";95export type MainWindowZoomDirection = "in" | "out" | "reset";
96export type MainWindowContentsCommand = "reload" | "forceReload" | "toggleDevTools";
9697
97export class DesktopWindow extends Context.Service<98export class DesktopWindow extends Context.Service<
98 DesktopWindow,99 DesktopWindow,
...@@ -137,6 +138,10 @@ export class DesktopWindow extends Context.Service<...@@ -137,6 +138,10 @@ export class DesktopWindow extends Context.Service<
137 // guest page instead of the app UI. The menu routes here to always target138 // guest page instead of the app UI. The menu routes here to always target
138 // the main window.139 // the main window.
139 readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect<void>;140 readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect<void>;
141 // Reload and DevTools for the main window's own webContents, for the same
142 // reason as zoomMain: the Electron roles act on the focused webContents,
143 // which is a preview guest whenever a browser page has focus.
144 readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect<void>;
140 readonly syncAppearance: Effect.Effect<void>;145 readonly syncAppearance: Effect.Effect<void>;
141 }146 }
142>()("@t3tools/desktop/window/DesktopWindow") {}147>()("@t3tools/desktop/window/DesktopWindow") {}
...@@ -420,6 +425,10 @@ export const make = Effect.gen(function* () {...@@ -420,6 +425,10 @@ export const make = Effect.gen(function* () {
420 nodeIntegration: false,425 nodeIntegration: false,
421 sandbox: true,426 sandbox: true,
422 webviewTag: true,427 webviewTag: true,
428 // A preview guest's fullscreen request is mirrored onto this embedder,
429 // which would otherwise put the whole window into OS fullscreen. With
430 // both sides opted out the page fills its webview and the window stays.
431 disableHtmlFullscreenWindowResize: true,
423 },432 },
424 });433 });
425434
...@@ -527,6 +536,7 @@ export const make = Effect.gen(function* () {...@@ -527,6 +536,7 @@ export const make = Effect.gen(function* () {
527 webPreferences.nodeIntegration = false;536 webPreferences.nodeIntegration = false;
528 webPreferences.nodeIntegrationInSubFrames = false;537 webPreferences.nodeIntegrationInSubFrames = false;
529 webPreferences.contextIsolation = false;538 webPreferences.contextIsolation = false;
539 webPreferences.disableHtmlFullscreenWindowResize = true;
530 });540 });
531541
532 const contextMenuContents = new WeakSet<Electron.WebContents>();542 const contextMenuContents = new WeakSet<Electron.WebContents>();
...@@ -1027,6 +1037,17 @@ export const make = Effect.gen(function* () {...@@ -1027,6 +1037,17 @@ export const make = Effect.gen(function* () {
1027 // own zoom, so put each guest back where the preview left it.1037 // own zoom, so put each guest back where the preview left it.
1028 yield* previewManager.reapplyZoom();1038 yield* previewManager.reapplyZoom();
1029 }),1039 }),
1040 runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) {
1041 yield* Effect.annotateCurrentSpan({ command });
1042 // The registered main window, never the focused one: with an OAuth popup
1043 // focused, Reload would otherwise reload the popup mid sign-in.
1044 const window = yield* electronWindow.main;
1045 if (Option.isNone(window) || window.value.isDestroyed()) return;
1046 const webContents = window.value.webContents;
1047 if (command === "reload") webContents.reload();
1048 else if (command === "forceReload") webContents.reloadIgnoringCache();
1049 else webContents.toggleDevTools();
1050 }),
1030 syncAppearance: Effect.gen(function* () {1051 syncAppearance: Effect.gen(function* () {
1031 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;1052 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
1032 yield* electronWindow.syncAllAppearance((window) =>1053 yield* electronWindow.syncAllAppearance((window) =>
apps/web/src/browser/BrowserSurfaceSlot.tsx+6
...@@ -68,6 +68,12 @@ export function BrowserSurfaceSlot(props: {...@@ -68,6 +68,12 @@ export function BrowserSurfaceSlot(props: {
68 // full width. The slot moves without resizing, so measure on shell resizes too.68 // full width. The slot moves without resizing, so measure on shell resizes too.
69 const panel = element.closest('[data-preview-panel-mode="inline"]');69 const panel = element.closest('[data-preview-panel-mode="inline"]');
70 if (panel) observer.observe(panel);70 if (panel) observer.observe(panel);
71 // A sidebar opening or closing shifts the whole column, so a slot that keeps
72 // its size and its offset inside the column (the mini player on the left)
73 // still moves on screen. The gap element carries the sidebar's width.
74 for (const gap of document.querySelectorAll('[data-slot="sidebar-gap"]')) {
75 observer.observe(gap);
76 }
71 window.addEventListener("resize", update);77 window.addEventListener("resize", update);
72 window.addEventListener("scroll", update, true);78 window.addEventListener("scroll", update, true);
73 return () => {79 return () => {
apps/web/src/browser/ElectronBrowserHost.tsx+30-1
...@@ -3,18 +3,21 @@...@@ -3,18 +3,21 @@
3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";3import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
4import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";4import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";5import { useAtomValue } from "@effect/atom-react";
6import { type ComponentProps, useEffect, useMemo } from "react";6import { type ComponentProps, useEffect, useMemo, useRef } from "react";
77
8import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";8import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
99
10import { isElectron } from "~/env";10import { isElectron } from "~/env";
11import { useTheme } from "~/hooks/useTheme";11import { useTheme } from "~/hooks/useTheme";
12import { useActivePreviewSessions } from "~/previewStateStore";12import { useActivePreviewSessions } from "~/previewStateStore";
13import { previewEnvironment } from "~/state/preview";
13import { useEnvironmentScope } from "~/state/session";14import { useEnvironmentScope } from "~/state/session";
15import { useAtomCommand } from "~/state/use-atom-command";
1416
15import { readPreviewAnnotationTheme } from "./annotationTheme";17import { readPreviewAnnotationTheme } from "./annotationTheme";
16import { useBrowserPointerStore } from "./browserPointerStore";18import { useBrowserPointerStore } from "./browserPointerStore";
17import { HostedBrowserWebview } from "./HostedBrowserWebview";19import { HostedBrowserWebview } from "./HostedBrowserWebview";
20import { openUrlInPreview } from "./openFileInPreview";
18import { rendersServerTabNatively } from "./previewRuntime";21import { rendersServerTabNatively } from "./previewRuntime";
19import { previewRuntimeTabId } from "./previewRuntimeTabId";22import { previewRuntimeTabId } from "./previewRuntimeTabId";
2023
...@@ -92,6 +95,32 @@ export function ElectronBrowserHost() {...@@ -92,6 +95,32 @@ export function ElectronBrowserHost() {
92 });95 });
93 }, []);96 }, []);
9497
98 // A `target="_blank"` link inside a hosted page opens as another tab of the
99 // same thread, so the page that held the link stays where it is.
100 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: true });
101 const sessionByRuntimeTabId = useRef(new Map<string, (typeof sessions)[number]>());
102 useEffect(() => {
103 sessionByRuntimeTabId.current = new Map(
104 sessions.map((session) => [session.runtimeTabId, session]),
105 );
106 }, [sessions]);
107 useEffect(() => {
108 const preview = window.desktopBridge?.preview;
109 if (!preview) return;
110 return preview.onOpenLink(({ tabId, url, background }) => {
111 const source = sessionByRuntimeTabId.current.get(tabId);
112 if (!source) return;
113 // The new tab keeps the source tab's profile so its cookies carry over.
114 void openUrlInPreview({
115 threadRef: source.threadRef,
116 url,
117 openPreview,
118 profileId: source.snapshot.profileId,
119 background,
120 });
121 });
122 }, [openPreview]);
123
95 if (!isElectron) return null;124 if (!isElectron) return null;
96 return (125 return (
97 <div className="contents" data-electron-browser-host>126 <div className="contents" data-electron-browser-host>
apps/web/src/browser/browserTargetResolver.test.ts+29
...@@ -2,8 +2,10 @@ import { EnvironmentId } from "@t3tools/contracts";...@@ -2,8 +2,10 @@ import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
4const readPreparedConnection = vi.fn();4const readPreparedConnection = vi.fn();
5const env = vi.hoisted(() => ({ isElectron: false }));
56
6vi.mock("~/state/session", () => ({ readPreparedConnection }));7vi.mock("~/state/session", () => ({ readPreparedConnection }));
8vi.mock("~/env", () => env);
79
8describe("browser target resolver", () => {10describe("browser target resolver", () => {
9 beforeEach(() => readPreparedConnection.mockReset());11 beforeEach(() => readPreparedConnection.mockReset());
...@@ -160,6 +162,33 @@ describe("browser target resolver", () => {...@@ -160,6 +162,33 @@ describe("browser target resolver", () => {
160 ).toBe("http://192.168.1.25:3000/app");162 ).toBe("http://192.168.1.25:3000/app");
161 });163 });
162164
165 it("keeps localhost for servers on the desktop's own WSL or primary backend", async () => {
166 // WSL NAT mode serves the backend on the distro IP, but a loopback-only dev
167 // server there answers only on localhost from the Windows desktop.
168 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
169 env.isElectron = true;
170 try {
171 for (const target of [
172 { _tag: "BearerConnectionTarget", connectionId: "local:wsl:Ubuntu" },
173 { _tag: "PrimaryConnectionTarget" },
174 ]) {
175 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://172.24.66.27:3773", target });
176 expect(
177 resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3001/"),
178 ).toBe("http://localhost:3001/");
179 }
180 readPreparedConnection.mockReturnValue({
181 httpBaseUrl: "http://192.168.1.25:3773",
182 target: { _tag: "BearerConnectionTarget", connectionId: "saved:remote" },
183 });
184 expect(
185 resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3001/"),
186 ).toBe("http://192.168.1.25:3001/");
187 } finally {
188 env.isElectron = false;
189 }
190 });
191
163 it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => {192 it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => {
164 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });193 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });
165 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");194 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
apps/web/src/browser/browserTargetResolver.ts+17
...@@ -6,6 +6,10 @@ import type {...@@ -6,6 +6,10 @@ import type {
6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
7import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification";7import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification";
88
9import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
10
11import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
12import { isElectron } from "~/env";
9import { readPreparedConnection } from "~/state/session";13import { readPreparedConnection } from "~/state/session";
1014
11export {15export {
...@@ -75,11 +79,24 @@ export function resolveBrowserNavigationTarget(...@@ -75,11 +79,24 @@ export function resolveBrowserNavigationTarget(
75 return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId));79 return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId));
76}80}
7781
82/**
83 * Whether a discovered loopback server is reachable as-is from this client's
84 * own browser tabs. The desktop app draws tabs on the machine that runs its
85 * primary backend and any desktop-local backend (WSL). Its connection URL can
86 * still be a LAN address (WSL NAT mode binds the distro IP), but a dev server
87 * bound to loopback only answers on `localhost` there.
88 */
89const reachesEnvironmentLoopback = (target: ConnectionTarget): boolean =>
90 isElectron &&
91 (target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target));
92
78export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {93export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
79 try {94 try {
80 const normalizedUrl = normalizePreviewUrl(rawUrl);95 const normalizedUrl = normalizePreviewUrl(rawUrl);
81 const parsed = new URL(normalizedUrl);96 const parsed = new URL(normalizedUrl);
82 if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;97 if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;
98 const target = readPreparedConnection(environmentId)?.target;
99 if (target && reachesEnvironmentLoopback(target)) return normalizedUrl;
83 return resolveEnvironmentPortTarget(100 return resolveEnvironmentPortTarget(
84 environmentId,101 environmentId,
85 {102 {
apps/web/src/browser/browserViewportLayout.test.ts+5-5
...@@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => {...@@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => {
123 expect(123 expect(
124 resolveBrowserDeviceViewportLayout(124 resolveBrowserDeviceViewportLayout(
125 { width: 1200, height: 900 },125 { width: 1200, height: 900 },
126 { _tag: "freeform", width: 1180, height: 858 },126 { _tag: "freeform", width: 1180, height: 852 },
127 ),127 ),
128 ).toEqual({128 ).toEqual({
129 canvasWidth: 1200,129 canvasWidth: 1200,
130 canvasHeight: 900,130 canvasHeight: 900,
131 viewportX: 10,131 viewportX: 10,
132 viewportY: 32,132 viewportY: 38,
133 viewportWidth: 1180,133 viewportWidth: 1180,
134 viewportHeight: 858,134 viewportHeight: 852,
135 viewportScale: 1,135 viewportScale: 1,
136 fillsPanel: false,136 fillsPanel: false,
137 });137 });
...@@ -140,11 +140,11 @@ describe("resolveBrowserViewportLayout", () => {...@@ -140,11 +140,11 @@ describe("resolveBrowserViewportLayout", () => {
140 it("captures the available framed area when responsive mode is enabled", () => {140 it("captures the available framed area when responsive mode is enabled", () => {
141 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({141 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({
142 width: 1180,142 width: 1180,
143 height: 858,143 height: 852,
144 });144 });
145 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({145 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({
146 width: 590,146 width: 590,
147 height: 429,147 height: 426,
148 });148 });
149 });149 });
150150
apps/web/src/browser/browserViewportLayout.ts+4-1
...@@ -19,7 +19,10 @@ export interface BrowserViewportLayout {...@@ -19,7 +19,10 @@ export interface BrowserViewportLayout {
19 readonly fillsPanel: boolean;19 readonly fillsPanel: boolean;
20}20}
2121
22export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32;22// Room for a compact size input (30px with its border) plus its 3px focus ring
23// on both sides, inside the toolbar's 1px bottom border. The bar scrolls
24// horizontally, which also clips anything that overflows vertically.
25export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38;
23export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10;26export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10;
2427
25export type BrowserViewportResizeDirection =28export type BrowserViewportResizeDirection =
apps/web/src/browser/openFileInPreview.ts+34-4
...@@ -17,8 +17,14 @@ import { AsyncResult } from "effect/reactivity";...@@ -17,8 +17,14 @@ import { AsyncResult } from "effect/reactivity";
1717
18import { resolveAssetUrl } from "~/assets/assetUrls";18import { resolveAssetUrl } from "~/assets/assetUrls";
19import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";19import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";
20import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";20import {
21import { useRightPanelStore } from "~/rightPanelStore";21 applyPreviewServerSnapshot,
22 readThreadPreviewState,
23 rememberPreviewUrl,
24 setActivePreviewTab,
25 updatePreviewServerSnapshot,
26} from "~/previewStateStore";
27import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore";
2228
23import {29import {
24 browserDefaultOpenProfileId,30 browserDefaultOpenProfileId,
...@@ -52,6 +58,10 @@ export async function openUrlInPreview<E>(input: {...@@ -52,6 +58,10 @@ export async function openUrlInPreview<E>(input: {
52 readonly threadRef: ScopedThreadRef;58 readonly threadRef: ScopedThreadRef;
53 readonly url: string;59 readonly url: string;
54 readonly openPreview: OpenPreviewMutation<E>;60 readonly openPreview: OpenPreviewMutation<E>;
61 /** Profile to open under; omit for the configured default. */
62 readonly profileId?: PreviewOpenInput["profileId"];
63 /** Open the tab without switching the thread to it. */
64 readonly background?: boolean;
55}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {65}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
56 const defaults = await resolveBrowserDefaults().catch(66 const defaults = await resolveBrowserDefaults().catch(
57 (cause: unknown) => new BrowserSettingsReadError({ cause }),67 (cause: unknown) => new BrowserSettingsReadError({ cause }),
...@@ -60,6 +70,13 @@ export async function openUrlInPreview<E>(input: {...@@ -60,6 +70,13 @@ export async function openUrlInPreview<E>(input: {
60 return AsyncResult.failure(Cause.fail(defaults));70 return AsyncResult.failure(Cause.fail(defaults));
61 }71 }
62 const runtime = previewRuntimeFor(input.threadRef.environmentId);72 const runtime = previewRuntimeFor(input.threadRef.environmentId);
73 const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId;
74 // The server's "opened" event switches the preview tab but not the panel's
75 // selection, so a changed selection means the user picked a tab themselves.
76 const selectedSurface = () =>
77 selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, input.threadRef)
78 ?.id ?? null;
79 const surfaceBeforeOpen = selectedSurface();
63 const result = await input.openPreview({80 const result = await input.openPreview({
64 environmentId: input.threadRef.environmentId,81 environmentId: input.threadRef.environmentId,
65 input: {82 input: {
...@@ -69,13 +86,26 @@ export async function openUrlInPreview<E>(input: {...@@ -69,13 +86,26 @@ export async function openUrlInPreview<E>(input: {
69 // maps the result differently, so the configured defaults have to be86 // maps the result differently, so the configured defaults have to be
70 // applied explicitly or file/link opens would ignore them.87 // applied explicitly or file/link opens would ignore them.
71 viewport: browserDefaultOpenViewport(defaults),88 viewport: browserDefaultOpenViewport(defaults),
72 profileId: browserDefaultOpenProfileId(defaults),89 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
73 ...(runtime === undefined ? {} : { runtime }),90 ...(runtime === undefined ? {} : { runtime }),
74 },91 },
75 });92 });
76 return mapAtomCommandResult(result, (snapshot) => {93 return mapAtomCommandResult(result, (snapshot) => {
77 applyPreviewServerSnapshot(input.threadRef, snapshot);
78 rememberPreviewUrl(input.threadRef, input.url);94 rememberPreviewUrl(input.threadRef, input.url);
95 if (input.background) {
96 updatePreviewServerSnapshot(input.threadRef, snapshot);
97 // The server's "opened" event activates the new tab; hand focus back,
98 // unless the user picked a tab, this one included, while the open was in flight.
99 if (
100 previousActiveTabId &&
101 readThreadPreviewState(input.threadRef).activeTabId === snapshot.tabId &&
102 selectedSurface() === surfaceBeforeOpen
103 ) {
104 setActivePreviewTab(input.threadRef, previousActiveTabId);
105 }
106 return;
107 }
108 applyPreviewServerSnapshot(input.threadRef, snapshot);
79 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);109 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
80 });110 });
81}111}
apps/web/src/components/AppSidebarLayout.tsx+8-1
...@@ -124,7 +124,14 @@ function SidebarControl() {...@@ -124,7 +124,14 @@ function SidebarControl() {
124124
125 // Capture before focused editors consume commands such as Mod+B for rich-text formatting.125 // Capture before focused editors consume commands such as Mod+B for rich-text formatting.
126 window.addEventListener("keydown", onKeyDown, true);126 window.addEventListener("keydown", onKeyDown, true);
127 return () => window.removeEventListener("keydown", onKeyDown, true);127 // A focused desktop browser page forwards the chord as a menu action.
128 const unsubscribe = window.desktopBridge?.onMenuAction((action) => {
129 if (action === "sidebar.toggle") toggleSidebar();
130 });
131 return () => {
132 window.removeEventListener("keydown", onKeyDown, true);
133 unsubscribe?.();
134 };
128 }, [keybindings, toggleSidebar, usagePageOpen]);135 }, [keybindings, toggleSidebar, usagePageOpen]);
129136
130 return (137 return (
apps/web/src/components/ChatView.tsx+16
...@@ -8076,6 +8076,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -8076,6 +8076,13 @@ export default function ChatView(props: ChatViewProps) {
8076 return;8076 return;
8077 }8077 }
80788078
8079 if (command === "rightPanel.toggleMaximized") {
8080 event.preventDefault();
8081 event.stopPropagation();
8082 if (!event.repeat) toggleRightPanelMaximized();
8083 return;
8084 }
8085
8079 if (command === "threadPanel.toggle") {8086 if (command === "threadPanel.toggle") {
8080 event.preventDefault();8087 event.preventDefault();
8081 event.stopPropagation();8088 event.stopPropagation();
...@@ -8313,6 +8320,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8313,6 +8320,7 @@ export default function ChatView(props: ChatViewProps) {
8313 closeThreadFind,8320 closeThreadFind,
8314 isThreadFindActive,8321 isThreadFindActive,
8315 toggleRightPanel,8322 toggleRightPanel,
8323 toggleRightPanelMaximized,
8316 toggleThreadPanel,8324 toggleThreadPanel,
8317 toggleTerminalVisibility,8325 toggleTerminalVisibility,
8318 composerRef,8326 composerRef,
...@@ -8324,6 +8332,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -8324,6 +8332,14 @@ export default function ChatView(props: ChatViewProps) {
8324 onEnvironmentChange,8332 onEnvironmentChange,
8325 ]);8333 ]);
83268334
8335 // A focused desktop browser page forwards these chords as menu actions.
8336 useEffect(() => {
8337 return window.desktopBridge?.onMenuAction((action) => {
8338 if (action === "rightPanel.toggle") toggleRightPanel();
8339 else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximized();
8340 });
8341 }, [toggleRightPanel, toggleRightPanelMaximized]);
8342
8327 // Paste-to-focus: the resting composer blurs on a click into the timeline,8343 // Paste-to-focus: the resting composer blurs on a click into the timeline,
8328 // so a paste that follows has no editable target and would be dropped.8344 // so a paste that follows has no editable target and would be dropped.
8329 // Route it to the composer like a typed key, which also expands it.8345 // Route it to the composer like a typed key, which also expands it.
apps/web/src/components/ReopenClosedViewShortcut.test.tsx+14-4
...@@ -309,17 +309,27 @@ describe("root reopen shortcut", () => {...@@ -309,17 +309,27 @@ describe("root reopen shortcut", () => {
309 );309 );
310310
311 it("forwards the chord to a focused desktop browser only while history exists", async () => {311 it("forwards the chord to a focused desktop browser only while history exists", async () => {
312 const forwardedCommands = () =>
313 (state.setShortcuts.mock.lastCall as unknown as [Array<{ command: string }>])[0].map(
314 ({ command }) => command,
315 );
312 useClosedViewStore316 useClosedViewStore
313 .getState()317 .getState()
314 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });318 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
315 await render();319 await render();
316 expect(state.setShortcuts).toHaveBeenLastCalledWith([320 expect(forwardedCommands()).toContain("view.reopenClosed");
317 expect.objectContaining({ command: "view.reopenClosed" }),
318 ]);
319 await act(() => {321 await act(() => {
320 menuAction?.("view.reopenClosed");322 menuAction?.("view.reopenClosed");
321 });323 });
322 expect(useClosedViewStore.getState().entries).toEqual([]);324 expect(useClosedViewStore.getState().entries).toEqual([]);
323 expect(state.setShortcuts).toHaveBeenLastCalledWith([]);325 expect(forwardedCommands()).not.toContain("view.reopenClosed");
326 });
327
328 it("always forwards the layout toggles out of a focused desktop browser", async () => {
329 await render();
330 expect(state.setShortcuts).toHaveBeenLastCalledWith([
331 expect.objectContaining({ command: "sidebar.toggle" }),
332 expect.objectContaining({ command: "rightPanel.toggle" }),
333 ]);
324 });334 });
325});335});
apps/web/src/components/ReopenClosedViewShortcut.tsx+32-14
...@@ -43,6 +43,13 @@ import {...@@ -43,6 +43,13 @@ import {
43} from "../threadRoutes";43} from "../threadRoutes";
44import { toastManager } from "./ui/toast";44import { toastManager } from "./ui/toast";
4545
46/** Layout commands a focused desktop browser page hands back to the app. */
47const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [
48 "sidebar.toggle",
49 "rightPanel.toggle",
50 "rightPanel.toggleMaximized",
51] as const;
52
46const isGlobalPullRequests = (ref: ScopedThreadRef) =>53const isGlobalPullRequests = (ref: ScopedThreadRef) =>
47 scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF);54 scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF);
4855
...@@ -175,22 +182,33 @@ export function ReopenClosedViewShortcut() {...@@ -175,22 +182,33 @@ export function ReopenClosedViewShortcut() {
175 useEffect(() => {182 useEffect(() => {
176 const preview = window.desktopBridge?.preview;183 const preview = window.desktopBridge?.preview;
177 if (!preview?.setForwardedShortcuts) return;184 if (!preview?.setForwardedShortcuts) return;
185 const options = {
186 context: {
187 previewFocus: true,
188 previewOpen: true,
189 terminalFocus: false,
190 terminalOpen,
191 editableFocus: false,
192 modelPickerOpen: false,
193 isDesktop: true,
194 isWeb: false,
195 },
196 };
197 // A focused browser page is a separate process, so these chords never reach
198 // the app's keydown listeners. The desktop claims them in the guest and
199 // sends each command back as a menu action.
200 const commands = [
201 ...(hasHistory ? (["view.reopenClosed"] as const) : []),
202 ...PREVIEW_FORWARDED_LAYOUT_COMMANDS,
203 ];
178 void preview204 void preview
179 .setForwardedShortcuts(205 .setForwardedShortcuts(
180 hasHistory206 commands.flatMap((command) =>
181 ? effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {207 effectiveShortcutsForCommand(keybindings, command, options).map((shortcut) => ({
182 context: {208 command,
183 previewFocus: true,209 shortcut,
184 previewOpen: true,210 })),
185 terminalFocus: false,211 ),
186 terminalOpen,
187 editableFocus: false,
188 modelPickerOpen: false,
189 isDesktop: true,
190 isWeb: false,
191 },
192 }).map((shortcut) => ({ command: "view.reopenClosed", shortcut }))
193 : [],
194 )212 )
195 .catch(() => undefined);213 .catch(() => undefined);
196 return () => {214 return () => {
apps/web/src/components/composerContextPresentation.test.ts+38
...@@ -8,6 +8,44 @@ import {...@@ -8,6 +8,44 @@ import {
8} from "./composerContextPresentation";8} from "./composerContextPresentation";
99
10describe("composerContextRecordsFromDraft", () => {10describe("composerContextRecordsFromDraft", () => {
11 it("pairs a picked element with its crop, which the draft keeps as an image", () => {
12 const annotation = {
13 id: "pick-1",
14 pageUrl: "http://localhost:3000/",
15 pageTitle: "Example",
16 comment: "",
17 elements: [],
18 regions: [],
19 strokes: [],
20 styleChanges: [],
21 // The draft stores annotations without their data URL.
22 screenshot: {
23 dataUrl: "",
24 width: 10,
25 height: 10,
26 cropRect: { x: 0, y: 0, width: 10, height: 10 },
27 },
28 createdAt: "2026-10-08T00:00:00.000Z",
29 };
30 const crop = {
31 type: "image" as const,
32 id: "pick-1",
33 name: "preview-annotation-pick-1.png",
34 mimeType: "image/png",
35 sizeBytes: 1,
36 previewUrl: "blob:crop",
37 file: new File(["x"], "preview-annotation-pick-1.png"),
38 };
39
40 expect(
41 composerContextRecordsFromDraft({
42 terminalContexts: [],
43 previewAnnotations: [annotation],
44 images: [crop],
45 }).get("preview-annotation_pick-1"),
46 ).toMatchObject({ kind: "preview-annotation", screenshot: { previewUrl: "blob:crop" } });
47 });
48
11 it("recovers the uploaded record when clipboard data points at an attachment already in the draft", () => {49 it("recovers the uploaded record when clipboard data points at an attachment already in the draft", () => {
12 const file = {50 const file = {
13 type: "file" as const,51 type: "file" as const,
apps/web/src/components/composerContextPresentation.tsx+24-5
...@@ -55,7 +55,12 @@ import {...@@ -55,7 +55,12 @@ import {
55export type ComposerDraftContextRecord =55export type ComposerDraftContextRecord =
56 | { kind: "terminal"; record: TerminalContextDraft }56 | { kind: "terminal"; record: TerminalContextDraft }
57 | { kind: "review-comment"; record: ReviewCommentContext }57 | { kind: "review-comment"; record: ReviewCommentContext }
58 | { kind: "preview-annotation"; record: PreviewAnnotationPayload }58 | {
59 kind: "preview-annotation";
60 record: PreviewAnnotationPayload;
61 /** The crop, which the draft stores as an image sharing the annotation's id. */
62 screenshot?: ComposerImageAttachment | undefined;
63 }
59 | { kind: "image"; record: ComposerImageAttachment; upload?: AttachmentUploadState | undefined }64 | { kind: "image"; record: ComposerImageAttachment; upload?: AttachmentUploadState | undefined }
60 | { kind: "file"; record: ComposerFileAttachment; upload?: AttachmentUploadState | undefined }65 | { kind: "file"; record: ComposerFileAttachment; upload?: AttachmentUploadState | undefined }
61 | { kind: "thread"; record: ThreadContextRecord };66 | { kind: "thread"; record: ThreadContextRecord };
...@@ -122,8 +127,13 @@ export function composerContextRecordsFromDraft(input: {...@@ -122,8 +127,13 @@ export function composerContextRecordsFromDraft(input: {
122 for (const record of input.reviewComments ?? []) {127 for (const record of input.reviewComments ?? []) {
123 records.set(reviewCommentContextId(record.id), { kind: "review-comment", record });128 records.set(reviewCommentContextId(record.id), { kind: "review-comment", record });
124 }129 }
130 const imagesById = new Map((input.images ?? []).map((image) => [image.id, image]));
125 for (const record of input.previewAnnotations ?? []) {131 for (const record of input.previewAnnotations ?? []) {
126 records.set(previewAnnotationContextId(record.id), { kind: "preview-annotation", record });132 records.set(previewAnnotationContextId(record.id), {
133 kind: "preview-annotation",
134 record,
135 screenshot: imagesById.get(record.id),
136 });
127 }137 }
128 for (const record of input.threadContexts ?? []) {138 for (const record of input.threadContexts ?? []) {
129 records.set(record.contextId, { kind: "thread", record });139 records.set(record.contextId, { kind: "thread", record });
...@@ -291,15 +301,19 @@ function ComposerReviewCommentDetails({ comment }: { comment: ReviewCommentConte...@@ -291,15 +301,19 @@ function ComposerReviewCommentDetails({ comment }: { comment: ReviewCommentConte
291301
292function ComposerPreviewAnnotationDetails({302function ComposerPreviewAnnotationDetails({
293 annotation,303 annotation,
304 screenshot,
294}: {305}: {
295 annotation: PreviewAnnotationPayload;306 annotation: PreviewAnnotationPayload;
307 screenshot: ComposerImageAttachment | undefined;
296}) {308}) {
297 const summary = previewAnnotationTooltip(annotation);309 const summary = previewAnnotationTooltip(annotation);
310 // The draft drops the annotation's own data URL and keeps the crop as an image.
311 const screenshotUrl = annotation.screenshot?.dataUrl || screenshot?.previewUrl;
298 return (312 return (
299 <div className="overflow-hidden rounded-lg border border-border/70 bg-background/70">313 <div className="overflow-hidden rounded-lg border border-border/70 bg-background/70">
300 {annotation.screenshot?.dataUrl ? (314 {screenshotUrl ? (
301 <img315 <img
302 src={annotation.screenshot.dataUrl}316 src={screenshotUrl}
303 alt="Annotated preview crop"317 alt="Annotated preview crop"
304 className="max-h-64 w-full border-border/70 border-b bg-muted object-contain"318 className="max-h-64 w-full border-border/70 border-b bg-muted object-contain"
305 />319 />
...@@ -406,7 +420,12 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<...@@ -406,7 +420,12 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
406 icon={<MousePointerClickIcon />}420 icon={<MousePointerClickIcon />}
407 label={previewAnnotationContextLabel(entry.record)}421 label={previewAnnotationContextLabel(entry.record)}
408 kindLabel="Preview annotation"422 kindLabel="Preview annotation"
409 details={<ComposerPreviewAnnotationDetails annotation={entry.record} />}423 details={
424 <ComposerPreviewAnnotationDetails
425 annotation={entry.record}
426 screenshot={entry.screenshot}
427 />
428 }
410 detailsMode={definition.capabilities.details}429 detailsMode={definition.capabilities.details}
411 kind="preview-annotation"430 kind="preview-annotation"
412 />431 />
apps/web/src/components/preview/openPreviewSession.test.ts+87-1
...@@ -13,7 +13,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"...@@ -13,7 +13,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
13import * as browserDefaults from "~/browser/browserDefaults";13import * as browserDefaults from "~/browser/browserDefaults";
14import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview";14import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview";
15import { __setClientSettingsForTests } from "~/hooks/useSettings";15import { __setClientSettingsForTests } from "~/hooks/useSettings";
16import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore";16import { useRightPanelStore } from "~/rightPanelStore";
17import {
18 applyPreviewServerSnapshot,
19 readThreadPreviewState,
20 resetPreviewStateForTests,
21 setActivePreviewTab,
22} from "~/previewStateStore";
1723
18import { openPreviewSession } from "./openPreviewSession";24import { openPreviewSession } from "./openPreviewSession";
1925
...@@ -37,6 +43,7 @@ const snapshot: PreviewSessionSnapshot = {...@@ -37,6 +43,7 @@ const snapshot: PreviewSessionSnapshot = {
3743
38beforeEach(() => {44beforeEach(() => {
39 resetPreviewStateForTests();45 resetPreviewStateForTests();
46 useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} });
40 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);47 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
41});48});
4249
...@@ -140,3 +147,82 @@ describe("openPreviewSession", () => {...@@ -140,3 +147,82 @@ describe("openPreviewSession", () => {
140 },147 },
141 );148 );
142});149});
150
151describe("openUrlInPreview from a link", () => {
152 it("opens under the source tab's profile instead of the default", async () => {
153 const openPreview = vi.fn(async (_arg: { input: PreviewOpenInput }) =>
154 AsyncResult.success(snapshot),
155 );
156
157 await openUrlInPreview({ openPreview, threadRef, url: "https://t3.chat/", profileId: "work" });
158
159 expect(openPreview.mock.calls[0]?.[0].input.profileId).toBe("work");
160 });
161
162 it("keeps the current tab active for a background open", async () => {
163 applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" });
164
165 await openUrlInPreview({
166 openPreview: async () => AsyncResult.success(snapshot),
167 threadRef,
168 url: "https://t3.chat/",
169 background: true,
170 });
171
172 const state = readThreadPreviewState(threadRef);
173 expect(state.activeTabId).toBe("tab-current");
174 expect(Object.keys(state.sessions).toSorted()).toEqual(["tab-1", "tab-current"]);
175 });
176
177 it("keeps a tab the user picked while a background open was in flight", async () => {
178 applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" });
179 applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-other" });
180 setActivePreviewTab(threadRef, "tab-current");
181
182 await openUrlInPreview({
183 openPreview: async () => {
184 // The server activates the new tab, then the user selects another one
185 // before the open resolves.
186 applyPreviewServerSnapshot(threadRef, snapshot);
187 setActivePreviewTab(threadRef, "tab-other");
188 return AsyncResult.success(snapshot);
189 },
190 threadRef,
191 url: "https://t3.chat/",
192 background: true,
193 });
194
195 expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-other");
196 });
197
198 it("keeps the new tab when the user picks it while a background open is in flight", async () => {
199 applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" });
200 setActivePreviewTab(threadRef, "tab-current");
201
202 await openUrlInPreview({
203 openPreview: async () => {
204 // The server activates the new tab, then the user clicks that same tab.
205 applyPreviewServerSnapshot(threadRef, snapshot);
206 useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId);
207 return AsyncResult.success(snapshot);
208 },
209 threadRef,
210 url: "https://t3.chat/",
211 background: true,
212 });
213
214 expect(readThreadPreviewState(threadRef).activeTabId).toBe(snapshot.tabId);
215 });
216
217 it("activates the new tab for a foreground open", async () => {
218 applyPreviewServerSnapshot(threadRef, { ...snapshot, tabId: "tab-current" });
219
220 await openUrlInPreview({
221 openPreview: async () => AsyncResult.success(snapshot),
222 threadRef,
223 url: "https://t3.chat/",
224 });
225
226 expect(readThreadPreviewState(threadRef).activeTabId).toBe("tab-1");
227 });
228});
packages/contracts/src/ipc.ts+9
...@@ -658,6 +658,14 @@ export interface DesktopPreviewPointerEvent {...@@ -658,6 +658,14 @@ export interface DesktopPreviewPointerEvent {
658 createdAt: string;658 createdAt: string;
659}659}
660660
661/** A `target="_blank"` link the previewed page asked to open beside itself. */
662export interface DesktopPreviewOpenLinkEvent {
663 tabId: string;
664 url: string;
665 /** True for middle-click / Cmd-click, which should not take focus. */
666 background: boolean;
667}
668
661/** Recording decorations are forwarded separately from the captured page pixels. */669/** Recording decorations are forwarded separately from the captured page pixels. */
662export const DesktopPreviewRecordingInputSchema = Schema.Union([670export const DesktopPreviewRecordingInputSchema = Schema.Union([
663 Schema.Struct({671 Schema.Struct({
...@@ -1331,6 +1339,7 @@ export interface DesktopPreviewBridge {...@@ -1331,6 +1339,7 @@ export interface DesktopPreviewBridge {
1331 };1339 };
1332 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;1340 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
1333 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;1341 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
1342 onOpenLink: (listener: (event: DesktopPreviewOpenLinkEvent) => void) => () => void;
1334}1343}
13351344
1336export type ConfirmDialogVariant = "default" | "destructive";1345export type ConfirmDialogVariant = "default" | "destructive";
pnpm-lock.yaml+11-11
...@@ -185,7 +185,7 @@ importers:...@@ -185,7 +185,7 @@ importers:
185 dependencies:185 dependencies:
186 '@clerk/electron':186 '@clerk/electron':
187 specifier: 0.0.44187 specifier: 0.0.44
188 version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.3.0(react@19.3.0))(react@19.2.6)188 version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.3.0(react@19.3.0))(react@19.2.6)
189 '@clerk/electron-passkeys':189 '@clerk/electron-passkeys':
190 specifier: 0.0.3190 specifier: 0.0.3
191 version: 0.0.3191 version: 0.0.3
...@@ -220,8 +220,8 @@ importers:...@@ -220,8 +220,8 @@ importers:
220 specifier: 4.0.1220 specifier: 4.0.1
221 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)221 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
222 electron:222 electron:
223 specifier: 44.4.2223 specifier: 44.4.5
224 version: 44.4.2224 version: 44.4.5
225 electron-store:225 electron-store:
226 specifier: ^8.2.0226 specifier: ^8.2.0
227 version: 8.2.0227 version: 8.2.0
...@@ -687,7 +687,7 @@ importers:...@@ -687,7 +687,7 @@ importers:
687 version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)687 version: 1.5.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
688 '@clerk/electron':688 '@clerk/electron':
689 specifier: 0.0.44689 specifier: 0.0.44
690 version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)690 version: 0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
691 '@clerk/react':691 '@clerk/react':
692 specifier: 6.16.1692 specifier: 6.16.1
693 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)693 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
...@@ -7727,8 +7727,8 @@ packages:...@@ -7727,8 +7727,8 @@ packages:
7727 resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}7727 resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
7728 engines: {node: '>=8.0.0'}7728 engines: {node: '>=8.0.0'}
77297729
7730 electron@44.4.2:7730 electron@44.4.5:
7731 resolution: {integrity: sha512-872t/+8M8TyribWm2cYpRwHY2aDlyDHOn+CHnhN9kHQStVW8pAijIhFolYA/qQyDdri28Xp8Mi3HEj5kFvajCw==}7731 resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
7732 engines: {node: '>= 22.12.0'}7732 engines: {node: '>= 22.12.0'}
7733 hasBin: true7733 hasBin: true
77347734
...@@ -12858,12 +12858,12 @@ snapshots:...@@ -12858,12 +12858,12 @@ snapshots:
12858 '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.312858 '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3
12859 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.312859 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3
1286012860
12861 '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':12861 '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12862 dependencies:12862 dependencies:
12863 '@clerk/clerk-js': 6.32.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)12863 '@clerk/clerk-js': 6.32.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
12864 '@clerk/react': 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)12864 '@clerk/react': 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
12865 '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)12865 '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
12866 electron: 44.4.212866 electron: 44.4.5
12867 react: 19.2.612867 react: 19.2.6
12868 tslib: 2.8.112868 tslib: 2.8.1
12869 optionalDependencies:12869 optionalDependencies:
...@@ -12871,12 +12871,12 @@ snapshots:...@@ -12871,12 +12871,12 @@ snapshots:
12871 electron-store: 8.2.012871 electron-store: 8.2.0
12872 react-dom: 19.2.6(react@19.2.6)12872 react-dom: 19.2.6(react@19.2.6)
1287312873
12874 '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.2)(react-dom@19.3.0(react@19.3.0))(react@19.2.6)':12874 '@clerk/electron@0.0.44(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.4.5)(react-dom@19.3.0(react@19.3.0))(react@19.2.6)':
12875 dependencies:12875 dependencies:
12876 '@clerk/clerk-js': 6.32.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6)12876 '@clerk/clerk-js': 6.32.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6)
12877 '@clerk/react': 6.16.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6)12877 '@clerk/react': 6.16.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6)
12878 '@clerk/shared': 4.33.0(react-dom@19.3.0(react@19.3.0))(react@19.2.6)12878 '@clerk/shared': 4.33.0(react-dom@19.3.0(react@19.3.0))(react@19.2.6)
12879 electron: 44.4.212879 electron: 44.4.5
12880 react: 19.2.612880 react: 19.2.6
12881 tslib: 2.8.112881 tslib: 2.8.1
12882 optionalDependencies:12882 optionalDependencies:
...@@ -18180,7 +18180,7 @@ snapshots:...@@ -18180,7 +18180,7 @@ snapshots:
18180 transitivePeerDependencies:18180 transitivePeerDependencies:
18181 - supports-color18181 - supports-color
1818218182
18183 electron@44.4.2:18183 electron@44.4.5:
18184 dependencies:18184 dependencies:
18185 '@electron-internal/extract-zip': 1.0.518185 '@electron-internal/extract-zip': 1.0.5
18186 '@electron/get': 5.1.018186 '@electron/get': 5.1.0
pnpm-workspace.yaml+1-1
...@@ -130,7 +130,7 @@ minimumReleaseAgeExclude:...@@ -130,7 +130,7 @@ minimumReleaseAgeExclude:
130 - expo-sharing@58.0.13130 - expo-sharing@58.0.13
131 - expo-sqlite@58.0.8131 - expo-sqlite@58.0.8
132 - expo-symbols@58.0.3132 - expo-symbols@58.0.3
133 - electron@44.4.2133 - electron@44.4.5
134 - "@anthropic-ai/claude-agent-sdk@0.3.276"134 - "@anthropic-ai/claude-agent-sdk@0.3.276"
135 - "@alchemy.run/cloudflare-runtime@2.0.0-beta.80"135 - "@alchemy.run/cloudflare-runtime@2.0.0-beta.80"
136 - "@alchemy.run/floci@2.0.0-beta.80"136 - "@alchemy.run/floci@2.0.0-beta.80"