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