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