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 @@
2424 "@t3tools/tailscale": "workspace:*",
2525 "dbus-next": "0.10.2",
2626 "effect": "catalog:",
27 "electron": "44.4.2",
27 "electron": "44.4.5",
2828 "electron-store": "^8.2.0",
2929 "electron-updater": "^6.8.9",
3030 "ffi-rs": "1.3.2",
apps/desktop/src/app/DesktopLifecycle.test.ts+1
......@@ -96,6 +96,7 @@ function layerDesktopWindow(
9696 dispatchMenuAction: () => Effect.void,
9797 dispatchSnapShotEvent: () => Effect.void,
9898 zoomMain: () => Effect.void,
99 runMainContentsCommand: () => Effect.void,
99100 syncAppearance: Effect.void,
100101 });
101102}
apps/desktop/src/backend/DesktopBackendPool.test.ts+1
......@@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref<string>): Layer.Layer<DesktopBackendPool.De
100100 dispatchMenuAction: () => Effect.die("unexpected menu action"),
101101 dispatchSnapShotEvent: () => Effect.void,
102102 zoomMain: () => Effect.die("unexpected zoom"),
103 runMainContentsCommand: () => Effect.die("unexpected main contents command"),
103104 syncAppearance: Effect.void,
104105 } satisfies DesktopWindow.DesktopWindow["Service"]),
105106 ),
apps/desktop/src/electron/ElectronDialog.ts+7-2
......@@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service<
9595 readonly pickFiles: (
9696 input: ElectronDialogPickFilesInput,
9797 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
98 /** Pass `owner` to attach the box to that window so it cannot fall behind it. */
9899 readonly showMessageBox: (
99100 options: Electron.MessageBoxOptions,
101 owner?: Electron.BrowserWindow,
100102 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
101103 readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
102104 }
......@@ -164,9 +166,12 @@ export const make = ElectronDialog.of({
164166 });
165167 return result.canceled ? [] : result.filePaths;
166168 }),
167 showMessageBox: (options) =>
169 showMessageBox: (options, owner) =>
168170 Effect.tryPromise({
169 try: () => Electron.dialog.showMessageBox(options),
171 try: () =>
172 owner === undefined
173 ? Electron.dialog.showMessageBox(options)
174 : Electron.dialog.showMessageBox(owner, options),
170175 catch: (cause) =>
171176 new ElectronDialogShowMessageBoxError({
172177 type: options.type ?? null,
apps/desktop/src/ipc/channels.ts+1
......@@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
109109export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
110110export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
111111export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
112export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link";
112113
113114export 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(
5454 yield* manager.subscribePointerEvents((event) =>
5555 electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
5656 );
57 yield* manager.subscribeOpenLinks((event) =>
58 electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event),
59 );
5760});
5861
5962export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({
apps/desktop/src/preload.ts+10
......@@ -1,5 +1,6 @@
11import type {
22 DesktopBridge,
3 DesktopPreviewOpenLinkEvent,
34 DesktopPreviewPointerEvent,
45 DesktopPreviewRecordingInputEvent,
56 DesktopPreviewRecordingFrame,
......@@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", {
416417 return () =>
417418 ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener);
418419 },
420 onOpenLink: (listener) => {
421 const wrappedListener = (_event: Electron.IpcRendererEvent, linkEvent: unknown) => {
422 if (typeof linkEvent !== "object" || linkEvent === null) return;
423 listener(linkEvent as DesktopPreviewOpenLinkEvent);
424 };
425 ipcRenderer.on(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
426 return () =>
427 ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener);
428 },
419429 },
420430} satisfies DesktopBridge);
apps/desktop/src/preview/BrowserSession.test.ts+94-2
......@@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
2121 >(),
2222}));
2323
24const { fromWebContents } = vi.hoisted(() => ({
25 fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
26}));
27
2428vi.mock("electron", () => ({
29 BrowserWindow: { fromWebContents },
2530 session: {
2631 fromPartition,
2732 },
2833}));
2934
35import * as ElectronDialog from "../electron/ElectronDialog.ts";
3036import * 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
3454describe("BrowserSession", () => {
3555 beforeEach(() => {
......@@ -168,6 +188,7 @@ describe("BrowserSession", () => {
168188 "clipboard-sanitized-write",
169189 "notifications",
170190 "geolocation",
191 "fullscreen",
171192 ]) {
172193 assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
173194 assert.isTrue(
......@@ -189,6 +210,73 @@ describe("BrowserSession", () => {
189210 }).pipe(Effect.provide(layer)),
190211 );
191212
213 it.effect("opens custom-scheme links externally only after the user confirms", () =>
214 Effect.gen(function* () {
215 const browserSessions = yield* BrowserSession.BrowserSession;
216 const partition = yield* browserSessions.getPartition("scope-a");
217 yield* browserSessions.getSession("scope-a");
218 const requestHandler =
219 sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
220 assert.isFunction(requestHandler);
221
222 const request = (externalURL: string) =>
223 Effect.callback<boolean>((resume) => {
224 requestHandler(
225 null,
226 "openExternal",
227 (granted: boolean) => resume(Effect.succeed(granted)),
228 { externalURL },
229 );
230 });
231
232 showMessageBox.mockReset();
233 showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1);
234 assert.isTrue(yield* request("slack://open?team=T1"));
235 assert.isFalse(yield* request("zoommtg://zoom.us/join"));
236 assert.strictEqual(showMessageBox.mock.calls.length, 2);
237
238 for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) {
239 assert.isFalse(yield* request(url), `${url} must never open externally`);
240 }
241 assert.strictEqual(showMessageBox.mock.calls.length, 2);
242 }).pipe(Effect.provide(layer)),
243 );
244
245 // A prompt without an owner can open behind the app window, leaving the one
246 // prompt slot taken and every later link silently denied.
247 it.effect("attaches the open-externally prompt to the window hosting the preview", () =>
248 Effect.gen(function* () {
249 const browserSessions = yield* BrowserSession.BrowserSession;
250 const partition = yield* browserSessions.getPartition("scope-a");
251 yield* browserSessions.getSession("scope-a");
252 const requestHandler =
253 sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
254 assert.isFunction(requestHandler);
255
256 const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow;
257 const host = { isDestroyed: () => false };
258 const guest = { isDestroyed: () => false, hostWebContents: host };
259 fromWebContents.mockImplementation((webContents) =>
260 webContents === host ? hostWindow : null,
261 );
262 showMessageBox.mockReset();
263 showMessageBox.mockReturnValueOnce(1);
264
265 yield* Effect.callback<boolean>((resume) => {
266 requestHandler(
267 guest,
268 "openExternal",
269 (granted: boolean) => resume(Effect.succeed(granted)),
270 {
271 externalURL: "slack://open?team=T1",
272 },
273 );
274 });
275
276 assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow);
277 }).pipe(Effect.provide(layer)),
278 );
279
192280 it.effect("preserves partition scope and the platform failure chain", () => {
193281 const nativeCause = new Error("native digest failed");
194282 const platformCause = PlatformError.systemError({
......@@ -218,7 +306,11 @@ describe("BrowserSession", () => {
218306 "Failed to derive a desktop preview browser partition for scope environment-a.",
219307 );
220308 assert.notInclude(error.message, nativeCause.message);
221 }).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(layerFailingCrypto))));
309 }).pipe(
310 Effect.provide(
311 BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))),
312 ),
313 );
222314 });
223315
224316 it.effect("preserves session scope, partition, and the Electron failure", () =>
apps/desktop/src/preview/BrowserSession.ts+115-5
......@@ -1,5 +1,5 @@
1import type { Session } from "electron";
2import { session } from "electron";
1import type { Session, WebContents } from "electron";
2import { BrowserWindow, session } from "electron";
33import * as Context from "effect/Context";
44import * as Crypto from "effect/Crypto";
55import * as Effect from "effect/Effect";
......@@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError";
99import * as Schema from "effect/Schema";
1010import * as SynchronizedRef from "effect/SynchronizedRef";
1111
12import * as ElectronDialog from "../electron/ElectronDialog.ts";
13
1214const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
1315/**
1416 * Incognito partitions deliberately omit the `persist:` prefix, which is what
......@@ -33,12 +35,51 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
3335 "clipboard-sanitized-write",
3436 "notifications",
3537 "geolocation",
38 // The Fullscreen API. `DesktopWindow` disables HTML fullscreen window resizing
39 // for the app window and every preview guest, so a page that goes fullscreen
40 // fills its own webview instead of taking over the whole T3 window.
41 "fullscreen",
3642 // Deliberately NOT local-fonts: preview sessions run untrusted web content,
3743 // and silently granting it would hand every page the user's installed-font
3844 // fingerprint (and font file bytes via FontData.blob()). The app's own font
3945 // picker runs in the main window session, which is unaffected by this list.
4046]);
4147
48/**
49 * Schemes a preview page may never hand to the OS, even with the user's
50 * consent: they read local files, run script, or are web pages that belong in
51 * the preview itself.
52 */
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
4283export class BrowserSessionPartitionDerivationError extends Schema.TaggedError<BrowserSessionPartitionDerivationError>()(
4384 "BrowserSessionPartitionDerivationError",
4485 {
......@@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array =>
159200 ),
160201 );
161202
203/**
204 * The window showing a preview guest. A `<webview>` guest has no window of its
205 * own, so look it up through the page that embeds it.
206 */
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
162214/** @public Service construction is part of the canonical Effect module API. */
163215export const make = Effect.gen(function* BrowserSessionMake() {
164216 const crypto = yield* Crypto.Crypto;
217 const electronDialog = yield* ElectronDialog.ElectronDialog;
218 const runFork = Effect.runForkWith(yield* Effect.context<never>());
219 // One prompt at a time, so a page cannot stack dialogs by looping a deep link.
220 let externalPromptOpen = false;
221 /**
222 * Electron grants an external-protocol navigation through the `openExternal`
223 * permission and then launches the OS handler itself. Ask first: previews run
224 * untrusted pages, and a silent grant would let any of them launch apps.
225 */
226 const confirmOpenExternal = (
227 requester: WebContents | null,
228 rawUrl: string | undefined,
229 callback: (granted: boolean) => void,
230 ): void => {
231 const url = externalProtocolPromptUrl(rawUrl);
232 if (url === null || externalPromptOpen) {
233 callback(false);
234 return;
235 }
236 externalPromptOpen = true;
237 const settle = (granted: boolean) =>
238 Effect.sync(() => {
239 externalPromptOpen = false;
240 callback(granted);
241 });
242 const shownUrl =
243 url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url;
244 runFork(
245 electronDialog
246 .showMessageBox(
247 {
248 type: "question",
249 buttons: ["Open", "Cancel"],
250 defaultId: 0,
251 cancelId: 1,
252 message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`,
253 detail: `A page in the browser wants to open another application:\n\n${shownUrl}`,
254 },
255 previewHostWindow(requester),
256 )
257 .pipe(
258 Effect.map(({ response }) => response === 0),
259 Effect.orElseSucceed(() => false),
260 Effect.flatMap(settle),
261 Effect.onInterrupt(() => settle(false)),
262 ),
263 );
264 };
165265 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
166266
167267 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
......@@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {
203303 // the challenge every few seconds, so logins behind it never complete
204304 // (#5002). Re-setting the unchanged native string is harmless, so it
205305 // is the rewritten string itself that trips the check.
206 browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
207 callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
208 });
306 browserSession.setPermissionRequestHandler(
307 (webContents, permission, callback, details) => {
308 if (permission === "openExternal") {
309 confirmOpenExternal(
310 webContents,
311 "externalURL" in details ? details.externalURL : undefined,
312 callback,
313 );
314 return;
315 }
316 callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
317 },
318 );
209319 browserSession.setPermissionCheckHandler((_webContents, permission) =>
210320 ALLOWED_PREVIEW_PERMISSIONS.has(permission),
211321 );
apps/desktop/src/preview/DesktopBrowserHost.test.ts+29
......@@ -119,4 +119,33 @@ describe("DesktopBrowserHost", () => {
119119 expect(paths).toEqual(["/srv/downloads/guid-1"]);
120120 }),
121121 );
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 );
122151});
apps/desktop/src/preview/DesktopBrowserHost.ts+20
......@@ -28,6 +28,11 @@ import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts
2828const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent));
2929const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand));
3030const 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
3237export interface DesktopBrowserTabKey {
3338 readonly threadId: string;
......@@ -50,6 +55,8 @@ interface AttachedTab {
5055 downloadDirectory: string | null;
5156 /** The guid CDP gave the download that is about to start. */
5257 pendingDownloadGuid: string | null;
58 /** When the server last sent this page input, which only an agent does. */
59 agentInputAt: number;
5360 readonly onMessage: (
5461 event: Electron.Event,
5562 method: string,
......@@ -72,6 +79,11 @@ export class DesktopBrowserHost extends Context.Service<
7279 readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void;
7380 /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */
7481 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;
7587 /** Points a server tab's download at the server; false for any other download. */
7688 readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
7789 /** The agent's cursor positions for attached tabs, keyed by their server tab. */
......@@ -161,6 +173,7 @@ export const make = Effect.gen(function* () {
161173 relay: null,
162174 downloadDirectory: null,
163175 pendingDownloadGuid: null,
176 agentInputAt: Number.NEGATIVE_INFINITY,
164177 onMessage: (_event, method, params, sessionId) => {
165178 if (method === "Browser.downloadWillBegin") {
166179 const guid = (params as { guid?: unknown } | undefined)?.guid;
......@@ -190,9 +203,15 @@ export const make = Effect.gen(function* () {
190203 tab.relay = null;
191204 return;
192205 }
206 if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now();
193207 relayFor(tab).receive(command.value.message);
194208 });
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
196215 // Read when a backend starts, not when the host is built.
197216 const announceAll = Effect.suspend(() =>
198217 Effect.forEach(
......@@ -219,6 +238,7 @@ export const make = Effect.gen(function* () {
219238 attach,
220239 detach,
221240 placeDownload,
241 humanStartedDownload,
222242 });
223243});
224244
apps/desktop/src/preview/Manager.test.ts+162-23
......@@ -158,21 +158,31 @@ describe("previewWindowOpenAction", () => {
158158 ).toBe("popup");
159159 });
160160
161 it("keeps target=_blank links in the preview tab", () => {
161 it("opens target=_blank links as a new tab", () => {
162162 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe(
163 "navigate",
163 "new-tab",
164164 );
165165 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe(
166 "navigate",
166 "new-tab",
167167 );
168168 });
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
170181 it("does not hand a window to schemes that cannot be hardened", () => {
171182 // A popup skips the `will-attach-webview` hardening, so it only gets a window
172183 // when its preferences can be overridden. Chromium copies the guest's
173184 // preferences for `about:blank` and forbids overriding them.
174185 for (const url of [
175 "about:blank",
176186 "javascript:alert(1)",
177187 "file:///etc/passwd",
178188 "vscode://vscode-remote/ssh-remote+box/tmp",
......@@ -181,6 +191,16 @@ describe("previewWindowOpenAction", () => {
181191 expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate");
182192 }
183193 });
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 });
184204});
185205
186206const {
......@@ -355,7 +375,7 @@ const makeTestPreviewWebContents = (
355375 getType: () => "webview",
356376 getURL: () => "https://example.com",
357377 getTitle: () => "Example",
358 isLoading: () => false,
378 isLoadingMainFrame: () => false,
359379 getZoomFactor: () => 1,
360380 setZoomFactor: vi.fn(),
361381 setAudioMuted: vi.fn(),
......@@ -460,7 +480,7 @@ const makeFaviconWebContents = (options?: {
460480 getType: () => "webview",
461481 getURL: () => currentUrl,
462482 getTitle: () => "Preview",
463 isLoading: () => loading,
483 isLoadingMainFrame: () => loading,
464484 isDevToolsOpened: () => false,
465485 getZoomFactor: () => 1,
466486 setZoomFactor: vi.fn(),
......@@ -572,6 +592,40 @@ describe("PreviewManager", () => {
572592 webviewSend.mockClear();
573593 });
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
575629 effectIt.effect("keeps preview shortcuts out of the host window", () =>
576630 withManager((manager) =>
577631 Effect.gen(function* () {
......@@ -885,7 +939,7 @@ describe("PreviewManager", () => {
885939 getType: () => "webview",
886940 getURL: () => "about:blank",
887941 getTitle: () => "",
888 isLoading: () => false,
942 isLoadingMainFrame: () => false,
889943 getZoomFactor: () => 1,
890944 setZoomFactor: vi.fn(),
891945 setAudioMuted: vi.fn(),
......@@ -978,7 +1032,7 @@ describe("PreviewManager", () => {
9781032 getType: () => "webview",
9791033 getURL: () => "http://localhost:3200/",
9801034 getTitle: () => "Preview",
981 isLoading: () => false,
1035 isLoadingMainFrame: () => false,
9821036 isDevToolsOpened: () => false,
9831037 getZoomFactor: () => 1,
9841038 setZoomFactor: vi.fn(),
......@@ -1481,7 +1535,7 @@ describe("PreviewManager", () => {
14811535 getType: () => "webview",
14821536 getURL: () => url,
14831537 getTitle: () => "Example",
1484 isLoading: () => false,
1538 isLoadingMainFrame: () => false,
14851539 getZoomFactor: () => {
14861540 if (!zoomReadable) throw new Error("zoom unavailable");
14871541 return effectiveZoom;
......@@ -1551,7 +1605,7 @@ describe("PreviewManager", () => {
15511605 getType: () => "webview",
15521606 getURL: () => url,
15531607 getTitle: () => "Example",
1554 isLoading: () => false,
1608 isLoadingMainFrame: () => false,
15551609 getZoomFactor: () => 1,
15561610 setZoomFactor: replacementSetZoomFactor,
15571611 setAudioMuted: vi.fn(),
......@@ -1592,7 +1646,7 @@ describe("PreviewManager", () => {
15921646 getType: () => "webview",
15931647 getURL: () => "https://example.com",
15941648 getTitle: () => "Example",
1595 isLoading: () => false,
1649 isLoadingMainFrame: () => false,
15961650 getZoomFactor: () => 1,
15971651 setZoomFactor,
15981652 setAudioMuted: vi.fn(),
......@@ -1639,7 +1693,7 @@ describe("PreviewManager", () => {
16391693 getType: () => "webview",
16401694 getURL: () => "https://example.com",
16411695 getTitle: () => "Example",
1642 isLoading: () => false,
1696 isLoadingMainFrame: () => false,
16431697 getZoomFactor: () => 1,
16441698 setZoomFactor,
16451699 setAudioMuted: vi.fn(),
......@@ -1695,7 +1749,7 @@ describe("PreviewManager", () => {
16951749 getType: () => "webview",
16961750 getURL: () => "https://example.com",
16971751 getTitle: () => "Example",
1698 isLoading: () => false,
1752 isLoadingMainFrame: () => false,
16991753 getZoomFactor: () => 1,
17001754 setZoomFactor: vi.fn(),
17011755 setAudioMuted: vi.fn(),
......@@ -1790,7 +1844,7 @@ describe("PreviewManager", () => {
17901844 getType: () => "webview",
17911845 getURL: () => "http://localhost:5173/README.md",
17921846 getTitle: () => "README.md",
1793 isLoading: () => true,
1847 isLoadingMainFrame: () => true,
17941848 getZoomFactor: () => 1,
17951849 setZoomFactor: vi.fn(),
17961850 setAudioMuted: vi.fn(),
......@@ -1915,7 +1969,7 @@ describe("PreviewManager", () => {
19151969 getType: () => "webview",
19161970 getURL: () => "https://example.com",
19171971 getTitle: () => "Example",
1918 isLoading: () => false,
1972 isLoadingMainFrame: () => false,
19191973 getZoomFactor: () => 1,
19201974 setZoomFactor: vi.fn(),
19211975 setAudioMuted,
......@@ -2258,7 +2312,7 @@ describe("PreviewManager", () => {
22582312 getType: () => "webview",
22592313 getURL: () => url,
22602314 getTitle: () => "localhost:5733",
2261 isLoading: () => loading,
2315 isLoadingMainFrame: () => loading,
22622316 getZoomFactor: () => 1,
22632317 setZoomFactor: vi.fn(),
22642318 setAudioMuted: vi.fn(),
......@@ -2339,6 +2393,28 @@ describe("PreviewManager", () => {
23392393 ),
23402394 );
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
23422418 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
23432419 withManager((manager) =>
23442420 Effect.gen(function* () {
......@@ -2351,7 +2427,7 @@ describe("PreviewManager", () => {
23512427 getType: () => "webview",
23522428 getURL: () => "https://example.com:8443/path?query=value",
23532429 getTitle: () => "Example",
2354 isLoading: () => false,
2430 isLoadingMainFrame: () => false,
23552431 getZoomFactor: () => 1,
23562432 setZoomFactor: vi.fn(),
23572433 setAudioMuted: vi.fn(),
......@@ -2919,7 +2995,7 @@ describe("PreviewManager", () => {
29192995 getType: () => "webview",
29202996 getURL: () => `https://example.com/${id}`,
29212997 getTitle: () => `Example ${id}`,
2922 isLoading: () => false,
2998 isLoadingMainFrame: () => false,
29232999 getZoomFactor: () => 1,
29243000 setZoomFactor: vi.fn(),
29253001 setAudioMuted: vi.fn(),
......@@ -3381,7 +3457,7 @@ describe("PreviewManager", () => {
33813457 getType: () => "webview",
33823458 getURL: () => "https://example.com",
33833459 getTitle: () => "Example",
3384 isLoading: () => false,
3460 isLoadingMainFrame: () => false,
33853461 getZoomFactor: () => 1,
33863462 setZoomFactor: vi.fn(),
33873463 setAudioMuted: vi.fn(),
......@@ -3880,7 +3956,7 @@ describe("PreviewManager", () => {
38803956 getType: () => "webview",
38813957 getURL: () => "https://example.com",
38823958 getTitle: () => "Example",
3883 isLoading: () => false,
3959 isLoadingMainFrame: () => false,
38843960 isFocused: () => true,
38853961 getZoomFactor: () => 1,
38863962 setZoomFactor: vi.fn(),
......@@ -4026,7 +4102,7 @@ describe("PreviewManager", () => {
40264102 getType: () => "webview",
40274103 getURL: () => "https://example.com",
40284104 getTitle: () => "Example",
4029 isLoading: () => false,
4105 isLoadingMainFrame: () => false,
40304106 isFocused: () => true,
40314107 getZoomFactor: () => 1,
40324108 setZoomFactor: vi.fn(),
......@@ -4095,6 +4171,69 @@ describe("PreviewManager", () => {
40954171 ),
40964172 );
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
40984237 effectIt.effect("a stale capture from a replaced pick never touches the next pick", () =>
40994238 withManager((manager) =>
41004239 Effect.gen(function* () {
......@@ -4105,7 +4244,7 @@ describe("PreviewManager", () => {
41054244 getType: () => "webview",
41064245 getURL: () => "https://example.com",
41074246 getTitle: () => "Example",
4108 isLoading: () => false,
4247 isLoadingMainFrame: () => false,
41094248 isFocused: () => true,
41104249 getZoomFactor: () => 1,
41114250 setZoomFactor: vi.fn(),
......@@ -4190,7 +4329,7 @@ describe("PreviewManager", () => {
41904329 getType: () => "webview",
41914330 getURL: () => "https://example.com",
41924331 getTitle: () => "Example",
4193 isLoading: () => false,
4332 isLoadingMainFrame: () => false,
41944333 getZoomFactor: () => 1,
41954334 setZoomFactor: vi.fn(),
41964335 setAudioMuted: vi.fn(),
apps/desktop/src/preview/Manager.ts+142-61
......@@ -13,6 +13,7 @@ import type {
1313 DesktopPreviewAnnotationTheme,
1414 DesktopPreviewColorScheme,
1515 DesktopPreviewFavicon,
16 DesktopPreviewOpenLinkEvent,
1617 DesktopPreviewPointerEvent,
1718 PreviewAnnotationPayload,
1819 PreviewAnnotationRect,
......@@ -30,6 +31,7 @@ import { normalizePreviewUrl } from "@t3tools/shared/preview";
3031import {
3132 BrowserWindow,
3233 ClipboardItem,
34 app,
3335 type Session,
3436 clipboard,
3537 nativeImage,
......@@ -284,61 +286,36 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
284286 };
285287};
286288
287/** `capturePage` never settles when the guest's compositor is wedged. */
288const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds";
289
290289/**
291 * Crops the guest for a picked annotation. A stalled `capturePage` resolves to
292 * `null` after the timeout: the annotation is still sendable without its
293 * screenshot, and the pick session must settle either way.
290 * Crops a full-page capture to a picked annotation. `cropRect` is in CSS px;
291 * the capture's pixels are CSS px × the guest's `devicePixelRatio`.
294292 */
295const captureAnnotationScreenshot = (
296 tabId: string,
297 wc: Electron.WebContents,
293const cropAnnotationScreenshot = (
294 image: Electron.NativeImage,
298295 cropRect: PreviewAnnotationRect | null,
299): Effect.Effect<PreviewAnnotationPayload["screenshot"], PreviewManagerError> =>
300 Effect.tryPromise({
301 // The unused abort signal is what makes this interruptible, and therefore
302 // what lets the timeout below fire. Drop the parameter and a stalled
303 // capture strands the pick session again.
304 try: (_signal) =>
305 wc.capturePage(
306 cropRect
307 ? {
308 x: cropRect.x,
309 y: cropRect.y,
310 width: cropRect.width,
311 height: cropRect.height,
312 }
313 : undefined,
314 ),
315 catch: (cause) =>
316 new PreviewOperationError({
317 operation: "captureAnnotationScreenshot",
318 tabId,
319 webContentsId: wc.id,
320 cause,
321 }),
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 );
296 devicePixelRatio: number,
297): PreviewAnnotationPayload["screenshot"] => {
298 const full = image.getSize();
299 if (!cropRect) {
300 return {
301 dataUrl: image.toDataURL(),
302 width: full.width,
303 height: full.height,
304 cropRect: { x: 0, y: 0, width: full.width, height: full.height },
305 };
306 }
307 const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
308 const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale)));
309 const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale)));
310 const cropped = image.crop({
311 x,
312 y,
313 width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))),
314 height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))),
315 });
316 const size = cropped.getSize();
317 return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect };
318};
342319
343320const findZoomStep = (current: number): number => {
344321 const index = ZOOM_LEVELS.findIndex(
......@@ -437,6 +414,8 @@ type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effe
437414
438415type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;
439416
417type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect<void>;
418
440419/**
441420 * Protocols a preview page may open in a real popup window.
442421 *
......@@ -483,14 +462,29 @@ const POPUP_WINDOW_OPTIONS = {
483462 * navigating the preview tab instead destroys the opener the popup has to
484463 * `postMessage` its result back to.
485464 *
486 * `target="_blank"` links arrive as a tab disposition and keep loading in the
487 * preview tab, which is what people expect from a link inside a preview.
465 * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web
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.
488474 */
489475export const previewWindowOpenAction = (details: {
490476 readonly url: string;
491477 readonly disposition: Electron.HandlerDetails["disposition"];
492}): "popup" | "navigate" =>
493 details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate";
478 readonly postBody?: Electron.PostBody;
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
495489export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
496490 input.type === "keyDown" &&
......@@ -566,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
566560 const attachedRef = yield* Ref.make<ReadonlyMap<number, ManagedListeners>>(new Map());
567561 const listenersRef = yield* Ref.make<ReadonlySet<Listener>>(new Set());
568562 const pointerEventListenersRef = yield* Ref.make<ReadonlySet<PointerEventListener>>(new Set());
563 const openLinkListenersRef = yield* Ref.make<ReadonlySet<OpenLinkListener>>(new Set());
569564 const recordingInputListenersRef = yield* Ref.make<ReadonlySet<RecordingInputListener>>(
570565 new Set(),
571566 );
......@@ -836,7 +831,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
836831 });
837832
838833 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",
840840 tabId: string,
841841 delivery: () => Effect.Effect<void>,
842842 ) =>
......@@ -1241,7 +1241,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12411241 const url = wc.getURL();
12421242 const title = wc.getTitle();
12431243 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 };
12451248 return { kind: "Success", url, title };
12461249 };
12471250
......@@ -1607,9 +1610,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16071610 wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);
16081611 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
16091612 wc.setWindowOpenHandler((details) => {
1610 if (previewWindowOpenAction(details) === "popup") {
1613 const action = previewWindowOpenAction(details);
1614 if (action === "popup") {
16111615 return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };
16121616 }
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 }
16131628 runFork(
16141629 attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
16151630 wc.loadURL(details.url),
......@@ -2234,8 +2249,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
22342249 const cropRect = normalizeCaptureRect(args[1]);
22352250 const submission =
22362251 args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach";
2252 const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1;
22372253 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"),
22392269 // The renderer cannot tell a dropped crop from a comment-only
22402270 // pick by the null alone, so a failed or timed-out capture is
22412271 // flagged on the result.
......@@ -3320,6 +3350,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
33203350 );
33213351 });
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 });
33233363 const subscribe = <A>(
33243364 ref: Ref.Ref<ReadonlySet<A>>,
33253365 listener: A,
......@@ -3341,6 +3381,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
33413381 [
33423382 Ref.set(listenersRef, new Set()),
33433383 Ref.set(pointerEventListenersRef, new Set()),
3384 Ref.set(openLinkListenersRef, new Set()),
33443385 Ref.set(recordingFrameListenersRef, new Set()),
33453386 Ref.set(recordingInputListenersRef, new Set()),
33463387 ],
......@@ -3386,6 +3427,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
33863427 stopRecording,
33873428 subscribePointerEvents: (listener: PointerEventListener) =>
33883429 subscribe(pointerEventListenersRef, listener),
3430 subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener),
33893431 subscribeRecordingInputs: (listener: RecordingInputListener) =>
33903432 subscribe(recordingInputListenersRef, listener),
33913433 subscribeRecordingFrames: (listener: RecordingFrameListener) =>
......@@ -3627,6 +3669,9 @@ export class PreviewManager extends Context.Service<
36273669 readonly subscribePointerEvents: (
36283670 listener: PointerEventListener,
36293671 ) => Effect.Effect<void, never, Scope.Scope>;
3672 readonly subscribeOpenLinks: (
3673 listener: OpenLinkListener,
3674 ) => Effect.Effect<void, never, Scope.Scope>;
36303675 readonly subscribeRecordingInputs: (
36313676 listener: RecordingInputListener,
36323677 ) => Effect.Effect<void, never, Scope.Scope>;
......@@ -3642,13 +3687,48 @@ export const make = Effect.gen(function* PreviewManagerMake() {
36423687 const browserSession = yield* BrowserSession.BrowserSession;
36433688 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
36443689 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 });
36453712 // Server tabs save downloads where the server's engine reads them. Downloads
36463713 // the person starts in a tab the server is not driving keep Electron's dialog.
36473714 const placeServerDownloads = (session: Electron.Session) => {
36483715 if (downloadSessions.has(session)) return;
36493716 downloadSessions.add(session);
36503717 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 });
36523732 });
36533733 };
36543734
......@@ -3732,6 +3812,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
37323812 saveRecording: operations.saveRecording,
37333813 subscribeStateChanges: operations.subscribeStateChanges,
37343814 subscribePointerEvents: operations.subscribePointerEvents,
3815 subscribeOpenLinks: operations.subscribeOpenLinks,
37353816 subscribeRecordingFrames: operations.subscribeRecordingFrames,
37363817 subscribeRecordingInputs: operations.subscribeRecordingInputs,
37373818 });
apps/desktop/src/preview/PickPreload.ts+2
......@@ -1410,6 +1410,8 @@ function startAnnotation(sendEnabled: boolean): void {
14101410 annotation,
14111411 screenshotRect,
14121412 submission === "send" && !sendEnabled ? "attach" : submission,
1413 // Main crops a full-page capture, whose pixels are CSS px × this.
1414 window.devicePixelRatio,
14131415 );
14141416 })
14151417 .catch(() => {
apps/desktop/src/window/DesktopApplicationMenu.test.ts+29-1
......@@ -87,6 +87,8 @@ const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>
8787 dispatchSnapShotEvent: () => Effect.void,
8888 zoomMain: (direction) =>
8989 Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
90 runMainContentsCommand: (command) =>
91 Deferred.succeed(selectedAction, `main-${command}`).pipe(Effect.asVoid),
9092 syncAppearance: Effect.void,
9193 } satisfies DesktopWindow.DesktopWindow["Service"]);
9294
......@@ -265,7 +267,9 @@ describe("DesktopApplicationMenu", () => {
265267 }
266268
267269 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 ),
269273 );
270274
271275 const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In");
......@@ -279,4 +283,28 @@ describe("DesktopApplicationMenu", () => {
279283 assert.equal(yield* Deferred.await(selectedAction), "zoom-in");
280284 }),
281285 );
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 );
282310});
apps/desktop/src/window/DesktopApplicationMenu.ts+26-8
......@@ -57,6 +57,13 @@ const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
5757 yield* desktopWindow.zoomMain(direction);
5858});
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
6067const checkForUpdatesFromMenu = Effect.gen(function* () {
6168 const updates = yield* DesktopUpdates.DesktopUpdates;
6269 const electronDialog = yield* ElectronDialog.ElectronDialog;
......@@ -150,6 +157,9 @@ export const make = Effect.gen(function* () {
150157 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
151158 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
152159 };
160 const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => {
161 runMenuEffect(command, runMainContentsCommand(command));
162 };
153163 const template: Electron.MenuItemConstructorOptions[] = [];
154164
155165 if (environment.platform === "darwin") {
......@@ -227,16 +237,24 @@ export const make = Effect.gen(function* () {
227237 {
228238 label: "View",
229239 submenu: [
230 { role: "reload" },
231 { role: "forceReload" },
232 { role: "toggleDevTools" },
233 { type: "separator" },
234240 /*
235 Not the zoom roles: those act on the focused webContents, so with
236 an embedded preview WebContentsView focused they zoom the guest
237 page and the app UI appears stuck. These always zoom the main
238 window (see DesktopWindow.zoomMain).
241 Not the reload, DevTools or zoom roles: those act on the focused
242 webContents, so with a browser page focused they reload or zoom
243 the guest page and the app UI appears stuck. These always target
244 the main window (see DesktopWindow.zoomMain).
239245 */
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" },
240258 { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
241259 { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
242260 {
apps/desktop/src/window/DesktopWindow.test.ts+40-1
......@@ -90,10 +90,12 @@ function makeFakeBrowserWindow() {
9090 once: vi.fn<(eventName: string, listener: (...args: readonly unknown[]) => void) => void>(),
9191 openDevTools: vi.fn(),
9292 reload: vi.fn(),
93 reloadIgnoringCache: vi.fn(),
9394 replaceMisspelling: vi.fn(),
9495 send: vi.fn(),
9596 setBackgroundThrottling: vi.fn(),
9697 setWindowOpenHandler: vi.fn(),
98 toggleDevTools: vi.fn(),
9799 };
98100
99101 const window = {
......@@ -138,6 +140,8 @@ function makeFakeBrowserWindow() {
138140 maximize: window.maximize,
139141 openDevTools: webContents.openDevTools,
140142 reload: webContents.reload,
143 reloadIgnoringCache: webContents.reloadIgnoringCache,
144 toggleDevTools: webContents.toggleDevTools,
141145 send: webContents.send,
142146 setZoomLevel: webContents.setZoomLevel,
143147 setWindowButtonPosition: window.setWindowButtonPosition,
......@@ -227,6 +231,7 @@ function layerTest(input: {
227231 readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect<void>;
228232 readonly previewZoomReapplies?: number[];
229233 readonly onReveal?: (window: Electron.BrowserWindow) => void;
234 readonly focusedWindow?: Electron.BrowserWindow;
230235}) {
231236 let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
232237 const layerDesktopAppSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
......@@ -273,7 +278,10 @@ function layerTest(input: {
273278 ),
274279 main: Ref.get(input.mainWindow),
275280 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),
277285 setMain: (window) => Ref.set(input.mainWindow, Option.some(window)),
278286 clearMain: () => Ref.set(input.mainWindow, Option.none()),
279287 prepareReveal: () => Effect.succeed(false),
......@@ -782,6 +790,37 @@ describe("DesktopWindow", () => {
782790 }),
783791 );
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
785824 it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () =>
786825 Effect.gen(function* () {
787826 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+21
......@@ -93,6 +93,7 @@ export type DesktopWindowError =
9393 | PreviewManager.PreviewManagerError;
9494
9595export type MainWindowZoomDirection = "in" | "out" | "reset";
96export type MainWindowContentsCommand = "reload" | "forceReload" | "toggleDevTools";
9697
9798export class DesktopWindow extends Context.Service<
9899 DesktopWindow,
......@@ -137,6 +138,10 @@ export class DesktopWindow extends Context.Service<
137138 // guest page instead of the app UI. The menu routes here to always target
138139 // the main window.
139140 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>;
140145 readonly syncAppearance: Effect.Effect<void>;
141146 }
142147>()("@t3tools/desktop/window/DesktopWindow") {}
......@@ -420,6 +425,10 @@ export const make = Effect.gen(function* () {
420425 nodeIntegration: false,
421426 sandbox: true,
422427 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,
423432 },
424433 });
425434
......@@ -527,6 +536,7 @@ export const make = Effect.gen(function* () {
527536 webPreferences.nodeIntegration = false;
528537 webPreferences.nodeIntegrationInSubFrames = false;
529538 webPreferences.contextIsolation = false;
539 webPreferences.disableHtmlFullscreenWindowResize = true;
530540 });
531541
532542 const contextMenuContents = new WeakSet<Electron.WebContents>();
......@@ -1027,6 +1037,17 @@ export const make = Effect.gen(function* () {
10271037 // own zoom, so put each guest back where the preview left it.
10281038 yield* previewManager.reapplyZoom();
10291039 }),
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 }),
10301051 syncAppearance: Effect.gen(function* () {
10311052 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
10321053 yield* electronWindow.syncAllAppearance((window) =>
apps/web/src/browser/BrowserSurfaceSlot.tsx+6
......@@ -68,6 +68,12 @@ export function BrowserSurfaceSlot(props: {
6868 // full width. The slot moves without resizing, so measure on shell resizes too.
6969 const panel = element.closest('[data-preview-panel-mode="inline"]');
7070 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 }
7177 window.addEventListener("resize", update);
7278 window.addEventListener("scroll", update, true);
7379 return () => {
apps/web/src/browser/ElectronBrowserHost.tsx+30-1
......@@ -3,18 +3,21 @@
33import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
44import { AuthPreviewOperateScope, FILL_PREVIEW_VIEWPORT } from "@t3tools/contracts";
55import { useAtomValue } from "@effect/atom-react";
6import { type ComponentProps, useEffect, useMemo } from "react";
6import { type ComponentProps, useEffect, useMemo, useRef } from "react";
77
88import { primaryEnvironmentIdAtom } from "~/state/primaryEnvironment";
99
1010import { isElectron } from "~/env";
1111import { useTheme } from "~/hooks/useTheme";
1212import { useActivePreviewSessions } from "~/previewStateStore";
13import { previewEnvironment } from "~/state/preview";
1314import { useEnvironmentScope } from "~/state/session";
15import { useAtomCommand } from "~/state/use-atom-command";
1416
1517import { readPreviewAnnotationTheme } from "./annotationTheme";
1618import { useBrowserPointerStore } from "./browserPointerStore";
1719import { HostedBrowserWebview } from "./HostedBrowserWebview";
20import { openUrlInPreview } from "./openFileInPreview";
1821import { rendersServerTabNatively } from "./previewRuntime";
1922import { previewRuntimeTabId } from "./previewRuntimeTabId";
2023
......@@ -92,6 +95,32 @@ export function ElectronBrowserHost() {
9295 });
9396 }, []);
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
95124 if (!isElectron) return null;
96125 return (
97126 <div className="contents" data-electron-browser-host>
apps/web/src/browser/browserTargetResolver.test.ts+29
......@@ -2,8 +2,10 @@ import { EnvironmentId } from "@t3tools/contracts";
22import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
44const readPreparedConnection = vi.fn();
5const env = vi.hoisted(() => ({ isElectron: false }));
56
67vi.mock("~/state/session", () => ({ readPreparedConnection }));
8vi.mock("~/env", () => env);
79
810describe("browser target resolver", () => {
911 beforeEach(() => readPreparedConnection.mockReset());
......@@ -160,6 +162,33 @@ describe("browser target resolver", () => {
160162 ).toBe("http://192.168.1.25:3000/app");
161163 });
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
163192 it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => {
164193 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });
165194 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
apps/web/src/browser/browserTargetResolver.ts+17
......@@ -6,6 +6,10 @@ import type {
66import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
77import { 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";
913import { readPreparedConnection } from "~/state/session";
1014
1115export {
......@@ -75,11 +79,24 @@ export function resolveBrowserNavigationTarget(
7579 return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId));
7680}
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
7893export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
7994 try {
8095 const normalizedUrl = normalizePreviewUrl(rawUrl);
8196 const parsed = new URL(normalizedUrl);
8297 if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;
98 const target = readPreparedConnection(environmentId)?.target;
99 if (target && reachesEnvironmentLoopback(target)) return normalizedUrl;
83100 return resolveEnvironmentPortTarget(
84101 environmentId,
85102 {
apps/web/src/browser/browserViewportLayout.test.ts+5-5
......@@ -123,15 +123,15 @@ describe("resolveBrowserViewportLayout", () => {
123123 expect(
124124 resolveBrowserDeviceViewportLayout(
125125 { width: 1200, height: 900 },
126 { _tag: "freeform", width: 1180, height: 858 },
126 { _tag: "freeform", width: 1180, height: 852 },
127127 ),
128128 ).toEqual({
129129 canvasWidth: 1200,
130130 canvasHeight: 900,
131131 viewportX: 10,
132 viewportY: 32,
132 viewportY: 38,
133133 viewportWidth: 1180,
134 viewportHeight: 858,
134 viewportHeight: 852,
135135 viewportScale: 1,
136136 fillsPanel: false,
137137 });
......@@ -140,11 +140,11 @@ describe("resolveBrowserViewportLayout", () => {
140140 it("captures the available framed area when responsive mode is enabled", () => {
141141 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 })).toEqual({
142142 width: 1180,
143 height: 858,
143 height: 852,
144144 });
145145 expect(resolveResponsiveBrowserViewportSize({ width: 1200, height: 900 }, 2)).toEqual({
146146 width: 590,
147 height: 429,
147 height: 426,
148148 });
149149 });
150150
apps/web/src/browser/browserViewportLayout.ts+4-1
......@@ -19,7 +19,10 @@ export interface BrowserViewportLayout {
1919 readonly fillsPanel: boolean;
2020}
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;
2326export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10;
2427
2528export type BrowserViewportResizeDirection =
apps/web/src/browser/openFileInPreview.ts+34-4
......@@ -17,8 +17,14 @@ import { AsyncResult } from "effect/reactivity";
1717
1818import { resolveAssetUrl } from "~/assets/assetUrls";
1919import { isPreviewAvailableFor, previewRuntimeFor } from "~/browser/previewRuntime";
20import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore";
21import { useRightPanelStore } from "~/rightPanelStore";
20import {
21 applyPreviewServerSnapshot,
22 readThreadPreviewState,
23 rememberPreviewUrl,
24 setActivePreviewTab,
25 updatePreviewServerSnapshot,
26} from "~/previewStateStore";
27import { selectSelectedRightPanelSurface, useRightPanelStore } from "~/rightPanelStore";
2228
2329import {
2430 browserDefaultOpenProfileId,
......@@ -52,6 +58,10 @@ export async function openUrlInPreview<E>(input: {
5258 readonly threadRef: ScopedThreadRef;
5359 readonly url: string;
5460 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;
5565}): Promise<AtomCommandResult<void, E | BrowserSettingsReadError>> {
5666 const defaults = await resolveBrowserDefaults().catch(
5767 (cause: unknown) => new BrowserSettingsReadError({ cause }),
......@@ -60,6 +70,13 @@ export async function openUrlInPreview<E>(input: {
6070 return AsyncResult.failure(Cause.fail(defaults));
6171 }
6272 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();
6380 const result = await input.openPreview({
6481 environmentId: input.threadRef.environmentId,
6582 input: {
......@@ -69,13 +86,26 @@ export async function openUrlInPreview<E>(input: {
6986 // maps the result differently, so the configured defaults have to be
7087 // applied explicitly or file/link opens would ignore them.
7188 viewport: browserDefaultOpenViewport(defaults),
72 profileId: browserDefaultOpenProfileId(defaults),
89 profileId: input.profileId ?? browserDefaultOpenProfileId(defaults),
7390 ...(runtime === undefined ? {} : { runtime }),
7491 },
7592 });
7693 return mapAtomCommandResult(result, (snapshot) => {
77 applyPreviewServerSnapshot(input.threadRef, snapshot);
7894 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);
79109 useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId);
80110 });
81111}
apps/web/src/components/AppSidebarLayout.tsx+8-1
......@@ -124,7 +124,14 @@ function SidebarControl() {
124124
125125 // Capture before focused editors consume commands such as Mod+B for rich-text formatting.
126126 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 };
128135 }, [keybindings, toggleSidebar, usagePageOpen]);
129136
130137 return (
apps/web/src/components/ChatView.tsx+16
......@@ -8076,6 +8076,13 @@ export default function ChatView(props: ChatViewProps) {
80768076 return;
80778077 }
80788078
8079 if (command === "rightPanel.toggleMaximized") {
8080 event.preventDefault();
8081 event.stopPropagation();
8082 if (!event.repeat) toggleRightPanelMaximized();
8083 return;
8084 }
8085
80798086 if (command === "threadPanel.toggle") {
80808087 event.preventDefault();
80818088 event.stopPropagation();
......@@ -8313,6 +8320,7 @@ export default function ChatView(props: ChatViewProps) {
83138320 closeThreadFind,
83148321 isThreadFindActive,
83158322 toggleRightPanel,
8323 toggleRightPanelMaximized,
83168324 toggleThreadPanel,
83178325 toggleTerminalVisibility,
83188326 composerRef,
......@@ -8324,6 +8332,14 @@ export default function ChatView(props: ChatViewProps) {
83248332 onEnvironmentChange,
83258333 ]);
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
83278343 // Paste-to-focus: the resting composer blurs on a click into the timeline,
83288344 // so a paste that follows has no editable target and would be dropped.
83298345 // 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", () => {
309309 );
310310
311311 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 );
312316 useClosedViewStore
313317 .getState()
314318 .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
315319 await render();
316 expect(state.setShortcuts).toHaveBeenLastCalledWith([
317 expect.objectContaining({ command: "view.reopenClosed" }),
318 ]);
320 expect(forwardedCommands()).toContain("view.reopenClosed");
319321 await act(() => {
320322 menuAction?.("view.reopenClosed");
321323 });
322324 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 ]);
324334 });
325335});
apps/web/src/components/ReopenClosedViewShortcut.tsx+32-14
......@@ -43,6 +43,13 @@ import {
4343} from "../threadRoutes";
4444import { 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
4653const isGlobalPullRequests = (ref: ScopedThreadRef) =>
4754 scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF);
4855
......@@ -175,22 +182,33 @@ export function ReopenClosedViewShortcut() {
175182 useEffect(() => {
176183 const preview = window.desktopBridge?.preview;
177184 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 ];
178204 void preview
179205 .setForwardedShortcuts(
180 hasHistory
181 ? effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {
182 context: {
183 previewFocus: true,
184 previewOpen: true,
185 terminalFocus: false,
186 terminalOpen,
187 editableFocus: false,
188 modelPickerOpen: false,
189 isDesktop: true,
190 isWeb: false,
191 },
192 }).map((shortcut) => ({ command: "view.reopenClosed", shortcut }))
193 : [],
206 commands.flatMap((command) =>
207 effectiveShortcutsForCommand(keybindings, command, options).map((shortcut) => ({
208 command,
209 shortcut,
210 })),
211 ),
194212 )
195213 .catch(() => undefined);
196214 return () => {
apps/web/src/components/composerContextPresentation.test.ts+38
......@@ -8,6 +8,44 @@ import {
88} from "./composerContextPresentation";
99
1010describe("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
1149 it("recovers the uploaded record when clipboard data points at an attachment already in the draft", () => {
1250 const file = {
1351 type: "file" as const,
apps/web/src/components/composerContextPresentation.tsx+24-5
......@@ -55,7 +55,12 @@ import {
5555export type ComposerDraftContextRecord =
5656 | { kind: "terminal"; record: TerminalContextDraft }
5757 | { 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 }
5964 | { kind: "image"; record: ComposerImageAttachment; upload?: AttachmentUploadState | undefined }
6065 | { kind: "file"; record: ComposerFileAttachment; upload?: AttachmentUploadState | undefined }
6166 | { kind: "thread"; record: ThreadContextRecord };
......@@ -122,8 +127,13 @@ export function composerContextRecordsFromDraft(input: {
122127 for (const record of input.reviewComments ?? []) {
123128 records.set(reviewCommentContextId(record.id), { kind: "review-comment", record });
124129 }
130 const imagesById = new Map((input.images ?? []).map((image) => [image.id, image]));
125131 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 });
127137 }
128138 for (const record of input.threadContexts ?? []) {
129139 records.set(record.contextId, { kind: "thread", record });
......@@ -291,15 +301,19 @@ function ComposerReviewCommentDetails({ comment }: { comment: ReviewCommentConte
291301
292302function ComposerPreviewAnnotationDetails({
293303 annotation,
304 screenshot,
294305}: {
295306 annotation: PreviewAnnotationPayload;
307 screenshot: ComposerImageAttachment | undefined;
296308}) {
297309 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;
298312 return (
299313 <div className="overflow-hidden rounded-lg border border-border/70 bg-background/70">
300 {annotation.screenshot?.dataUrl ? (
314 {screenshotUrl ? (
301315 <img
302 src={annotation.screenshot.dataUrl}
316 src={screenshotUrl}
303317 alt="Annotated preview crop"
304318 className="max-h-64 w-full border-border/70 border-b bg-muted object-contain"
305319 />
......@@ -406,7 +420,12 @@ const composerContextPresentationRegistry = createContextPresentationRegistry<
406420 icon={<MousePointerClickIcon />}
407421 label={previewAnnotationContextLabel(entry.record)}
408422 kindLabel="Preview annotation"
409 details={<ComposerPreviewAnnotationDetails annotation={entry.record} />}
423 details={
424 <ComposerPreviewAnnotationDetails
425 annotation={entry.record}
426 screenshot={entry.screenshot}
427 />
428 }
410429 detailsMode={definition.capabilities.details}
411430 kind="preview-annotation"
412431 />
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"
1313import * as browserDefaults from "~/browser/browserDefaults";
1414import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview";
1515import { __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
1824import { openPreviewSession } from "./openPreviewSession";
1925
......@@ -37,6 +43,7 @@ const snapshot: PreviewSessionSnapshot = {
3743
3844beforeEach(() => {
3945 resetPreviewStateForTests();
46 useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} });
4047 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
4148});
4249
......@@ -140,3 +147,82 @@ describe("openPreviewSession", () => {
140147 },
141148 );
142149});
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 {
658658 createdAt: string;
659659}
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
661669/** Recording decorations are forwarded separately from the captured page pixels. */
662670export const DesktopPreviewRecordingInputSchema = Schema.Union([
663671 Schema.Struct({
......@@ -1331,6 +1339,7 @@ export interface DesktopPreviewBridge {
13311339 };
13321340 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
13331341 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
1342 onOpenLink: (listener: (event: DesktopPreviewOpenLinkEvent) => void) => () => void;
13341343}
13351344
13361345export type ConfirmDialogVariant = "default" | "destructive";
pnpm-lock.yaml+11-11
......@@ -185,7 +185,7 @@ importers:
185185 dependencies:
186186 '@clerk/electron':
187187 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)
189189 '@clerk/electron-passkeys':
190190 specifier: 0.0.3
191191 version: 0.0.3
......@@ -220,8 +220,8 @@ importers:
220220 specifier: 4.0.1
221221 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
222222 electron:
223 specifier: 44.4.2
224 version: 44.4.2
223 specifier: 44.4.5
224 version: 44.4.5
225225 electron-store:
226226 specifier: ^8.2.0
227227 version: 8.2.0
......@@ -687,7 +687,7 @@ importers:
687687 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)
688688 '@clerk/electron':
689689 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)
691691 '@clerk/react':
692692 specifier: 6.16.1
693693 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
......@@ -7727,8 +7727,8 @@ packages:
77277727 resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
77287728 engines: {node: '>=8.0.0'}
77297729
7730 electron@44.4.2:
7731 resolution: {integrity: sha512-872t/+8M8TyribWm2cYpRwHY2aDlyDHOn+CHnhN9kHQStVW8pAijIhFolYA/qQyDdri28Xp8Mi3HEj5kFvajCw==}
7730 electron@44.4.5:
7731 resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
77327732 engines: {node: '>= 22.12.0'}
77337733 hasBin: true
77347734
......@@ -12858,12 +12858,12 @@ snapshots:
1285812858 '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3
1285912859 '@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)':
1286212862 dependencies:
1286312863 '@clerk/clerk-js': 6.32.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
1286412864 '@clerk/react': 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
1286512865 '@clerk/shared': 4.33.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
12866 electron: 44.4.2
12866 electron: 44.4.5
1286712867 react: 19.2.6
1286812868 tslib: 2.8.1
1286912869 optionalDependencies:
......@@ -12871,12 +12871,12 @@ snapshots:
1287112871 electron-store: 8.2.0
1287212872 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)':
1287512875 dependencies:
1287612876 '@clerk/clerk-js': 6.32.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6)
1287712877 '@clerk/react': 6.16.1(react-dom@19.3.0(react@19.3.0))(react@19.2.6)
1287812878 '@clerk/shared': 4.33.0(react-dom@19.3.0(react@19.3.0))(react@19.2.6)
12879 electron: 44.4.2
12879 electron: 44.4.5
1288012880 react: 19.2.6
1288112881 tslib: 2.8.1
1288212882 optionalDependencies:
......@@ -18180,7 +18180,7 @@ snapshots:
1818018180 transitivePeerDependencies:
1818118181 - supports-color
1818218182
18183 electron@44.4.2:
18183 electron@44.4.5:
1818418184 dependencies:
1818518185 '@electron-internal/extract-zip': 1.0.5
1818618186 '@electron/get': 5.1.0
pnpm-workspace.yaml+1-1
......@@ -130,7 +130,7 @@ minimumReleaseAgeExclude:
130130 - expo-sharing@58.0.13
131131 - expo-sqlite@58.0.8
132132 - expo-symbols@58.0.3
133 - electron@44.4.2
133 - electron@44.4.5
134134 - "@anthropic-ai/claude-agent-sdk@0.3.276"
135135 - "@alchemy.run/cloudflare-runtime@2.0.0-beta.80"
136136 - "@alchemy.run/floci@2.0.0-beta.80"