| author | |
| committer | |
| log | b6c590af389d09400fa4f99a56ffc33e9ed4ebf1 |
| tree | 3c3adf424d641cfbbfb55fd31ad96e13aa5f3301 |
| parent | b51aef10c4f21c35f195b81e548c052041a15051 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 108 insertions(+), 25 deletions(-)
apps/desktop/src/ipc/methods/preview.ts+11-18| ... | ... | @@ -19,37 +19,30 @@ import { |
| 19 | 19 | PreviewAutomationSnapshot, |
| 20 | 20 | PreviewAutomationStatus, |
| 21 | 21 | } from "@t3tools/contracts"; |
| 22 | import { BrowserWindow } from "electron"; | |
| 23 | 22 | import * as Effect from "effect/Effect"; |
| 24 | 23 | import * as Schema from "effect/Schema"; |
| 25 | 24 | import * as NodeURL from "node:url"; |
| 26 | 25 | |
| 26 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | |
| 27 | 27 | import * as PreviewManager from "../../preview/Manager.ts"; |
| 28 | 28 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; |
| 29 | 29 | import * as IpcChannels from "../channels.ts"; |
| 30 | 30 | import * as DesktopIpc from "../DesktopIpc.ts"; |
| 31 | 31 | |
| 32 | const broadcast = (channel: string, ...args: ReadonlyArray<unknown>): void => { | |
| 33 | for (const window of BrowserWindow.getAllWindows()) { | |
| 34 | if (!window.isDestroyed()) { | |
| 35 | window.webContents.send(channel, ...args); | |
| 36 | } | |
| 37 | } | |
| 38 | }; | |
| 39 | ||
| 40 | 32 | export const installPreviewEventForwarding = Effect.fn( |
| 41 | 33 | "desktop.ipc.preview.installEventForwarding", |
| 42 | 34 | )(function* () { |
| 35 | const electronWindow = yield* ElectronWindow.ElectronWindow; | |
| 43 | 36 | const manager = yield* PreviewManager.PreviewManager; |
| 44 | yield* manager.subscribeStateChanges((tabId, state) => { | |
| 45 | broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); | |
| 46 | }); | |
| 47 | yield* manager.subscribeRecordingFrames((frame) => { | |
| 48 | broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); | |
| 49 | }); | |
| 50 | yield* manager.subscribePointerEvents((event) => { | |
| 51 | broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); | |
| 52 | }); | |
| 37 | yield* manager.subscribeStateChanges((tabId, state) => | |
| 38 | electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), | |
| 39 | ); | |
| 40 | yield* manager.subscribeRecordingFrames((frame) => | |
| 41 | electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), | |
| 42 | ); | |
| 43 | yield* manager.subscribePointerEvents((event) => | |
| 44 | electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), | |
| 45 | ); | |
| 53 | 46 | }); |
| 54 | 47 | |
| 55 | 48 | export const createTab = DesktopIpc.makeIpcMethod({ |
apps/desktop/src/preview/Manager.test.ts+73-1| ... | ... | @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; |
| 5 | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 6 | import * as Fiber from "effect/Fiber"; |
| 7 | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Logger from "effect/Logger"; | |
| 8 | 9 | import * as Option from "effect/Option"; |
| 9 | 10 | import * as Path from "effect/Path"; |
| 10 | 11 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -13,6 +14,7 @@ import { TestClock } from "effect/testing"; |
| 13 | 14 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 14 | 15 | |
| 15 | 16 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 17 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| 16 | 18 | import * as BrowserSession from "./BrowserSession.ts"; |
| 17 | 19 | import * as PreviewManager from "./Manager.ts"; |
| 18 | 20 | |
| ... | ... | @@ -130,6 +132,72 @@ describe("PreviewManager", () => { |
| 130 | 132 | ), |
| 131 | 133 | ); |
| 132 | 134 | |
| 135 | effectIt.effect("isolates failed state listeners and continues delivery", () => { | |
| 136 | const loggedErrors: Array<unknown> = []; | |
| 137 | const logger = Logger.make(({ message }) => { | |
| 138 | for (const value of Array.isArray(message) ? message : [message]) { | |
| 139 | if (typeof value === "object" && value !== null && "cause" in value) { | |
| 140 | loggedErrors.push(Cause.squash(value.cause as Cause.Cause<never>)); | |
| 141 | } | |
| 142 | } | |
| 143 | }); | |
| 144 | const deliveryError = new ElectronWindow.ElectronWindowOperationError({ | |
| 145 | operation: "send-window-message", | |
| 146 | platform: "darwin", | |
| 147 | windowId: 42, | |
| 148 | channel: "preview:state-change", | |
| 149 | cause: new Error("renderer unavailable"), | |
| 150 | }); | |
| 151 | const delivered = vi.fn(); | |
| 152 | ||
| 153 | return withManager((manager) => | |
| 154 | Effect.gen(function* () { | |
| 155 | yield* manager.subscribeStateChanges(() => Effect.die(deliveryError)); | |
| 156 | yield* manager.subscribeStateChanges((tabId, state) => | |
| 157 | Effect.sync(() => { | |
| 158 | delivered(tabId, state); | |
| 159 | }), | |
| 160 | ); | |
| 161 | ||
| 162 | const state = yield* manager.createTab("tab_listener_failure"); | |
| 163 | ||
| 164 | expect(delivered).toHaveBeenCalledOnce(); | |
| 165 | expect(delivered).toHaveBeenCalledWith("tab_listener_failure", state); | |
| 166 | expect(loggedErrors).toHaveLength(1); | |
| 167 | expect(loggedErrors[0]).toBeInstanceOf(ElectronWindow.ElectronWindowOperationError); | |
| 168 | expect(loggedErrors[0]).toMatchObject({ | |
| 169 | operation: "send-window-message", | |
| 170 | windowId: 42, | |
| 171 | channel: "preview:state-change", | |
| 172 | }); | |
| 173 | }), | |
| 174 | ).pipe( | |
| 175 | Effect.provide( | |
| 176 | Logger.layer([logger], { | |
| 177 | mergeWithExisting: false, | |
| 178 | }), | |
| 179 | ), | |
| 180 | ); | |
| 181 | }); | |
| 182 | ||
| 183 | effectIt.effect("does not swallow state listener interruption", () => | |
| 184 | withManager((manager) => | |
| 185 | Effect.gen(function* () { | |
| 186 | const exit = yield* Effect.scoped( | |
| 187 | Effect.gen(function* () { | |
| 188 | yield* manager.subscribeStateChanges(() => Effect.interrupt); | |
| 189 | return yield* Effect.exit(manager.createTab("tab_interrupted_listener")); | |
| 190 | }), | |
| 191 | ); | |
| 192 | ||
| 193 | expect(Exit.isFailure(exit)).toBe(true); | |
| 194 | if (Exit.isFailure(exit)) { | |
| 195 | expect(Cause.hasInterrupts(exit.cause)).toBe(true); | |
| 196 | } | |
| 197 | }), | |
| 198 | ), | |
| 199 | ); | |
| 200 | ||
| 133 | 201 | effectIt.effect("queues navigation until the webview registers", () => |
| 134 | 202 | withManager((manager) => |
| 135 | 203 | Effect.gen(function* () { |
| ... | ... | @@ -411,7 +479,11 @@ describe("PreviewManager", () => { |
| 411 | 479 | }, |
| 412 | 480 | } as never); |
| 413 | 481 | |
| 414 | yield* manager.subscribePointerEvents((event) => activity.push(event.phase)); | |
| 482 | yield* manager.subscribePointerEvents((event) => | |
| 483 | Effect.sync(() => { | |
| 484 | activity.push(event.phase); | |
| 485 | }), | |
| 486 | ); | |
| 415 | 487 | yield* manager.createTab("tab_1"); |
| 416 | 488 | yield* manager.registerWebview("tab_1", 42); |
| 417 | 489 | const click = yield* manager |
apps/desktop/src/preview/Manager.ts+24-6| ... | ... | @@ -281,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { |
| 281 | 281 | return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; |
| 282 | 282 | }; |
| 283 | 283 | |
| 284 | type Listener = (tabId: string, state: PreviewTabState) => void; | |
| 285 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; | |
| 284 | type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>; | |
| 285 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>; | |
| 286 | 286 | |
| 287 | 287 | type PreviewInputSignal = |
| 288 | 288 | | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } |
| ... | ... | @@ -313,7 +313,7 @@ interface BrowserDiagnostics { |
| 313 | 313 | readonly requests: ReadonlyMap<string, { url: string; method: string }>; |
| 314 | 314 | } |
| 315 | 315 | |
| 316 | type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; | |
| 316 | type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>; | |
| 317 | 317 | |
| 318 | 318 | interface ExpectedAgentInput { |
| 319 | 319 | readonly signal: PreviewInputSignal; |
| ... | ... | @@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 442 | 442 | return copy; |
| 443 | 443 | }; |
| 444 | 444 | |
| 445 | const deliverEvent = ( | |
| 446 | eventKind: "state-change" | "recording-frame" | "pointer-event", | |
| 447 | tabId: string, | |
| 448 | delivery: () => Effect.Effect<void>, | |
| 449 | ) => | |
| 450 | Effect.suspend(delivery).pipe( | |
| 451 | Effect.catchCause((cause) => | |
| 452 | Cause.hasInterrupts(cause) | |
| 453 | ? Effect.failCause(cause) | |
| 454 | : Effect.logWarning("Desktop preview event listener failed.", { | |
| 455 | eventKind, | |
| 456 | tabId, | |
| 457 | cause, | |
| 458 | }), | |
| 459 | ), | |
| 460 | ); | |
| 461 | ||
| 445 | 462 | const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { |
| 446 | 463 | const listeners = yield* Ref.get(listenersRef); |
| 447 | 464 | yield* Effect.forEach( |
| 448 | 465 | listeners, |
| 449 | (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), | |
| 466 | (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)), | |
| 450 | 467 | { discard: true }, |
| 451 | 468 | ); |
| 452 | 469 | }); |
| ... | ... | @@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 739 | 756 | }; |
| 740 | 757 | yield* Effect.forEach( |
| 741 | 758 | listeners, |
| 742 | (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), | |
| 759 | (listener) => | |
| 760 | deliverEvent("recording-frame", frame.tabId, () => listener(frame)), | |
| 743 | 761 | { discard: true }, |
| 744 | 762 | ); |
| 745 | 763 | } |
| ... | ... | @@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1918 | 1936 | const listeners = yield* Ref.get(pointerEventListenersRef); |
| 1919 | 1937 | yield* Effect.forEach( |
| 1920 | 1938 | listeners, |
| 1921 | (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), | |
| 1939 | (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), | |
| 1922 | 1940 | { discard: true }, |
| 1923 | 1941 | ); |
| 1924 | 1942 | }); |